add health import

This commit is contained in:
2026-05-19 14:50:19 +02:00
parent bc6e850afb
commit e36f27da4a
12 changed files with 1843 additions and 14 deletions
+827 -2
View File
@@ -40,7 +40,7 @@ final class App
$this->triggerReminderCheckFromTraffic($method, $path);
$hasUsers = $this->users->hasAnyUsers();
$isAuthenticated = $this->auth->check();
$systemPaths = ['/reminders/run'];
$systemPaths = ['/reminders/run', '/api/health'];
// A failed setup must never leave the app in a half-authenticated redirect loop.
if (!$hasUsers && $isAuthenticated) {
@@ -141,6 +141,22 @@ final class App
$this->handleReminderRun();
return;
case '/api/health':
if ($method !== 'POST') {
json_response(['ok' => false, 'message' => 'Method Not Allowed'], 405);
}
$this->handleHealthImport();
return;
case '/api/health/status':
if ($method !== 'GET') {
json_response(['ok' => false, 'message' => 'Method Not Allowed'], 405);
}
$this->handleHealthImportStatus();
return;
default:
http_response_code(404);
View::render('not-found', [
@@ -234,6 +250,631 @@ final class App
redirect('/');
}
private function handleHealthImport(): void
{
ignore_user_abort(true);
@set_time_limit(0);
$token = $this->healthImportBearerToken();
if ($token === '') {
json_response(['ok' => false, 'message' => 'Bearer-Token fehlt.'], 401);
}
$user = $this->users->findByHealthImportToken($token);
if ($user === null) {
json_response(['ok' => false, 'message' => 'Bearer-Token ist ungültig.'], 401);
}
$username = (string) ($user['username'] ?? '');
$payload = request_json_body();
if ($payload === []) {
$this->users->recordHealthImport($username, 'error', 'Leerer oder ungültiger JSON-Import.');
json_response(['ok' => false, 'message' => 'Leerer oder ungültiger JSON-Import.'], 400);
}
try {
$settings = $this->hydrateSettings($this->settings->forUser($username));
$result = $this->importHealthPayload($username, $settings, $payload);
$message = sprintf(
'Importiert: %d Tage, %d Schlaf, %d Sport, %d Spaziergänge.',
(int) $result['days'],
(int) $result['sleep'],
(int) $result['sport'],
(int) $result['walk']
);
$this->users->recordHealthImport($username, 'ok', $message);
json_response(['ok' => true, 'message' => $message, 'result' => $result]);
} catch (RuntimeException $exception) {
$this->users->recordHealthImport($username, 'error', $exception->getMessage());
json_response(['ok' => false, 'message' => $exception->getMessage()], 400);
}
}
private function handleHealthImportStatus(): void
{
$user = $this->requireUser();
json_response([
'ok' => true,
'status' => $this->users->healthImportConfig((string) ($user['username'] ?? '')),
]);
}
private function healthImportBearerToken(): string
{
$header = trim((string) ($_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? ''));
if (preg_match('/^Bearer\s+(.+)$/i', $header, $matches) === 1) {
return trim((string) ($matches[1] ?? ''));
}
return trim((string) ($_SERVER['HTTP_X_MOOD_HEALTH_TOKEN'] ?? ''));
}
private function importHealthPayload(string $username, array $settings, array $payload): array
{
$metrics = $this->healthMetricsFromPayload($payload);
$workouts = $this->healthWorkoutsFromPayload($payload);
$metricImport = $this->healthEventsFromMetrics($metrics);
$workoutImport = $this->healthEventsFromWorkouts($workouts, $settings);
$entries = $this->entries->all($username);
$entryMap = [];
foreach ($entries as $entry) {
if (is_array($entry) && $this->isValidDate((string) ($entry['date'] ?? ''))) {
$entryMap[(string) $entry['date']] = $entry;
}
}
$dates = array_unique(array_merge(
array_keys($metricImport['steps']),
array_keys($metricImport['sleep']),
array_keys($workoutImport['sport']),
array_keys($workoutImport['walk'])
));
sort($dates, SORT_STRING);
if ($dates === []) {
throw new RuntimeException('Der Import enthielt keine unterstützten Health-Daten.');
}
$totalItems = max(1, $this->countHealthImportItems($metricImport, $workoutImport));
$processedItems = 0;
$startedAt = date(DATE_ATOM);
$this->users->recordHealthImportProgress($username, 'Import vorbereitet.', 0, $totalItems, $startedAt);
$sleepCount = 0;
$sportCount = 0;
$walkCount = 0;
$now = date(DATE_ATOM);
foreach ($dates as $date) {
if (!$this->isValidDate((string) $date)) {
continue;
}
$current = $entryMap[$date] ?? $this->scoring->normalize([
'date' => $date,
'pain_enabled' => !empty($settings['tracking']['pain_enabled']),
'walk_mode' => (string) ($settings['walk']['mode'] ?? 'time'),
'summary' => ['comment' => '', 'mood' => 0, 'energy' => 0, 'stress' => 0, 'alcohol' => false],
'events' => [],
'background_image' => '',
]);
$events = array_values(array_filter(
is_array($current['events'] ?? null) ? $current['events'] : [],
'is_array'
));
if (isset($metricImport['steps'][$date])) {
$health = is_array($current['health'] ?? null) ? $current['health'] : [];
$health['steps'] = max(0, (int) $metricImport['steps'][$date]);
$health['steps_imported_at'] = $now;
$current['health'] = $health;
$processedItems++;
}
if (isset($metricImport['sleep'][$date])) {
$events = array_values(array_filter($events, static fn (array $event): bool => (string) ($event['type'] ?? '') !== 'sleep'));
foreach ($metricImport['sleep'][$date] as $event) {
$events[] = $event;
$sleepCount++;
$processedItems++;
}
}
if (isset($workoutImport['sport'][$date])) {
$events = array_values(array_filter($events, static fn (array $event): bool => (string) ($event['type'] ?? '') !== 'sport'));
foreach ($workoutImport['sport'][$date] as $event) {
$events[] = $event;
$sportCount++;
$processedItems++;
}
}
if (isset($workoutImport['walk'][$date])) {
$importIDs = [];
foreach ($workoutImport['walk'][$date] as $event) {
$importID = (string) ($event['import_id'] ?? '');
if ($importID !== '') {
$importIDs[$importID] = true;
}
}
$events = array_values(array_filter($events, static function (array $event) use ($importIDs): bool {
$importID = (string) ($event['import_id'] ?? '');
return $importID === '' || !isset($importIDs[$importID]);
}));
foreach ($workoutImport['walk'][$date] as $event) {
$events[] = $event;
$walkCount++;
$processedItems++;
}
}
$current['events'] = $events;
$entryMap[$date] = $current;
$this->users->recordHealthImportProgress(
$username,
'Verarbeite ' . format_display_date((string) $date, false) . '.',
$processedItems,
$totalItems,
$startedAt
);
}
if (!empty($workoutImport['settings_changed'])) {
$this->settings->saveForUser($username, $settings);
}
$this->persistUserEntries($username, $settings, array_values($entryMap));
return [
'days' => count($dates),
'steps' => count($metricImport['steps']),
'sleep' => $sleepCount,
'sport' => $sportCount,
'walk' => $walkCount,
'sport_types_added' => (int) ($workoutImport['sport_types_added'] ?? 0),
];
}
private function countHealthImportItems(array $metricImport, array $workoutImport): int
{
return count($metricImport['steps'] ?? [])
+ array_sum(array_map('count', $metricImport['sleep'] ?? []))
+ array_sum(array_map('count', $workoutImport['sport'] ?? []))
+ array_sum(array_map('count', $workoutImport['walk'] ?? []));
}
private function healthMetricsFromPayload(array $payload): array
{
return array_values(array_filter(
is_array($payload['metrics'] ?? null) ? $payload['metrics'] : [],
'is_array'
));
}
private function healthWorkoutsFromPayload(array $payload): array
{
if (is_array($payload['workouts'] ?? null)) {
return array_values(array_filter($payload['workouts'], 'is_array'));
}
if (array_is_list($payload) && isset($payload[0]) && is_array($payload[0])) {
return array_values(array_filter($payload, 'is_array'));
}
return [];
}
private function healthEventsFromMetrics(array $metrics): array
{
$steps = [];
$sleepBuckets = [];
foreach ($metrics as $metric) {
$name = strtolower((string) ($metric['name'] ?? ''));
$data = array_values(array_filter(is_array($metric['data'] ?? null) ? $metric['data'] : [], 'is_array'));
if ($name === 'step_count') {
foreach ($data as $point) {
$date = $this->healthPointDate($point['date'] ?? null);
if ($date === null) {
continue;
}
$steps[$date] = ($steps[$date] ?? 0) + max(0, (int) round((float) ($point['qty'] ?? 0)));
}
}
if ($name === 'sleep_analysis') {
foreach ($data as $point) {
$date = $this->healthPointDate($point['date'] ?? ($point['sleepEnd'] ?? ($point['endDate'] ?? null)));
if ($date === null) {
continue;
}
$hours = $this->healthSleepHours($point);
if ($hours <= 0) {
continue;
}
$bucket = $sleepBuckets[$date] ?? [
'hours' => 0.0,
'start' => null,
'end' => null,
'core' => 0.0,
'deep' => 0.0,
'rem' => 0.0,
];
if (isset($point['totalSleep']) || isset($point['asleep'])) {
$bucket['hours'] = max((float) $bucket['hours'], $hours);
} else {
$bucket['hours'] = (float) $bucket['hours'] + $hours;
}
foreach (['core', 'deep', 'rem'] as $phase) {
if (is_numeric($point[$phase] ?? null)) {
$bucket[$phase] = max((float) $bucket[$phase], (float) $point[$phase]);
}
}
$start = $this->healthDateTime($point['sleepStart'] ?? ($point['inBedStart'] ?? ($point['startDate'] ?? null)));
$end = $this->healthDateTime($point['sleepEnd'] ?? ($point['inBedEnd'] ?? ($point['endDate'] ?? null)));
if ($start !== null && ($bucket['start'] === null || $start < $bucket['start'])) {
$bucket['start'] = $start;
}
if ($end !== null && ($bucket['end'] === null || $end > $bucket['end'])) {
$bucket['end'] = $end;
}
$sleepBuckets[$date] = $bucket;
}
}
}
$sleep = [];
foreach ($sleepBuckets as $date => $bucket) {
$commentParts = ['Automatisch importierter Schlaf'];
if ($bucket['start'] instanceof DateTimeImmutable && $bucket['end'] instanceof DateTimeImmutable) {
$commentParts[] = $bucket['start']->format('H:i') . '-' . $bucket['end']->format('H:i');
}
foreach (['deep' => 'Tief', 'rem' => 'REM', 'core' => 'Kern'] as $phase => $label) {
if ((float) ($bucket[$phase] ?? 0) > 0) {
$commentParts[] = $label . ' ' . format_points((float) $bucket[$phase]) . ' h';
}
}
$sleep[$date][] = [
'id' => 'health-sleep-' . $date,
'type' => 'sleep',
'time' => $bucket['start'] instanceof DateTimeImmutable ? $bucket['start']->format('H:i') : '',
'comment' => implode(' · ', $commentParts),
'value' => round((float) $bucket['hours'], 2),
'unit' => 'h',
'sport_type_id' => '',
'consumed' => true,
'mood' => 0,
'energy' => 0,
'stress' => 0,
'source' => 'health_auto_export',
'import_id' => 'health-sleep-' . $date,
'route' => [],
];
}
return [
'steps' => $steps,
'sleep' => $sleep,
];
}
private function healthEventsFromWorkouts(array $workouts, array &$settings): array
{
$sport = [];
$walk = [];
$settingsChanged = false;
$sportTypesAdded = 0;
foreach ($workouts as $workout) {
$start = $this->healthDateTime($workout['start'] ?? null);
$end = $this->healthDateTime($workout['end'] ?? null);
if ($start === null) {
continue;
}
$date = $start->format('Y-m-d');
$duration = is_numeric($workout['duration'] ?? null)
? ((float) $workout['duration'] / 60)
: ($end !== null ? max(0, ($end->getTimestamp() - $start->getTimestamp()) / 60) : 0);
if ($duration <= 0) {
continue;
}
$name = trim((string) ($workout['name'] ?? 'Workout')) ?: 'Workout';
$importID = 'health-workout-' . trim((string) ($workout['id'] ?? sha1($name . $start->format(DATE_ATOM) . (string) ($workout['duration'] ?? ''))));
$route = $this->healthRouteFromWorkout($workout);
$comment = $this->healthWorkoutComment($name, $workout, $start, $end);
$durationLabel = format_points($duration) . ' min';
$distanceLabel = $this->healthQuantityLabel($workout['distance'] ?? null);
$energyLabel = $this->healthQuantityLabel($workout['activeEnergyBurned'] ?? ($workout['activeEnergy'] ?? null));
$heartRate = $workout['heartRate']['avg']['qty'] ?? ($workout['avgHeartRate']['qty'] ?? null);
$heartRateLabel = is_numeric($heartRate) ? 'Ø Puls ' . (string) round((float) $heartRate) . ' bpm' : '';
if ($this->healthWorkoutIsWalk($name)) {
$walk[$date][] = [
'id' => 'health-walk-' . substr(sha1($importID), 0, 12),
'type' => 'walk',
'time' => $start->format('H:i'),
'comment' => $comment,
'value' => round($duration),
'unit' => 'min',
'sport_type_id' => '',
'consumed' => true,
'mood' => 0,
'energy' => 0,
'stress' => 0,
'source' => 'health_auto_export',
'import_id' => $importID,
'duration_label' => $durationLabel,
'distance_label' => $distanceLabel,
'energy_label' => $energyLabel,
'heart_rate_label' => $heartRateLabel,
'route' => $route,
];
continue;
}
[$sportTypeID, $wasAdded] = $this->healthSportTypeForWorkout($name, $settings);
if ($wasAdded) {
$settingsChanged = true;
$sportTypesAdded++;
}
$sport[$date][] = [
'id' => 'health-sport-' . substr(sha1($importID), 0, 12),
'type' => 'sport',
'time' => $start->format('H:i'),
'comment' => $comment,
'value' => round($duration),
'unit' => 'min',
'sport_type_id' => $sportTypeID,
'consumed' => true,
'mood' => 0,
'energy' => 0,
'stress' => 0,
'source' => 'health_auto_export',
'import_id' => $importID,
'duration_label' => $durationLabel,
'distance_label' => $distanceLabel,
'energy_label' => $energyLabel,
'heart_rate_label' => $heartRateLabel,
'route' => $route,
];
}
return [
'sport' => $sport,
'walk' => $walk,
'settings_changed' => $settingsChanged,
'sport_types_added' => $sportTypesAdded,
];
}
private function healthPointDate(mixed $value): ?string
{
$dateTime = $this->healthDateTime($value);
return $dateTime?->format('Y-m-d');
}
private function healthDateTime(mixed $value): ?DateTimeImmutable
{
$raw = trim((string) $value);
if ($raw === '') {
return null;
}
$timezone = new DateTimeZone(date_default_timezone_get());
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $raw) === 1) {
$date = DateTimeImmutable::createFromFormat('!Y-m-d', $raw, $timezone);
return $date instanceof DateTimeImmutable ? $date : null;
}
try {
return (new DateTimeImmutable($raw))->setTimezone($timezone);
} catch (Exception) {
return null;
}
}
private function healthSleepHours(array $point): float
{
foreach (['totalSleep', 'asleep', 'qty', 'inBed'] as $key) {
if (is_numeric($point[$key] ?? null)) {
return max(0.0, min(24.0, (float) $point[$key]));
}
}
$start = $this->healthDateTime($point['sleepStart'] ?? ($point['startDate'] ?? null));
$end = $this->healthDateTime($point['sleepEnd'] ?? ($point['endDate'] ?? null));
if ($start !== null && $end !== null && $end > $start) {
return min(24.0, ($end->getTimestamp() - $start->getTimestamp()) / 3600);
}
return 0.0;
}
private function healthWorkoutIsWalk(string $name): bool
{
$normalized = normalize_sport_type_id($name);
return in_array($normalized, ['walking', 'walk', 'outdoor-walk', 'indoor-walk'], true)
|| str_contains($normalized, 'walking');
}
private function healthSportTypeForWorkout(string $name, array &$settings): array
{
$mapping = $this->healthWorkoutSportMapping($name);
$candidates = array_values(array_unique(array_filter(array_merge(
[(string) $mapping['id'], normalize_sport_type_id($name)],
$mapping['aliases']
))));
foreach (normalized_sport_types($settings) as $type) {
$typeValues = array_filter([
normalize_sport_type_id((string) ($type['id'] ?? '')),
normalize_sport_type_id((string) ($type['label'] ?? '')),
normalize_sport_type_id((string) ($type['recovery_group'] ?? '')),
]);
foreach ($candidates as $candidate) {
foreach ($typeValues as $typeValue) {
if ($candidate === $typeValue || str_contains($typeValue, $candidate) || str_contains($candidate, $typeValue)) {
return [(string) ($type['id'] ?? $candidate), false];
}
}
if (in_array($candidate, $typeValues, true)) {
return [(string) ($type['id'] ?? $candidate), false];
}
}
}
$settings['sport_types'][] = [
'id' => (string) $mapping['id'],
'label' => (string) $mapping['label'],
'icon' => (string) $mapping['icon'],
'location' => '',
'recovery_group' => (string) $mapping['id'],
'bonus_points' => 2,
'allow_consecutive' => false,
];
$settings['sport_types'] = normalized_sport_types($settings);
return [(string) $mapping['id'], true];
}
private function healthWorkoutSportMapping(string $name): array
{
$normalized = normalize_sport_type_id($name);
$mappings = [
'running' => ['label' => 'Joggen', 'icon' => 'run', 'aliases' => ['running', 'run', 'jogging', 'joggen']],
'cycling' => ['label' => 'Radfahren', 'icon' => 'bike', 'aliases' => ['cycling', 'biking', 'bike', 'radfahren', 'fahrrad']],
'swimming' => ['label' => 'Schwimmen', 'icon' => 'swim', 'aliases' => ['swimming', 'swim', 'schwimmen']],
'hiking' => ['label' => 'Wandern', 'icon' => 'hike', 'aliases' => ['hiking', 'wandern', 'hike']],
'rowing' => ['label' => 'Rudergerät', 'icon' => 'row', 'aliases' => ['rowing', 'rower', 'rudergeraet', 'rudern']],
'yoga' => ['label' => 'Yoga', 'icon' => 'yoga', 'aliases' => ['yoga']],
'strength' => ['label' => 'Krafttraining', 'icon' => 'strength', 'aliases' => ['strength', 'strength-training', 'traditional-strength-training', 'functional-strength-training', 'krafttraining']],
'hiit-workout' => ['label' => 'HIIT / Workout', 'icon' => 'hiit', 'aliases' => ['hiit', 'high-intensity-interval-training', 'workout', 'cross-training', 'functional-training']],
'dance' => ['label' => 'Tanzen', 'icon' => 'dance', 'aliases' => ['dance', 'dancing', 'tanzen']],
'core' => ['label' => 'Core', 'icon' => 'core', 'aliases' => ['core', 'core-training']],
'pilates' => ['label' => 'Pilates', 'icon' => 'yoga', 'aliases' => ['pilates']],
'elliptical' => ['label' => 'Crosstrainer', 'icon' => 'hiit', 'aliases' => ['elliptical', 'cross-trainer', 'crosstrainer']],
'stair-climbing' => ['label' => 'Treppensteigen', 'icon' => 'hike', 'aliases' => ['stair-climbing', 'stairs', 'treppensteigen']],
];
foreach ($mappings as $id => $mapping) {
foreach ($mapping['aliases'] as $alias) {
if ($normalized === $alias || str_contains($normalized, $alias)) {
return ['id' => $id] + $mapping;
}
}
}
$id = normalize_sport_type_id($name) ?: 'workout';
return [
'id' => $id,
'label' => trim($name) !== '' ? trim($name) : 'Workout',
'icon' => 'hiit',
'aliases' => [$id],
];
}
private function healthWorkoutComment(string $name, array $workout, DateTimeImmutable $start, ?DateTimeImmutable $end): string
{
$parts = ['Apple Health · ' . $name];
if ($end !== null) {
$parts[] = $start->format('H:i') . '-' . $end->format('H:i');
}
$heartRate = $workout['heartRate']['avg']['qty'] ?? ($workout['avgHeartRate']['qty'] ?? null);
if (is_numeric($heartRate)) {
$parts[] = 'Ø Puls ' . (string) round((float) $heartRate) . ' bpm';
}
return implode(' · ', $parts);
}
private function healthQuantityLabel(mixed $quantity): string
{
if (!is_array($quantity) || !is_numeric($quantity['qty'] ?? null)) {
return '';
}
$qty = (float) $quantity['qty'];
$units = trim((string) ($quantity['units'] ?? ''));
if ($units === '') {
return format_points($qty);
}
return format_points($qty) . ' ' . $units;
}
private function healthRouteFromWorkout(array $workout): array
{
$route = is_array($workout['route'] ?? null) ? $workout['route'] : [];
$points = [];
foreach ($route as $point) {
if (!is_array($point)) {
continue;
}
$lat = $point['latitude'] ?? ($point['lat'] ?? null);
$lon = $point['longitude'] ?? ($point['lon'] ?? null);
if (!is_numeric($lat) || !is_numeric($lon)) {
continue;
}
$lat = (float) $lat;
$lon = (float) $lon;
if ($lat < -90 || $lat > 90 || $lon < -180 || $lon > 180) {
continue;
}
$points[] = ['lat' => round($lat, 6), 'lon' => round($lon, 6)];
}
if (count($points) <= 180) {
return $points;
}
$step = max(1, (int) floor(count($points) / 180));
$reduced = [];
foreach ($points as $index => $point) {
if ($index % $step === 0) {
$reduced[] = $point;
}
}
$last = $points[count($points) - 1];
if ($reduced === [] || $reduced[count($reduced) - 1] !== $last) {
$reduced[] = $last;
}
return $reduced;
}
private function showDashboard(): void
{
$user = $this->requireUser();
@@ -370,6 +1011,13 @@ final class App
if ((string) ($event['id'] ?? '') === $eventID) {
$updatedEvent['image'] = (string) ($event['image'] ?? '');
$updatedEvent['source'] = (string) ($event['source'] ?? '');
$updatedEvent['import_id'] = (string) ($event['import_id'] ?? '');
$updatedEvent['duration_label'] = (string) ($event['duration_label'] ?? '');
$updatedEvent['distance_label'] = (string) ($event['distance_label'] ?? '');
$updatedEvent['energy_label'] = (string) ($event['energy_label'] ?? '');
$updatedEvent['heart_rate_label'] = (string) ($event['heart_rate_label'] ?? '');
$updatedEvent['route'] = is_array($event['route'] ?? null) ? $event['route'] : [];
if (is_array($upload) && (int) ($upload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) {
$this->deleteDashboardImage($user['username'], (string) ($event['image'] ?? ''));
$updatedEvent['image'] = $this->storeDashboardImage($user['username'], $date, $upload);
@@ -473,12 +1121,106 @@ final class App
'mood' => normalize_signal_value($event['mood'] ?? 0),
'energy' => normalize_signal_value($event['energy'] ?? 0),
'stress' => normalize_signal_value($event['stress'] ?? 0),
'source' => (string) ($event['source'] ?? ''),
'import_id' => (string) ($event['import_id'] ?? ''),
'duration_label' => (string) ($event['duration_label'] ?? ''),
'distance_label' => (string) ($event['distance_label'] ?? ''),
'energy_label' => (string) ($event['energy_label'] ?? ''),
'heart_rate_label' => (string) ($event['heart_rate_label'] ?? ''),
'route_map' => $this->buildOsmRouteMap(is_array($event['route'] ?? null) ? $event['route'] : []),
];
}
return $timeline;
}
private function buildOsmRouteMap(array $route): ?array
{
$points = array_values(array_filter($route, static function (mixed $point): bool {
return is_array($point)
&& is_numeric($point['lat'] ?? null)
&& is_numeric($point['lon'] ?? null)
&& (float) $point['lat'] >= -90
&& (float) $point['lat'] <= 90
&& (float) $point['lon'] >= -180
&& (float) $point['lon'] <= 180;
}));
if (count($points) < 2) {
return null;
}
$width = 320;
$height = 168;
$tileSize = 256;
$padding = 24;
$zoom = 15;
for ($candidateZoom = 16; $candidateZoom >= 3; $candidateZoom--) {
$projected = array_map(fn (array $point): array => $this->projectOsmPoint((float) $point['lat'], (float) $point['lon'], $candidateZoom, $tileSize), $points);
$xs = array_column($projected, 'x');
$ys = array_column($projected, 'y');
$spanX = max($xs) - min($xs);
$spanY = max($ys) - min($ys);
if ($spanX <= ($width - ($padding * 2)) && $spanY <= ($height - ($padding * 2))) {
$zoom = $candidateZoom;
break;
}
}
$projected = array_map(fn (array $point): array => $this->projectOsmPoint((float) $point['lat'], (float) $point['lon'], $zoom, $tileSize), $points);
$xs = array_column($projected, 'x');
$ys = array_column($projected, 'y');
$left = ((min($xs) + max($xs)) / 2) - ($width / 2);
$top = ((min($ys) + max($ys)) / 2) - ($height / 2);
$tileMinX = (int) floor($left / $tileSize);
$tileMaxX = (int) floor(($left + $width) / $tileSize);
$tileMinY = (int) floor($top / $tileSize);
$tileMaxY = (int) floor(($top + $height) / $tileSize);
$tileLimit = 2 ** $zoom;
$tiles = [];
for ($x = $tileMinX; $x <= $tileMaxX; $x++) {
for ($y = $tileMinY; $y <= $tileMaxY; $y++) {
if ($y < 0 || $y >= $tileLimit) {
continue;
}
$wrappedX = (($x % $tileLimit) + $tileLimit) % $tileLimit;
$tiles[] = [
'url' => 'https://tile.openstreetmap.org/' . $zoom . '/' . $wrappedX . '/' . $y . '.png',
'left' => round(($x * $tileSize) - $left, 2),
'top' => round(($y * $tileSize) - $top, 2),
];
}
}
$linePoints = implode(' ', array_map(
static fn (array $point): string => round($point['x'] - $left, 1) . ',' . round($point['y'] - $top, 1),
$projected
));
return [
'width' => $width,
'height' => $height,
'tiles' => $tiles,
'line' => $linePoints,
];
}
private function projectOsmPoint(float $lat, float $lon, int $zoom, int $tileSize): array
{
$lat = max(-85.05112878, min(85.05112878, $lat));
$scale = (2 ** $zoom) * $tileSize;
$x = (($lon + 180.0) / 360.0) * $scale;
$latRad = deg2rad($lat);
$y = (0.5 - (log(tan($latRad) + (1 / cos($latRad))) / (2 * M_PI))) * $scale;
return ['x' => $x, 'y' => $y];
}
private function buildDashboardCompareDays(string $date, array $entryMap, array $settings): array
{
$days = [];
@@ -571,6 +1313,52 @@ final class App
'range' => $start->format('j.n.Y') . ' - ' . $start->modify('+6 day')->format('j.n.Y'),
'is_selected' => $isSelected,
'days' => $days,
'insights' => $this->buildWeekHealthInsights($start, $days, $entryMap),
];
}
private function buildWeekHealthInsights(DateTimeImmutable $start, array $days, array $entryMap): array
{
$weekSteps = [];
$sportMinutes = 0;
foreach ($days as $day) {
$entry = is_array($day['entry'] ?? null) ? $day['entry'] : null;
if ($entry === null) {
continue;
}
$steps = (int) ($entry['health']['steps'] ?? 0);
if ($steps > 0) {
$weekSteps[] = $steps;
}
$sportMinutes += (int) ($entry['sport_minutes'] ?? 0);
}
$weekAverageSteps = $weekSteps !== [] ? (int) round(array_sum($weekSteps) / count($weekSteps)) : 0;
$previousMonthStart = $start->modify('first day of previous month');
$previousMonthEnd = $previousMonthStart->modify('last day of this month');
$previousMonthSteps = [];
for ($day = $previousMonthStart; $day <= $previousMonthEnd; $day = $day->modify('+1 day')) {
$entry = $entryMap[$day->format('Y-m-d')] ?? null;
$steps = is_array($entry) ? (int) ($entry['health']['steps'] ?? 0) : 0;
if ($steps > 0) {
$previousMonthSteps[] = $steps;
}
}
$previousAverageSteps = $previousMonthSteps !== [] ? (int) round(array_sum($previousMonthSteps) / count($previousMonthSteps)) : 0;
$stepDifference = $previousAverageSteps > 0 ? $weekAverageSteps - $previousAverageSteps : 0;
return [
'average_steps' => $weekAverageSteps,
'previous_month_average_steps' => $previousAverageSteps,
'step_difference' => $stepDifference,
'step_direction' => $stepDifference >= 0 ? 'mehr' : 'weniger',
'daily_sport_minutes' => (int) round($sportMinutes / 7),
'has_step_comparison' => $weekAverageSteps > 0 && $previousAverageSteps > 0,
];
}
@@ -656,6 +1444,10 @@ final class App
return true;
}
if ((int) ($entry['health']['steps'] ?? 0) > 0) {
return true;
}
return count(array_filter(is_array($entry['events'] ?? null) ? $entry['events'] : [], 'is_array')) > 0;
}
@@ -1169,6 +1961,14 @@ final class App
}
}
$pendingHealthTokens = is_array($_SESSION['_health_import_token'] ?? null) ? $_SESSION['_health_import_token'] : [];
$healthImportToken = $pendingHealthTokens[$user['username']] ?? null;
if (is_string($healthImportToken)) {
unset($_SESSION['_health_import_token'][$user['username']]);
} else {
$healthImportToken = null;
}
$optionsOpenPanel = trim((string) ($_GET['panel'] ?? ''));
if ($optionsOpenPanel === 'score') {
$optionsOpenPanel = '';
@@ -1186,6 +1986,9 @@ final class App
'pushAvailable' => $pushAvailable,
'pushPublicKey' => $pushPublicKey,
'pushSubscriptionCount' => $this->notifications->subscriptionCount($user['username']),
'healthImportConfig' => $this->users->healthImportConfig($user['username']),
'healthImportToken' => $healthImportToken,
'healthImportUrl' => app_origin() . '/api/health',
'backupAvailable' => class_exists('ZipArchive'),
'aiConfig' => $user['is_admin'] ? $this->aiConfig->get() : null,
'aiStatus' => $user['is_admin'] ? $this->openAi->configuration() : null,
@@ -1258,6 +2061,19 @@ final class App
redirect('/options');
}
if ($form === 'health_import_token') {
$token = $this->users->issueHealthImportToken($user['username']);
$_SESSION['_health_import_token'][$user['username']] = $token;
flash('success', 'Der Health-Import-Token wurde erstellt. Kopiere ihn jetzt in Health Auto Export.');
redirect('/options?panel=health');
}
if ($form === 'health_import_revoke') {
$this->users->revokeHealthImportToken($user['username']);
flash('success', 'Der Health-Import-Token wurde deaktiviert.');
redirect('/options?panel=health');
}
if ($form === 'password') {
$current = (string) ($_POST['current_password'] ?? '');
$new = (string) ($_POST['new_password'] ?? '');
@@ -1690,6 +2506,15 @@ final class App
$settings['scoring']['pain_multiplier'] = max(0, min(10, (int) ($input['scoring']['pain_multiplier'] ?? ($settings['scoring']['pain_multiplier'] ?? 3))));
$settings['scoring']['sleep_feeling_multiplier'] = max(0, min(10, (int) ($input['scoring']['sleep_feeling_multiplier'] ?? 2)));
$settings['scoring']['journal_points'] = max(0, min(20, (int) ($input['scoring']['journal_points'] ?? 2)));
$stepBonus = is_array($settings['scoring']['step_bonus'] ?? null) ? $settings['scoring']['step_bonus'] : $defaults['scoring']['step_bonus'];
$settings['scoring']['step_bonus'] = [
'min' => max(0, min(100000, (int) ($input['scoring']['step_bonus']['min'] ?? $stepBonus['min'] ?? 10000))),
'max' => max(0, min(100000, (int) ($input['scoring']['step_bonus']['max'] ?? $stepBonus['max'] ?? 15000))),
'points' => max(0, min(20, (int) ($input['scoring']['step_bonus']['points'] ?? $stepBonus['points'] ?? 1))),
];
if ($settings['scoring']['step_bonus']['max'] < $settings['scoring']['step_bonus']['min']) {
$settings['scoring']['step_bonus']['max'] = $settings['scoring']['step_bonus']['min'];
}
$settings['tracking'] = [
'pain_enabled' => isset($input['tracking']['pain_enabled']) && $input['tracking']['pain_enabled'] === '1',
];
@@ -2300,7 +3125,7 @@ final class App
header('X-Robots-Tag: noindex, nofollow, noarchive, nosnippet');
header('Cross-Origin-Opener-Policy: same-origin');
header('Permissions-Policy: camera=(), microphone=(), geolocation=()');
header("Content-Security-Policy: default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'");
header("Content-Security-Policy: default-src 'self'; img-src 'self' data: https://tile.openstreetmap.org; style-src 'self'; script-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'");
}
private function enforceCsrf(): void