users = new UserRepository(); $this->settings = new SettingsRepository(); $this->entries = new EntryRepository(); $this->throttle = new LoginThrottle(); $this->scoring = new ScoringService(); $this->auth = new Auth($this->users); $this->notifications = new NotificationRepository(); $this->webPush = new WebPushService($this->notifications); $this->summaries = new SummaryRepository(); $this->aiConfig = new AiConfigRepository(); $this->openAi = new OpenAiSummaryService($this->aiConfig); } public function run(): void { $this->sendSecurityHeaders(); $path = request_path(); $method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; $this->triggerReminderCheckFromTraffic($method, $path); $hasUsers = $this->users->hasAnyUsers(); $isAuthenticated = $this->auth->check(); $systemPaths = ['/reminders/run', '/api/health', '/api/putzliga']; // A failed setup must never leave the app in a half-authenticated redirect loop. if (!$hasUsers && $isAuthenticated) { $this->auth->logout(); $isAuthenticated = false; } if (!$hasUsers) { if ($path === '/login') { $path = '/setup'; } elseif ($path !== '/setup' && !in_array($path, $systemPaths, true)) { redirect('/setup'); } } elseif (!$isAuthenticated) { if ($path === '/setup') { $path = '/login'; } elseif ($path !== '/login' && !in_array($path, $systemPaths, true)) { redirect('/login'); } } if ($isAuthenticated && in_array($path, ['/login', '/setup'], true)) { redirect('/'); } switch ($path) { case '/setup': $method === 'POST' ? $this->handleSetup() : $this->showSetup(); return; case '/login': $method === 'POST' ? $this->handleLogin() : $this->showLogin(); return; case '/logout': if ($method !== 'POST') { http_response_code(405); exit('Method Not Allowed'); } $this->enforceCsrf(); $this->auth->logout(); flash('success', 'Du wurdest abgemeldet.'); redirect('/login'); case '/': $method === 'POST' ? $this->handleDashboard() : $this->showDashboard(); return; case '/day-image': $this->serveDayImage(); return; case '/event-image': $this->serveEventImage(); return; case '/track': $method === 'POST' ? $this->handleTrack() : $this->showTrack(); return; case '/archive': $method === 'POST' ? $this->handleArchive() : $this->showArchive(); return; case '/options': $method === 'POST' ? $this->handleOptions() : $this->showOptions(); return; case '/push/subscribe': if ($method !== 'POST') { http_response_code(405); exit('Method Not Allowed'); } $this->handlePushSubscribe(); return; case '/push/unsubscribe': if ($method !== 'POST') { http_response_code(405); exit('Method Not Allowed'); } $this->handlePushUnsubscribe(); return; case '/push/test': if ($method !== 'POST') { http_response_code(405); exit('Method Not Allowed'); } $this->handlePushTest(); return; case '/reminders/run': $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; case '/api/putzliga': if ($method !== 'POST') { json_response(['ok' => false, 'message' => 'Method Not Allowed'], 405); } $this->handlePutzligaImport(); return; default: http_response_code(404); View::render('not-found', [ 'pageTitle' => 'Nicht gefunden', 'page' => 'not-found', 'authUser' => $this->auth->user(), ]); } } private function showSetup(): void { View::render('setup', [ 'pageTitle' => 'Setup', 'page' => 'setup', 'authUser' => null, ]); } private function handleSetup(): void { $this->enforceCsrf(); if ($this->users->hasAnyUsers()) { redirect('/login'); } $username = trim((string) ($_POST['username'] ?? '')); $password = (string) ($_POST['password'] ?? ''); $passwordConfirm = (string) ($_POST['password_confirm'] ?? ''); if (!$this->isValidUsername($username)) { flash('error', 'Bitte nutze einen Benutzernamen mit 3 bis 32 Zeichen aus Buchstaben, Zahlen, Punkt, Minus oder Unterstrich.'); redirect('/setup'); } if (!$this->isStrongPassword($password)) { flash('error', 'Das Passwort sollte mindestens 10 Zeichen lang sein.'); redirect('/setup'); } if ($password !== $passwordConfirm) { flash('error', 'Die Passwörter stimmen nicht überein.'); redirect('/setup'); } try { $user = $this->users->create($username, $password, true); $this->auth->login($user); flash('success', 'Der erste Account wurde erstellt. Du kannst direkt loslegen.'); redirect('/'); } catch (RuntimeException $exception) { flash('error', $exception->getMessage()); redirect('/setup'); } } private function showLogin(): void { View::render('login', [ 'pageTitle' => 'Login', 'page' => 'login', 'authUser' => null, ]); } private function handleLogin(): void { $this->enforceCsrf(); $username = trim((string) ($_POST['username'] ?? '')); $password = (string) ($_POST['password'] ?? ''); $throttleKey = $this->throttleKey($username); if ($this->throttle->tooManyAttempts($throttleKey)) { $seconds = $this->throttle->availableInSeconds($throttleKey); flash('error', 'Zu viele fehlgeschlagene Login-Versuche. Bitte warte ' . max(1, $seconds) . ' Sekunden.'); redirect('/login'); } $remember = isset($_POST['remember_me']) && $_POST['remember_me'] === '1'; if (!$this->auth->attempt($username, $password, $remember)) { $this->throttle->hit($throttleKey); flash('error', 'Login fehlgeschlagen. Bitte prüfe Benutzername und Passwort.'); redirect('/login'); } $this->throttle->clear($throttleKey); flash('success', 'Willkommen zurück.'); 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'] ?? ''); $rawBody = (string) file_get_contents('php://input'); $payload = $this->decodeHealthImportPayload($rawBody); if ($payload === []) { $traceID = $this->healthImportTraceID(); $message = 'Diagnose-ID: ' . $traceID . '. Leerer oder ungültiger JSON-Import.'; $this->logHealthImportFailure($traceID, $username, 'Leerer oder ungültiger JSON-Import.', [], strlen($rawBody)); $this->users->recordHealthImport($username, 'error', $message); json_response(['ok' => false, 'message' => $message], 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) { $traceID = $this->healthImportTraceID(); $message = 'Diagnose-ID: ' . $traceID . '. ' . $exception->getMessage(); $this->logHealthImportFailure($traceID, $username, $exception->getMessage(), $payload, strlen($rawBody)); $this->users->recordHealthImport($username, 'error', $message); json_response(['ok' => false, 'message' => $message], 400); } } private function decodeHealthImportPayload(string $rawBody): array { if (trim($rawBody) === '') { return []; } $decoded = json_decode($rawBody, true); return is_array($decoded) ? $decoded : []; } private function healthImportTraceID(): string { try { return substr(bin2hex(random_bytes(4)), 0, 8); } catch (Exception) { return substr(sha1((string) microtime(true)), 0, 8); } } private function logHealthImportFailure(string $traceID, string $username, string $message, array $payload, int $rawLength): void { $metrics = $payload === [] ? [] : $this->healthMetricsFromPayload($payload); $workouts = $payload === [] ? [] : $this->healthWorkoutsFromPayload($payload); $context = [ 'trace_id' => $traceID, 'user' => $username, 'message' => $message, 'method' => (string) ($_SERVER['REQUEST_METHOD'] ?? ''), 'path' => (string) ($_SERVER['REQUEST_URI'] ?? ''), 'content_type' => (string) ($_SERVER['CONTENT_TYPE'] ?? ($_SERVER['HTTP_CONTENT_TYPE'] ?? '')), 'content_length' => (string) ($_SERVER['CONTENT_LENGTH'] ?? ''), 'raw_length' => $rawLength, 'json_error' => json_last_error_msg(), 'user_agent' => (string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 'payload' => $this->healthPayloadDiagnostics($payload, $metrics, $workouts), ]; error_log('[Mood Health Import] ' . json_encode($context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); } private function handleHealthImportStatus(): void { $user = $this->requireUser(); json_response([ 'ok' => true, 'status' => $this->users->healthImportConfig((string) ($user['username'] ?? '')), ]); } private function handlePutzligaImport(): void { $token = $this->healthImportBearerToken(); if ($token === '') { json_response(['ok' => false, 'message' => 'Bearer-Token fehlt.'], 401); } $user = $this->users->findByPutzligaImportToken($token); if ($user === null) { json_response(['ok' => false, 'message' => 'Bearer-Token ist ungültig.'], 401); } $payload = $this->decodeHealthImportPayload((string) file_get_contents('php://input')); $date = (string) ($payload['date'] ?? ''); $tasksPayload = is_array($payload['tasks'] ?? null) ? $payload['tasks'] : []; $tasks = array_values(array_filter(array_map( static fn (mixed $task): string => trim((string) $task), $tasksPayload ), static fn (string $task): bool => $task !== '')); if (!$this->isValidDate($date)) { json_response(['ok' => false, 'message' => 'Datum fehlt oder ist ungültig.'], 400); } if (count($tasks) < 3) { json_response(['ok' => false, 'message' => 'Mindestens 3 erledigte Aufgaben sind nötig.'], 400); } $username = (string) ($user['username'] ?? ''); $settings = $this->hydrateSettings($this->settings->forUser($username)); $entries = $this->entries->all($username); $entryMap = []; foreach ($entries as $entry) { if (is_array($entry) && $this->isValidDate((string) ($entry['date'] ?? ''))) { $entryMap[(string) $entry['date']] = $entry; } } $entry = $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' => '', ]); $importID = 'putzliga-' . $date; $events = array_values(array_filter( is_array($entry['events'] ?? null) ? $entry['events'] : [], static fn (array $event): bool => (string) ($event['import_id'] ?? '') !== $importID )); $events[] = [ 'id' => $importID, 'type' => 'event', 'time' => (string) ($payload['time'] ?? ''), 'comment' => 'Du warst fleißig', 'value' => 0, 'unit' => '', 'mood' => 0, 'energy' => 1, 'stress' => 0, 'source' => 'putzliga', 'import_id' => $importID, 'task_titles' => array_slice(array_values(array_unique($tasks)), 0, 20), ]; $entry['events'] = $events; $entryMap[$date] = $entry; $this->persistUserEntries($username, $settings, array_values($entryMap)); $this->users->recordPutzligaImport($username, 'ok', count($tasks) . ' Aufgaben synchronisiert.'); json_response(['ok' => true, 'message' => 'Putzliga-Moment aktualisiert.', 'tasks' => count($tasks)]); } 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 { if ($this->isHealthAutoExportConfigurationPayload($payload)) { throw new RuntimeException('Diese JSON-Datei ist eine Health-Auto-Export-Konfiguration, aber kein Health-Datenexport. Bitte in Health Auto Export die Automation ausführen und die dabei erzeugte REST-API-Nutzlast senden.'); } $metrics = $this->healthMetricsFromPayload($payload); $workouts = $this->healthWorkoutsFromPayload($payload); $metricImport = $this->healthEventsFromMetrics($metrics, (float) ($settings['sleep']['optimal_hours'] ?? 7.0)); $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. ' . $this->healthPayloadSummary($payload, $metrics, $workouts)); } $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 isHealthAutoExportConfigurationPayload(array $payload): bool { if (!isset($payload['exportDestination'], $payload['exportDataType'], $payload['urlString'])) { return false; } $metrics = is_array($payload['metrics'] ?? null) ? $payload['metrics'] : []; $hasOnlyMetricNames = $metrics !== [] && array_filter($metrics, 'is_array') === []; $hasDataCollections = is_array($payload['workouts'] ?? null) || is_array($payload['data'] ?? null); return $hasOnlyMetricNames && !$hasDataCollections; } 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 { $metrics = []; $this->collectHealthMetrics($payload, $metrics); return $metrics; } private function healthWorkoutsFromPayload(array $payload): array { if (is_array($payload['data'] ?? null)) { $nested = $this->healthWorkoutsFromPayload($payload['data']); if ($nested !== []) { return $nested; } } if (is_array($payload['workouts'] ?? null)) { return array_values(array_filter($payload['workouts'], 'is_array')); } if ($this->looksLikeHealthWorkout($payload)) { return [$payload]; } if (array_is_list($payload) && isset($payload[0]) && is_array($payload[0])) { return array_values(array_filter($payload, fn (array $item): bool => $this->looksLikeHealthWorkout($item))); } return []; } private function collectHealthMetrics(array $payload, array &$metrics): void { if (is_array($payload['metrics'] ?? null)) { foreach ($payload['metrics'] as $metric) { if (is_array($metric)) { $this->collectHealthMetrics($metric, $metrics); } } } if (is_array($payload['data'] ?? null)) { $this->collectHealthMetrics($payload['data'], $metrics); } if (array_is_list($payload)) { foreach ($payload as $item) { if (is_array($item)) { $this->collectHealthMetrics($item, $metrics); } } return; } $name = trim((string) ($payload['name'] ?? ($payload['metric'] ?? ($payload['metricName'] ?? ($payload['type'] ?? ''))))); if ($name === '') { return; } $data = is_array($payload['data'] ?? null) ? $payload['data'] : (is_array($payload['records'] ?? null) ? $payload['records'] : []); if ($data === [] && (isset($payload['qty']) || isset($payload['value']) || isset($payload['date']) || isset($payload['startDate']))) { $data = [$payload]; } if ($data !== []) { $metrics[] = ['name' => $name, 'data' => array_values(array_filter($data, 'is_array'))]; } } private function looksLikeHealthWorkout(array $payload): bool { return isset($payload['start'], $payload['duration']) || isset($payload['startDate'], $payload['duration']) || isset($payload['workoutActivityType']) || isset($payload['workoutType']); } private function healthPayloadSummary(array $payload, array $metrics, array $workouts): string { $keys = array_slice(array_keys($payload), 0, 8); $metricNames = []; foreach (array_slice($metrics, 0, 5) as $metric) { if (is_array($metric) && trim((string) ($metric['name'] ?? '')) !== '') { $metricNames[] = trim((string) $metric['name']); } } $parts = ['Erkannt: ' . count($metrics) . ' Metriken, ' . count($workouts) . ' Workouts']; if ($keys !== []) { $parts[] = 'Top-Level: ' . implode(', ', $keys); } if ($metricNames !== []) { $parts[] = 'Metriken: ' . implode(', ', $metricNames); } return implode('. ', $parts) . '.'; } private function healthPayloadDiagnostics(array $payload, array $metrics, array $workouts): array { return [ 'top_level' => $this->describeHealthPayloadNode($payload), 'metrics_count' => count($metrics), 'workouts_count' => count($workouts), 'metric_samples' => $this->healthMetricDiagnostics($metrics), 'workout_samples' => $this->healthArraySamples($workouts), 'payload_samples' => $this->healthNestedArraySamples($payload), ]; } private function healthMetricDiagnostics(array $metrics): array { $samples = []; foreach (array_slice($metrics, 0, 8) as $index => $metric) { if (!is_array($metric)) { continue; } $data = is_array($metric['data'] ?? null) ? $metric['data'] : []; $firstPoint = null; foreach ($data as $point) { if (is_array($point)) { $firstPoint = $point; break; } } $samples[] = [ 'index' => $index, 'name' => (string) ($metric['name'] ?? ''), 'normalized' => $this->healthMetricName((string) ($metric['name'] ?? '')), 'data_count' => count($data), 'keys' => array_slice(array_keys($metric), 0, 12), 'first_point' => is_array($firstPoint) ? $this->describeHealthPayloadNode($firstPoint) : null, ]; } return $samples; } private function healthArraySamples(array $items): array { $samples = []; foreach (array_slice($items, 0, 5) as $index => $item) { if (is_array($item)) { $samples[] = ['index' => $index] + $this->describeHealthPayloadNode($item); } } return $samples; } private function healthNestedArraySamples(array $payload, string $path = '$', int $depth = 0): array { if ($depth > 2) { return []; } $samples = []; foreach ($payload as $key => $value) { $currentPath = $path . (is_int($key) ? '[' . $key . ']' : '.' . (string) $key); if (!is_array($value)) { continue; } $samples[] = ['path' => $currentPath] + $this->describeHealthPayloadNode($value); if (count($samples) >= 12) { break; } foreach ($this->healthNestedArraySamples($value, $currentPath, $depth + 1) as $nested) { $samples[] = $nested; if (count($samples) >= 12) { break 2; } } } return $samples; } private function describeHealthPayloadNode(array $node): array { $keys = array_slice(array_keys($node), 0, 12); $types = []; foreach ($keys as $key) { $value = $node[$key] ?? null; $types[(string) $key] = is_array($value) ? 'array(' . count($value) . ')' : get_debug_type($value); } return [ 'is_list' => array_is_list($node), 'count' => count($node), 'keys' => $keys, 'types' => $types, ]; } private function healthEventsFromMetrics(array $metrics, float $optimalSleepHours = 7.0): array { $steps = []; $sleepBuckets = []; foreach ($metrics as $metric) { $name = $this->healthMetricName((string) ($metric['name'] ?? ($metric['metric'] ?? ''))); $data = array_values(array_filter(is_array($metric['data'] ?? null) ? $metric['data'] : (is_array($metric['records'] ?? null) ? $metric['records'] : []), 'is_array')); if ($name === 'step_count') { foreach ($data as $point) { $date = $this->healthPointDate($point['date'] ?? ($point['startDate'] ?? null)); if ($date === null) { continue; } $steps[$date] = ($steps[$date] ?? 0) + max(0, (int) round((float) ($point['qty'] ?? ($point['value'] ?? 0)))); } } if ($name === 'sleep_analysis') { foreach ($data as $point) { $start = $this->healthDateTime($point['sleepStart'] ?? ($point['inBedStart'] ?? ($point['startDate'] ?? null))); $end = $this->healthDateTime($point['sleepEnd'] ?? ($point['inBedEnd'] ?? ($point['endDate'] ?? null))); $date = ($end ?? $this->healthDateTime($point['date'] ?? null))?->format('Y-m-d'); 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]); } } 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) { $signals = $this->healthSleepSignals($bucket, $optimalSleepHours); $sleep[$date][] = [ 'id' => 'health-sleep-' . $date, 'type' => 'sleep', 'time' => $bucket['start'] instanceof DateTimeImmutable ? $bucket['start']->format('H:i') : '', 'comment' => '', 'value' => round((float) $bucket['hours'], 2), 'unit' => 'h', 'sport_type_id' => '', 'consumed' => true, 'mood' => $signals['mood'], 'energy' => $signals['energy'], 'stress' => $signals['stress'], 'source' => 'health_auto_export', 'import_id' => 'health-sleep-' . $date, 'sleep_deep' => round((float) ($bucket['deep'] ?? 0), 2), 'sleep_rem' => round((float) ($bucket['rem'] ?? 0), 2), 'sleep_core' => round((float) ($bucket['core'] ?? 0), 2), 'route' => [], ]; } return [ 'steps' => $steps, 'sleep' => $sleep, ]; } private function healthSleepSignals(array $bucket, float $optimalSleepHours = 7.0): array { $hours = (float) ($bucket['hours'] ?? 0); $deep = (float) ($bucket['deep'] ?? 0); $rem = (float) ($bucket['rem'] ?? 0); $core = (float) ($bucket['core'] ?? 0); $optimalSleepHours = max(1.0, min(16.0, $optimalSleepHours)); $quality = 0; $deviation = abs($hours - $optimalSleepHours); if ($deviation <= 0.75) { $quality++; } elseif ($deviation >= 2.0) { $quality--; } if ($deep >= 0.8) { $quality++; } elseif ($deep > 0 && $deep < 0.4) { $quality--; } if ($rem >= 1.2) { $quality++; } elseif ($rem > 0 && $rem < 0.6) { $quality--; } return [ 'mood' => max(-2, min(2, $quality - 1)), 'energy' => max(-2, min(2, (int) round(($deep + $rem) - 2 - max(0, $deviation - 1)))), 'stress' => max(-2, min(2, $core >= 3.5 && $deviation <= 1.0 ? -1 : ($deviation >= 2.0 ? 1 : 0))), ]; } private function healthMetricName(string $name): string { $normalized = normalize_sport_type_id($name); return match ($normalized) { 'step-count', 'steps', 'hkquantitytypeidentifierstepcount' => 'step_count', 'sleep-analysis', 'sleep', 'hkcategorytypeidentifiersleepanalysis' => 'sleep_analysis', default => str_replace('-', '_', $normalized), }; } private function healthEventsFromWorkouts(array $workouts, array &$settings): array { $sport = []; $walk = []; $settingsChanged = false; $sportTypesAdded = 0; foreach ($workouts as $workout) { $start = $this->healthDateTime($workout['start'] ?? ($workout['startDate'] ?? null)); $end = $this->healthDateTime($workout['end'] ?? ($workout['endDate'] ?? 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 = ''; $durationLabel = format_points($duration) . ' min'; $distanceLabel = $this->healthQuantityLabel($workout['distance'] ?? 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' => '', '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' => '', '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', 'value', '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', 'spaziergang'], true) || str_contains($normalized, 'walking') || str_contains($normalized, 'spaziergang'); } 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) ceil(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(); $settings = $this->hydrateSettings($this->settings->forUser($user['username'])); $entries = $this->entries->all($user['username']); $evaluatedEntries = $this->evaluateEntriesWithContext($entries, $settings); $evaluatedEntries = array_map( fn (array $entry): array => $this->withDashboardImageState($user['username'], $entry), $evaluatedEntries ); $dashboardView = $this->normalizeDashboardView((string) ($_GET['view'] ?? 'day')); $dashboardDate = (string) ($_GET['date'] ?? today()); if (!$this->isValidDate($dashboardDate)) { $dashboardDate = today(); } $entryMap = []; foreach ($evaluatedEntries as $entry) { $entryMap[(string) ($entry['date'] ?? '')] = $entry; } $selectedEntry = $entryMap[$dashboardDate] ?? $this->withDashboardImageState($user['username'], $this->buildEmptyDashboardEntry($dashboardDate, $settings, $entryMap[shift_date($dashboardDate, -1)] ?? null)); $summary = $this->buildDashboardSummary($evaluatedEntries); $chartData = $this->buildDashboardCharts($evaluatedEntries); View::render('dashboard', [ 'pageTitle' => 'Mood', 'page' => 'dashboard', 'pageBodyClass' => 'page-dashboard-immersive', 'authUser' => $user, 'settings' => $settings, 'summary' => $summary, 'entries' => array_reverse($evaluatedEntries), 'chartPayload' => encode_payload($chartData), 'dashboardView' => $dashboardView, 'dashboardDate' => $dashboardDate, 'dayEntry' => $selectedEntry, 'dashboardEventTypes' => day_event_type_options(), 'dashboardSignals' => signal_scale_options(), 'dashboardTimeline' => $this->buildDashboardTimeline($selectedEntry, $settings), 'dashboardCompareDays' => $this->buildDashboardCompareDays($dashboardDate, $entryMap, $settings), 'dashboardWeek' => $this->buildDashboardWeekView($dashboardDate, $entryMap), 'dashboardMonth' => $this->buildDashboardMonthView($dashboardDate, $entryMap), 'dashboardPrevDate' => shift_date($dashboardDate, -1), 'dashboardNextDate' => shift_date($dashboardDate, 1), 'dashboardSportTypes' => normalized_sport_types($settings), 'dashboardWalkMode' => (string) ($settings['walk']['mode'] ?? 'time'), 'dashboardHasContent' => $this->entryHasContent($selectedEntry, isset($entryMap[$dashboardDate])), 'dashboardVisualScore' => $this->dashboardVisualScore($selectedEntry, isset($entryMap[$dashboardDate])), 'dashboardLineLevel' => $this->dashboardLineLevel($selectedEntry, isset($entryMap[$dashboardDate])), 'dashboardLineTone' => $this->dashboardLineTone($selectedEntry, isset($entryMap[$dashboardDate])), ]); } private function handleDashboard(): void { $this->enforceCsrf(); $user = $this->requireUser(); $settings = $this->hydrateSettings($this->settings->forUser($user['username'])); $form = (string) ($_POST['form_name'] ?? ''); $date = (string) ($_POST['date'] ?? today()); if (!$this->isValidDate($date)) { flash('error', 'Bitte wähle einen gültigen Tag.'); redirect('/'); } $entries = $this->entries->all($user['username']); $entryMap = []; foreach ($entries as $existingEntry) { $entryMap[(string) ($existingEntry['date'] ?? '')] = $existingEntry; } $current = $entryMap[$date] ?? $this->buildEmptyDashboardEntry($date, $settings, $entryMap[shift_date($date, -1)] ?? null); try { if ($form === 'save_day_summary') { $current['summary'] = [ 'comment' => trim((string) ($_POST['summary_comment'] ?? '')), 'mood' => normalize_signal_value($_POST['summary_mood'] ?? 0), 'energy' => normalize_signal_value($_POST['summary_energy'] ?? 0), 'stress' => normalize_signal_value($_POST['summary_stress'] ?? 0), 'alcohol' => isset($_POST['summary_alcohol']) && (string) $_POST['summary_alcohol'] === '1', ]; $current['summary_comment'] = $current['summary']['comment']; $current['summary_mood'] = $current['summary']['mood']; $current['summary_energy'] = $current['summary']['energy']; $current['summary_stress'] = $current['summary']['stress']; $current['summary_alcohol'] = !empty($current['summary']['alcohol']); $current['note'] = $current['summary']['comment']; $current['alcohol'] = !empty($current['summary']['alcohol']); $upload = uploaded_files('background_image')[0] ?? null; if (is_array($upload) && (int) ($upload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) { $previousImage = (string) ($current['background_image'] ?? ''); $current['background_image'] = $this->storeDashboardImage($user['username'], $date, $upload); if ($previousImage !== (string) $current['background_image']) { $this->deleteDashboardImage($user['username'], $previousImage); } } $entryMap[$date] = $current; $this->persistUserEntries($user['username'], $settings, array_values($entryMap)); flash('success', 'Die Tagesbilanz wurde gespeichert.'); redirect('/?view=day&date=' . rawurlencode($date)); } if ($form === 'add_event') { $event = $this->dashboardEventFromPost($_POST); $upload = uploaded_files('event_image')[0] ?? null; if (is_array($upload) && (int) ($upload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) { $event['image'] = $this->storeDashboardImage($user['username'], $date, $upload); } $events = is_array($current['events'] ?? null) ? $current['events'] : []; $events[] = $event; $current['events'] = $events; $entryMap[$date] = $current; $this->persistUserEntries($user['username'], $settings, array_values($entryMap)); flash('success', 'Der Moment wurde hinzugefügt.'); redirect('/?view=day&date=' . rawurlencode($date)); } if ($form === 'update_event') { $eventID = trim((string) ($_POST['event_id'] ?? '')); $updatedEvent = $this->dashboardEventFromPost($_POST); $updatedEvent['id'] = $eventID !== '' ? $eventID : $updatedEvent['id']; $upload = uploaded_files('event_image')[0] ?? null; $events = []; foreach (is_array($current['events'] ?? null) ? $current['events'] : [] as $event) { if (!is_array($event)) { continue; } 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['sleep_deep'] = (float) ($event['sleep_deep'] ?? 0); $updatedEvent['sleep_rem'] = (float) ($event['sleep_rem'] ?? 0); $updatedEvent['sleep_core'] = (float) ($event['sleep_core'] ?? 0); $updatedEvent['route'] = is_array($event['route'] ?? null) ? $event['route'] : []; if (is_array($upload) && (int) ($upload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) { $previousImage = (string) ($event['image'] ?? ''); $updatedEvent['image'] = $this->storeDashboardImage($user['username'], $date, $upload); if ($previousImage !== (string) $updatedEvent['image']) { $this->deleteDashboardImage($user['username'], $previousImage); } } $events[] = $updatedEvent; continue; } $events[] = $event; } $current['events'] = $events; $entryMap[$date] = $current; $this->persistUserEntries($user['username'], $settings, array_values($entryMap)); flash('success', 'Der Moment wurde aktualisiert.'); redirect('/?view=day&date=' . rawurlencode($date)); } if ($form === 'delete_event') { $eventID = trim((string) ($_POST['event_id'] ?? '')); foreach (is_array($current['events'] ?? null) ? $current['events'] : [] as $event) { if (is_array($event) && (string) ($event['id'] ?? '') === $eventID) { $this->deleteDashboardImage($user['username'], (string) ($event['image'] ?? '')); } } $current['events'] = array_values(array_filter( is_array($current['events'] ?? null) ? $current['events'] : [], static fn (array $event): bool => (string) ($event['id'] ?? '') !== $eventID )); $entryMap[$date] = $current; $this->persistUserEntries($user['username'], $settings, array_values($entryMap)); flash('success', 'Der Moment wurde entfernt.'); redirect('/?view=day&date=' . rawurlencode($date)); } if ($form === 'remove_background') { $this->deleteDashboardImage($user['username'], (string) ($current['background_image'] ?? '')); $current['background_image'] = ''; $entryMap[$date] = $current; $this->persistUserEntries($user['username'], $settings, array_values($entryMap)); flash('success', 'Das Tagesbild wurde entfernt.'); redirect('/?view=day&date=' . rawurlencode($date)); } } catch (RuntimeException $exception) { flash('error', $exception->getMessage()); redirect('/?view=day&date=' . rawurlencode($date)); } redirect('/?view=day&date=' . rawurlencode($date)); } private function normalizeDashboardView(string $view): string { return in_array($view, ['day', 'week', 'month'], true) ? $view : 'day'; } private function buildEmptyDashboardEntry(string $date, array $settings, ?array $previousEntry = null): array { $entry = $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' => '', ]); return array_merge($entry, [ 'evaluation' => $this->scoring->evaluate($entry, $settings, $previousEntry), 'sport_type_meta' => [], ]); } private function buildDashboardTimeline(array $entry, array $settings): array { $timeline = []; foreach (is_array($entry['events'] ?? null) ? $entry['events'] : [] as $event) { if (!is_array($event)) { continue; } $type = (string) ($event['type'] ?? 'event'); $mood = normalize_signal_value($event['mood'] ?? 0); $energy = normalize_signal_value($event['energy'] ?? 0); $stress = normalize_signal_value($event['stress'] ?? 0); if ($type === 'sleep' && (string) ($event['source'] ?? '') === 'health_auto_export') { $signals = $this->healthSleepSignals([ 'hours' => (float) ($event['value'] ?? 0), 'deep' => (float) ($event['sleep_deep'] ?? 0), 'rem' => (float) ($event['sleep_rem'] ?? 0), 'core' => (float) ($event['sleep_core'] ?? 0), ], (float) ($settings['sleep']['optimal_hours'] ?? 7.0)); $mood = $signals['mood']; $energy = $signals['energy']; $stress = $signals['stress']; } $timeline[] = [ 'kind' => 'event', 'id' => (string) ($event['id'] ?? ''), 'type' => $type, 'time' => (string) ($event['time'] ?? ''), 'comment' => (string) ($event['comment'] ?? ''), 'value' => (float) ($event['value'] ?? 0), 'unit' => (string) ($event['unit'] ?? ''), 'sport_type_id' => (string) ($event['sport_type_id'] ?? ''), 'consumed' => !empty($event['consumed']), 'image' => (string) ($event['image'] ?? ''), 'image_url' => is_string($event['image_url'] ?? null) ? (string) $event['image_url'] : null, 'mood' => $mood, 'energy' => $energy, 'stress' => $stress, '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'] ?? ''), 'sleep_deep' => (float) ($event['sleep_deep'] ?? 0), 'sleep_rem' => (float) ($event['sleep_rem'] ?? 0), 'sleep_core' => (float) ($event['sleep_core'] ?? 0), 'task_titles' => is_array($event['task_titles'] ?? null) ? $event['task_titles'] : [], '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 = []; for ($offset = -3; $offset <= 1; $offset++) { $dayDate = shift_date($date, $offset); $entry = $entryMap[$dayDate] ?? $this->buildEmptyDashboardEntry($dayDate, $settings, $entryMap[shift_date($dayDate, -1)] ?? null); $isPersisted = isset($entryMap[$dayDate]); $hasContent = $isPersisted || $this->entryHasContent($entry); $visualScore = $this->dashboardVisualScore($entry, $isPersisted); $lineLevel = $this->dashboardLineLevel($entry, $isPersisted); $days[] = [ 'date' => $dayDate, 'short' => (new DateTimeImmutable($dayDate))->format('D'), 'day' => format_compact_date($dayDate), 'offset' => $offset, 'is_current' => $dayDate === $date, 'has_content' => $hasContent, 'visual_score' => $visualScore, 'score_level' => $lineLevel, 'line_level' => $lineLevel, 'line_tone' => $lineLevel === null ? 'empty' : signal_value_class($lineLevel), ]; } return $days; } private function buildDashboardWeekView(string $date, array $entryMap): array { $current = new DateTimeImmutable($date); $selectedStart = $current->modify('monday this week'); $selectedKey = $selectedStart->format('Y-m-d'); $currentStart = (new DateTimeImmutable(today()))->modify('monday this week'); $currentKey = $currentStart->format('Y-m-d'); $weekKeys = [$currentKey => true, $selectedKey => true]; foreach (array_keys($entryMap) as $entryDate) { if (!$this->isValidDate((string) $entryDate)) { continue; } $weekKeys[(new DateTimeImmutable((string) $entryDate))->modify('monday this week')->format('Y-m-d')] = true; } unset($weekKeys[$currentKey]); $otherWeekKeys = array_keys($weekKeys); rsort($otherWeekKeys, SORT_STRING); $orderedWeekKeys = array_merge([$currentKey], $otherWeekKeys); $periods = []; foreach ($orderedWeekKeys as $weekKey) { $periods[] = $this->buildDashboardWeekPeriod(new DateTimeImmutable((string) $weekKey), $date, $entryMap, $weekKey === $selectedKey); } $selectedPeriod = $this->buildDashboardWeekPeriod($selectedStart, $date, $entryMap, true); return array_merge($selectedPeriod, [ 'periods' => $periods, ]); } private function buildDashboardWeekPeriod(DateTimeImmutable $start, string $selectedDate, array $entryMap, bool $isSelected): array { $days = []; for ($index = 0; $index < 7; $index++) { $day = $start->modify('+' . $index . ' day'); $iso = $day->format('Y-m-d'); $entry = $entryMap[$iso] ?? null; $hasContent = $entry !== null && $this->entryHasContent($entry); $lineLevel = $entry !== null ? $this->dashboardLineLevel($entry, true) : null; $days[] = [ 'date' => $iso, 'weekday' => format_display_date($iso, true), 'short' => $day->format('D'), 'day' => $day->format('j'), 'entry' => $entry, 'has_content' => $hasContent, 'score_level' => $lineLevel, 'line_tone' => $lineLevel === null ? 'empty' : signal_value_class($lineLevel), 'is_current' => $iso === $selectedDate, ]; } return [ 'key' => $start->format('Y-m-d'), 'title' => 'Woche ' . $start->format('W'), '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, ]; } private function buildDashboardMonthView(string $date, array $entryMap): array { $current = new DateTimeImmutable($date); $selectedStart = $current->modify('first day of this month'); $selectedKey = $selectedStart->format('Y-m-d'); $currentStart = (new DateTimeImmutable(today()))->modify('first day of this month'); $currentKey = $currentStart->format('Y-m-d'); $monthKeys = [$currentKey => true, $selectedKey => true]; foreach (array_keys($entryMap) as $entryDate) { if (!$this->isValidDate((string) $entryDate)) { continue; } $monthKeys[(new DateTimeImmutable((string) $entryDate))->modify('first day of this month')->format('Y-m-d')] = true; } unset($monthKeys[$currentKey]); $otherMonthKeys = array_keys($monthKeys); rsort($otherMonthKeys, SORT_STRING); $orderedMonthKeys = array_merge([$currentKey], $otherMonthKeys); $periods = []; foreach ($orderedMonthKeys as $monthKey) { $periods[] = $this->buildDashboardMonthPeriod(new DateTimeImmutable((string) $monthKey), $date, $entryMap, $monthKey === $selectedKey); } $selectedPeriod = $this->buildDashboardMonthPeriod($selectedStart, $date, $entryMap, true); return array_merge($selectedPeriod, [ 'periods' => $periods, ]); } private function buildDashboardMonthPeriod(DateTimeImmutable $start, string $selectedDate, array $entryMap, bool $isSelected): array { $end = $start->modify('last day of this month'); $days = []; for ($day = $start; $day <= $end; $day = $day->modify('+1 day')) { $iso = $day->format('Y-m-d'); $entry = $entryMap[$iso] ?? null; $hasContent = $entry !== null && $this->entryHasContent($entry); $lineLevel = $entry !== null ? $this->dashboardLineLevel($entry, true) : null; $days[] = [ 'date' => $iso, 'day' => $day->format('j'), 'weekday' => format_display_date($iso, true), 'entry' => $entry, 'has_content' => $hasContent, 'score_level' => $lineLevel, 'line_tone' => $lineLevel === null ? 'empty' : signal_value_class($lineLevel), 'is_future' => $iso > $selectedDate, ]; } return [ 'key' => $start->format('Y-m-d'), 'title' => month_label($start->format('Y-m')), 'is_selected' => $isSelected, 'days' => $days, ]; } private function entryHasContent(array $entry, bool $isPersisted = false): bool { if ($isPersisted) { return true; } if (trim((string) ($entry['summary']['comment'] ?? '')) !== '') { return true; } if (!empty($entry['summary']['alcohol'] ?? $entry['summary_alcohol'] ?? false)) { return true; } if (trim((string) ($entry['background_image'] ?? '')) !== '') { 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; } private function dashboardVisualScore(array $entry, bool $isPersisted = false): ?int { if (!$this->entryHasContent($entry, $isPersisted)) { return null; } $summary = is_array($entry['summary'] ?? null) ? $entry['summary'] : []; $mood = normalize_signal_value($summary['mood'] ?? $entry['summary_mood'] ?? legacy_to_signal_scale($entry['mood'] ?? 5)); $energy = normalize_signal_value($summary['energy'] ?? $entry['summary_energy'] ?? legacy_to_signal_scale($entry['energy'] ?? 5)); $stress = normalize_signal_value($summary['stress'] ?? $entry['summary_stress'] ?? legacy_to_signal_scale($entry['stress'] ?? 5)); return signal_combo_score($mood, $energy, $stress); } private function dashboardLineLevel(array $entry, bool $isPersisted = false): ?int { if (!$this->entryHasContent($entry, $isPersisted)) { return null; } if (is_array($entry['evaluation']['balance'] ?? null)) { return max(-2, min(2, (int) ($entry['evaluation']['balance']['level'] ?? 0))); } $percentage = max(0.0, min(100.0, (float) ($entry['evaluation']['percentage'] ?? 0))); return max(-2, min(2, (int) round(($percentage - 50.0) / 25.0))); } private function dashboardLineTone(array $entry, bool $isPersisted = false): string { return signal_value_class($this->dashboardLineLevel($entry, $isPersisted) ?? 0); } private function dashboardEventFromPost(array $input): array { $type = trim((string) ($input['event_type'] ?? 'event')); if (!array_key_exists($type, day_event_type_options())) { $type = 'event'; } $time = trim((string) ($input['event_time'] ?? '')); if (!$this->isValidTime($time)) { $time = date('H:i'); } $comment = trim((string) ($input['event_comment'] ?? '')); $value = max(0, min(50000, $this->localizedFloat($input['event_value'] ?? 0))); if (in_array($type, ['walk', 'sport', 'sleep'], true) && $value <= 0) { throw new RuntimeException('Für diesen Moment braucht es einen Wert oder eine Dauer.'); } $sportTypeID = trim((string) ($input['event_sport_type_id'] ?? '')); if ($type === 'sport' && $sportTypeID === '') { throw new RuntimeException('Bitte wähle eine Sportart.'); } $unit = trim((string) ($input['event_unit'] ?? day_event_type_unit($type))); if ($type === 'walk') { $walkMode = trim((string) ($input['event_walk_mode'] ?? 'time')); $unit = $walkMode === 'steps' ? 'steps' : 'min'; } return [ 'id' => 'evt-' . substr(sha1((string) microtime(true) . $type . $time . (string) ($input['event_comment'] ?? '')), 0, 12), 'type' => $type, 'time' => $time, 'comment' => $comment, 'value' => $value, 'unit' => $unit, 'sport_type_id' => $sportTypeID, 'consumed' => isset($input['event_consumed']) ? ((string) $input['event_consumed'] === '1') : true, 'mood' => normalize_signal_value($input['event_mood'] ?? 0), 'energy' => normalize_signal_value($input['event_energy'] ?? 0), 'stress' => normalize_signal_value($input['event_stress'] ?? 0), ]; } private function localizedFloat(mixed $value): float { $normalized = str_replace(',', '.', trim((string) $value)); return is_numeric($normalized) ? (float) $normalized : 0.0; } private function dashboardMediaDirectory(string $username): string { return storage_path('users/' . normalize_username($username) . '/media'); } private function withDashboardImageState(string $username, array $entry): array { $fileName = trim((string) ($entry['background_image'] ?? '')); $date = (string) ($entry['date'] ?? ''); $entry['background_image_url'] = null; $events = []; foreach (is_array($entry['events'] ?? null) ? $entry['events'] : [] as $event) { if (!is_array($event)) { continue; } $event['image_url'] = null; $eventImage = trim((string) ($event['image'] ?? '')); if ($eventImage !== '' && is_file($this->dashboardMediaDirectory($username) . '/' . basename($eventImage))) { $event['image_url'] = '/event-image?date=' . rawurlencode($date) . '&id=' . rawurlencode((string) ($event['id'] ?? '')); } $events[] = $event; } $entry['events'] = $events; if ($fileName === '' || !$this->isValidDate($date)) { return $entry; } $path = $this->dashboardMediaDirectory($username) . '/' . basename($fileName); if (is_file($path)) { $entry['background_image_url'] = '/day-image?date=' . rawurlencode($date); } return $entry; } private function storeDashboardImage(string $username, string $date, array $upload): string { $error = (int) ($upload['error'] ?? UPLOAD_ERR_NO_FILE); if ($error !== UPLOAD_ERR_OK) { throw new RuntimeException('Das Bild konnte nicht hochgeladen werden.'); } $tmpName = (string) ($upload['tmp_name'] ?? ''); if ($tmpName === '' || !is_uploaded_file($tmpName)) { throw new RuntimeException('Die hochgeladene Bilddatei ist ungültig.'); } $mime = mime_content_type($tmpName) ?: ''; $extension = match ($mime) { 'image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp', default => '', }; if ($extension === '') { throw new RuntimeException('Bitte nutze JPG, PNG oder WebP als Bild.'); } $directory = $this->dashboardMediaDirectory($username); if (!is_dir($directory)) { mkdir($directory, 0775, true); } $hash = hash_file('sha256', $tmpName); if (!is_string($hash) || $hash === '') { throw new RuntimeException('Das Bild konnte nicht gelesen werden.'); } $targetExtension = function_exists('imagecreatetruecolor') ? 'webp' : $extension; $fileName = $date . '-' . substr($hash, 0, 16) . '.' . $targetExtension; $target = $directory . '/' . $fileName; if (is_file($target) && $targetExtension !== 'webp') { return $fileName; } if ($targetExtension === 'webp' && $this->writeOptimizedDashboardImage($tmpName, $mime, $target)) { return $fileName; } $fileName = $date . '-' . substr($hash, 0, 16) . '.' . $extension; $target = $directory . '/' . $fileName; if (is_file($target)) { return $fileName; } if (!move_uploaded_file($tmpName, $target)) { throw new RuntimeException('Das Bild konnte nicht gespeichert werden.'); } return $fileName; } private function writeOptimizedDashboardImage(string $sourcePath, string $mime, string $target): bool { if (!function_exists('imagecreatetruecolor') || !function_exists('imagewebp')) { return false; } $source = match ($mime) { 'image/jpeg' => function_exists('imagecreatefromjpeg') ? @imagecreatefromjpeg($sourcePath) : false, 'image/png' => function_exists('imagecreatefrompng') ? @imagecreatefrompng($sourcePath) : false, 'image/webp' => function_exists('imagecreatefromwebp') ? @imagecreatefromwebp($sourcePath) : false, default => false, }; if (!$source instanceof GdImage) { return false; } $source = $this->applyImageOrientation($source, $sourcePath, $mime); $width = imagesx($source); $height = imagesy($source); if ($width <= 0 || $height <= 0) { imagedestroy($source); return false; } $maxWidth = 1800; $maxHeight = 1800; $scale = min(1.0, $maxWidth / $width, $maxHeight / $height); $targetWidth = max(1, (int) round($width * $scale)); $targetHeight = max(1, (int) round($height * $scale)); $canvas = imagecreatetruecolor($targetWidth, $targetHeight); if (!$canvas instanceof GdImage) { imagedestroy($source); return false; } imagealphablending($canvas, true); imagesavealpha($canvas, true); imagecopyresampled($canvas, $source, 0, 0, 0, 0, $targetWidth, $targetHeight, $width, $height); $written = imagewebp($canvas, $target, 84); imagedestroy($source); imagedestroy($canvas); return $written && is_file($target); } private function applyImageOrientation(GdImage $source, string $sourcePath, string $mime): GdImage { if ($mime !== 'image/jpeg' || !function_exists('exif_read_data')) { return $source; } $exif = @exif_read_data($sourcePath); $orientation = is_array($exif) ? (int) ($exif['Orientation'] ?? 1) : 1; $oriented = match ($orientation) { 3 => imagerotate($source, 180, 0), 6 => imagerotate($source, -90, 0), 8 => imagerotate($source, 90, 0), default => $source, }; if ($oriented instanceof GdImage && $oriented !== $source) { imagedestroy($source); return $oriented; } return $source; } private function deleteDashboardImage(string $username, string $fileName): void { $fileName = basename(trim($fileName)); if ($fileName === '' || $this->dashboardImageReferenceCount($username, $fileName) > 1) { return; } $path = $this->dashboardMediaDirectory($username) . '/' . basename($fileName); if (is_file($path)) { @unlink($path); } } private function dashboardImageReferenceCount(string $username, string $fileName): int { $fileName = basename(trim($fileName)); if ($fileName === '') { return 0; } $count = 0; foreach ($this->entries->all($username) as $entry) { if (!is_array($entry)) { continue; } if (basename((string) ($entry['background_image'] ?? '')) === $fileName) { $count++; } foreach (is_array($entry['events'] ?? null) ? $entry['events'] : [] as $event) { if (is_array($event) && basename((string) ($event['image'] ?? '')) === $fileName) { $count++; } } } return $count; } private function serveDayImage(): void { $user = $this->requireUser(); $date = (string) ($_GET['date'] ?? ''); if (!$this->isValidDate($date)) { http_response_code(404); exit('Nicht gefunden'); } $entry = $this->entries->find($user['username'], $date); $fileName = trim((string) ($entry['background_image'] ?? '')); if ($fileName === '') { http_response_code(404); exit('Nicht gefunden'); } $path = $this->dashboardMediaDirectory($user['username']) . '/' . basename($fileName); if (!is_file($path)) { http_response_code(404); exit('Nicht gefunden'); } $mime = mime_content_type($path) ?: 'application/octet-stream'; header('Content-Type: ' . $mime); header('Content-Length: ' . (string) filesize($path)); header('Cache-Control: private, max-age=604800'); readfile($path); exit; } private function serveEventImage(): void { $user = $this->requireUser(); $date = (string) ($_GET['date'] ?? ''); $eventID = trim((string) ($_GET['id'] ?? '')); if (!$this->isValidDate($date) || $eventID === '') { http_response_code(404); exit('Nicht gefunden'); } $entry = $this->entries->find($user['username'], $date); foreach (is_array($entry['events'] ?? null) ? $entry['events'] : [] as $event) { if (!is_array($event) || (string) ($event['id'] ?? '') !== $eventID) { continue; } $fileName = trim((string) ($event['image'] ?? '')); $path = $this->dashboardMediaDirectory($user['username']) . '/' . basename($fileName); if ($fileName === '' || !is_file($path)) { break; } $mime = mime_content_type($path) ?: 'application/octet-stream'; header('Content-Type: ' . $mime); header('Content-Length: ' . (string) filesize($path)); header('Cache-Control: private, max-age=604800'); readfile($path); exit; } http_response_code(404); exit('Nicht gefunden'); } private function showTrack(): void { $user = $this->requireUser(); $settings = $this->hydrateSettings($this->settings->forUser($user['username'])); $date = (string) ($_GET['date'] ?? today()); if (!$this->isValidDate($date)) { $date = today(); } $entry = $this->entries->find($user['username'], $date) ?? [ 'date' => $date, 'mood' => 6, 'energy' => 6, 'stress' => 4, 'pain' => 1, 'pain_enabled' => !empty($settings['tracking']['pain_enabled']), 'sleep_hours' => 7, 'sleep_feeling' => 3, 'sport_minutes' => 0, 'sport_type' => '', 'sport_types' => [], 'walk_mode' => (string) ($settings['walk']['mode'] ?? 'time'), 'walk_minutes' => 0, 'walk_steps' => 0, 'alcohol' => false, 'note' => '', ]; $entry['pain_enabled'] = !empty($settings['tracking']['pain_enabled']); $entry = $this->scoring->normalize($entry); $previousEntry = $this->entries->find($user['username'], shift_date($date, -1)); $evaluation = $this->scoring->evaluate($entry, $settings, $previousEntry); View::render('track', [ 'pageTitle' => 'Tag tracken', 'page' => 'track', 'authUser' => $user, 'entry' => $entry, 'evaluation' => $evaluation, 'settings' => $settings, 'sportTypes' => normalized_sport_types($settings), 'trackMood' => $evaluation['sentiment'], 'topbarDate' => $entry['date'], 'trackPayload' => encode_payload([ 'settings' => $settings, 'entry' => $entry, 'previousEntry' => $previousEntry !== null ? $this->scoring->normalize($previousEntry) : null, ]), ]); } private function handleTrack(): void { $this->enforceCsrf(); $user = $this->requireUser(); $settings = $this->hydrateSettings($this->settings->forUser($user['username'])); $entry = $this->scoring->normalize([ 'date' => $_POST['date'] ?? today(), 'mood' => $_POST['mood'] ?? 5, 'energy' => $_POST['energy'] ?? 5, 'stress' => $_POST['stress'] ?? 5, 'pain' => $_POST['pain'] ?? 1, 'pain_enabled' => !empty($settings['tracking']['pain_enabled']), 'sleep_hours' => $_POST['sleep_hours'] ?? 0, 'sleep_feeling' => $_POST['sleep_feeling'] ?? 3, 'sport_minutes' => $_POST['sport_minutes'] ?? 0, 'sport_types' => $_POST['sport_types'] ?? [], 'walk_mode' => $_POST['walk_mode'] ?? ($settings['walk']['mode'] ?? 'time'), 'walk_minutes' => $_POST['walk_minutes'] ?? 0, 'walk_steps' => $_POST['walk_steps'] ?? 0, 'alcohol' => $_POST['alcohol'] ?? false, 'note' => $_POST['note'] ?? '', ]); if (!$this->isValidDate($entry['date'])) { flash('error', 'Bitte wähle ein gültiges Datum.'); redirect('/track'); } $entries = $this->entries->all($user['username']); $entryMap = []; foreach ($entries as $existingEntry) { $entryMap[(string) ($existingEntry['date'] ?? '')] = $existingEntry; } $entryMap[$entry['date']] = $entry; $this->persistUserEntries($user['username'], $settings, array_values($entryMap)); flash('success', 'Der Tag wurde gespeichert.'); redirect('/track?date=' . rawurlencode($entry['date'])); } private function showArchive(): void { $user = $this->requireUser(); $settings = $this->hydrateSettings($this->settings->forUser($user['username'])); $view = $this->normalizeArchiveView((string) ($_GET['view'] ?? 'days')); $filterMonth = trim((string) ($_GET['filter_month'] ?? '')); if ($filterMonth !== '' && preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $filterMonth) !== 1) { $filterMonth = ''; } $selectedDate = isset($_GET['date']) ? (string) $_GET['date'] : null; $selectedWeekKey = isset($_GET['week'] ) ? trim((string) $_GET['week']) : null; $selectedMonthKey = isset($_GET['month_key']) ? trim((string) $_GET['month_key']) : null; $entries = $this->entries->all($user['username']); $archive = array_reverse($this->evaluateEntriesWithContext($entries, $settings)); $weeklySummaries = $this->summaries->weekly($user['username']); $monthlySummaries = $this->summaries->monthly($user['username']); $weeklyArchive = $this->buildWeeklyArchiveCards($archive, $weeklySummaries); $monthlyArchive = $this->buildMonthlyArchiveCards($archive, $weeklySummaries, $monthlySummaries, $weeklyArchive); $monthOptions = $this->buildArchiveMonthOptions($archive, $weeklyArchive, $monthlyArchive); $filteredDays = $filterMonth === '' ? $archive : array_values(array_filter( $archive, static fn (array $entry): bool => month_key((string) ($entry['date'] ?? '')) === $filterMonth )); $filteredWeeks = $filterMonth === '' ? $weeklyArchive : array_values(array_filter( $weeklyArchive, fn (array $week): bool => $this->archiveItemOverlapsMonth($week, $filterMonth) )); $filteredMonths = $filterMonth === '' ? $monthlyArchive : array_values(array_filter( $monthlyArchive, static fn (array $month): bool => (string) ($month['summary_key'] ?? '') === $filterMonth )); $selectedEntry = null; if ($view === 'days' && $selectedDate !== null) { foreach ($filteredDays as $entry) { if ($entry['date'] === $selectedDate) { $selectedEntry = $entry; break; } } } $selectedWeek = null; if ($view === 'weeks' && $selectedWeekKey !== null) { foreach ($filteredWeeks as $week) { if (($week['summary_key'] ?? '') === $selectedWeekKey) { $selectedWeek = $week; break; } } } $selectedMonth = null; if ($view === 'months' && $selectedMonthKey !== null) { foreach ($filteredMonths as $month) { if (($month['summary_key'] ?? '') === $selectedMonthKey) { $selectedMonth = $month; break; } } } View::render('archive', [ 'pageTitle' => 'Archiv', 'page' => 'archive', 'authUser' => $user, 'entries' => $filteredDays, 'selectedEntry' => $selectedEntry, 'selectedWeek' => $selectedWeek, 'selectedMonth' => $selectedMonth, 'settings' => $settings, 'archiveView' => $view, 'archiveFilterMonth' => $filterMonth, 'archiveMonthOptions' => $monthOptions, 'weeklyArchive' => $filteredWeeks, 'monthlyArchive' => $filteredMonths, 'aiAvailable' => $this->openAi->isAvailable(), ]); } private function handleArchive(): void { $this->enforceCsrf(); $user = $this->requireUser(); $settings = $this->hydrateSettings($this->settings->forUser($user['username'])); $entries = $this->evaluateEntriesWithContext($this->entries->all($user['username']), $settings); $form = (string) ($_POST['form_name'] ?? ''); $returnView = $this->normalizeArchiveView((string) ($_POST['view'] ?? 'days')); $returnFilterMonth = trim((string) ($_POST['filter_month'] ?? '')); if ($returnFilterMonth !== '' && preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $returnFilterMonth) !== 1) { $returnFilterMonth = ''; } try { if ($form === 'generate_weekly_summary') { $weekKey = trim((string) ($_POST['week_key'] ?? '')); $context = $this->buildWeeklySummaryContext($weekKey, $entries); $text = $this->openAi->generateWeekly($context['prompt']); $this->summaries->save($user['username'], 'weekly', $weekKey, [ 'title' => 'Wochenzusammenfassung ' . iso_week_label($weekKey), 'created_at' => date(DATE_ATOM), 'date_from' => $context['date_from'], 'date_to' => $context['date_to'], 'text' => $text, ]); flash('success', 'Die KI-Wochenzusammenfassung wurde erstellt.'); redirect($this->archivePath([ 'view' => 'weeks', 'filter_month' => $returnFilterMonth !== '' ? $returnFilterMonth : month_key($context['date_to']), 'week' => $weekKey, ])); } if ($form === 'generate_monthly_summary') { $monthKey = trim((string) ($_POST['month_key'] ?? '')); $weeklySummaries = $this->summaries->weekly($user['username']); $context = $this->buildMonthlySummaryContext($monthKey, $entries, $weeklySummaries); $text = $this->openAi->generateMonthly($context['prompt']); preg_match('/^(\d{4})-(\d{2})$/', $monthKey, $monthMatches); $this->summaries->save($user['username'], 'monthly', $monthKey, [ 'title' => 'Monatszusammenfassung ' . (string) ($monthMatches[2] ?? '') . ' / ' . (string) ($monthMatches[1] ?? ''), 'created_at' => date(DATE_ATOM), 'date_from' => $context['date_from'], 'date_to' => $context['date_to'], 'text' => $text, ]); flash('success', 'Die KI-Monatszusammenfassung wurde erstellt.'); redirect($this->archivePath([ 'view' => 'months', 'filter_month' => $returnFilterMonth !== '' ? $returnFilterMonth : $monthKey, 'month_key' => $monthKey, ])); } } catch (RuntimeException $exception) { flash('error', $exception->getMessage()); redirect($this->archivePath([ 'view' => $returnView, 'filter_month' => $returnFilterMonth, ])); } redirect($this->archivePath([ 'view' => $returnView, 'filter_month' => $returnFilterMonth, ])); } private function showOptions(): void { $user = $this->requireUser(); $settings = $this->hydrateSettings($this->settings->forUser($user['username'])); $evaluatedEntries = $this->evaluateEntriesWithContext($this->entries->all($user['username']), $settings); $sportTypePresets = array_values(array_filter( Defaults::settings()['sport_types'], static function (array $preset) use ($settings): bool { foreach (normalized_sport_types($settings) as $type) { if (($type['id'] ?? '') === ($preset['id'] ?? '')) { return false; } } return true; } )); $pushAvailable = $this->webPush->isAvailable(); $pushPublicKey = null; if ($pushAvailable) { try { $pushPublicKey = $this->webPush->publicKey(); } catch (RuntimeException) { $pushAvailable = false; } } $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; } $pendingPutzligaTokens = is_array($_SESSION['_putzliga_import_token'] ?? null) ? $_SESSION['_putzliga_import_token'] : []; $putzligaImportToken = $pendingPutzligaTokens[$user['username']] ?? null; if (is_string($putzligaImportToken)) { unset($_SESSION['_putzliga_import_token'][$user['username']]); } else { $putzligaImportToken = null; } $optionsOpenPanel = trim((string) ($_GET['panel'] ?? '')); if ($optionsOpenPanel === 'score') { $optionsOpenPanel = ''; } View::render('options', [ 'pageTitle' => 'Optionen', 'page' => 'options', 'authUser' => $user, 'optionsOpenPanel' => $optionsOpenPanel, 'settings' => $settings, 'sportTypePresets' => $sportTypePresets, 'sportLocationOptions' => sport_location_options(), 'walkModeOptions' => walk_mode_options(), 'pushAvailable' => $pushAvailable, 'pushPublicKey' => $pushPublicKey, 'pushSubscriptionCount' => $this->notifications->subscriptionCount($user['username']), 'healthImportConfig' => $this->users->healthImportConfig($user['username']), 'healthImportToken' => $healthImportToken, 'healthImportUrl' => app_origin() . '/api/health', 'putzligaImportConfig' => $this->users->putzligaImportConfig($user['username']), 'putzligaImportToken' => $putzligaImportToken, 'putzligaImportUrl' => app_origin() . '/api/putzliga', 'backupAvailable' => class_exists('ZipArchive'), 'aiConfig' => $user['is_admin'] ? $this->aiConfig->get() : null, 'aiStatus' => $user['is_admin'] ? $this->openAi->configuration() : null, 'users' => $user['is_admin'] ? $this->users->all() : [], 'statsSummary' => $this->buildDashboardSummary($evaluatedEntries), 'statsChartPayload' => encode_payload($this->buildDashboardCharts($evaluatedEntries)), 'maxScore' => $this->scoring->evaluate([ 'mood' => 10, 'energy' => 10, 'stress' => 1, 'pain' => 1, 'pain_enabled' => !empty($settings['tracking']['pain_enabled']), 'sleep_hours' => 7, 'sleep_feeling' => 5, 'sport_minutes' => 999, 'sport_types' => array_map( static fn (array $type): string => (string) ($type['id'] ?? ''), normalized_sport_types($settings) ), 'walk_mode' => (string) ($settings['walk']['mode'] ?? 'time'), 'walk_minutes' => 999, 'walk_steps' => 10000, 'alcohol' => false, 'note' => 'x', ], $settings)['max_total'], ]); } private function handleOptions(): void { $this->enforceCsrf(); $user = $this->requireUser(); $form = (string) ($_POST['form_name'] ?? ''); if ($form === 'settings') { $currentSettings = $this->hydrateSettings($this->settings->forUser($user['username'])); $settings = $this->sanitizeSettings($_POST['settings'] ?? [], $currentSettings); $this->settings->saveForUser($user['username'], $settings); flash('success', 'Deine persönlichen Optionen wurden aktualisiert.'); redirect('/options'); } if ($form === 'ai_config' && ($user['is_admin'] ?? false)) { $this->aiConfig->save($_POST['ai'] ?? []); flash('success', 'Die zentrale KI-Konfiguration wurde aktualisiert.'); redirect('/options'); } if ($form === 'export_backup') { $settings = $this->hydrateSettings($this->settings->forUser($user['username'])); $this->downloadUserBackup($user, $settings); } if ($form === 'import_backup') { $settings = $this->hydrateSettings($this->settings->forUser($user['username'])); try { $imported = $this->importUserBackup($user, $settings); flash( 'success', $imported === 1 ? '1 Archivobjekt wurde aus dem Backup verarbeitet.' : $imported . ' Archivobjekte wurden aus dem Backup verarbeitet.' ); } catch (RuntimeException $exception) { flash('error', $exception->getMessage()); } 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 === 'putzliga_import_token') { $token = $this->users->issuePutzligaImportToken($user['username']); $_SESSION['_putzliga_import_token'][$user['username']] = $token; flash('success', 'Der Putzliga-Token wurde erstellt. Kopiere ihn in Putzliga.'); redirect('/options?panel=health'); } if ($form === 'putzliga_import_revoke') { $this->users->revokePutzligaImportToken($user['username']); flash('success', 'Der Putzliga-Token wurde deaktiviert.'); redirect('/options?panel=health'); } if ($form === 'password') { $current = (string) ($_POST['current_password'] ?? ''); $new = (string) ($_POST['new_password'] ?? ''); $confirm = (string) ($_POST['new_password_confirm'] ?? ''); if ($this->users->verify($user['username'], $current) === null) { flash('error', 'Das aktuelle Passwort stimmt nicht.'); redirect('/options'); } if (!$this->isStrongPassword($new)) { flash('error', 'Das neue Passwort sollte mindestens 10 Zeichen lang sein.'); redirect('/options'); } if ($new !== $confirm) { flash('error', 'Die neuen Passwörter stimmen nicht überein.'); redirect('/options'); } $this->users->changePassword($user['username'], $new); flash('success', 'Dein Passwort wurde aktualisiert.'); redirect('/options'); } if ($form === 'create_user' && ($user['is_admin'] ?? false)) { $username = trim((string) ($_POST['username'] ?? '')); $password = (string) ($_POST['password'] ?? ''); $isAdmin = isset($_POST['is_admin']) && $_POST['is_admin'] === '1'; if (!$this->isValidUsername($username)) { flash('error', 'Bitte nutze für neue Accounts einen sauberen Benutzernamen.'); redirect('/options'); } if (!$this->isStrongPassword($password)) { flash('error', 'Das Startpasswort sollte mindestens 10 Zeichen lang sein.'); redirect('/options'); } try { $this->users->create($username, $password, $isAdmin); flash('success', 'Der neue Account wurde angelegt.'); } catch (RuntimeException $exception) { flash('error', $exception->getMessage()); } redirect('/options'); } redirect('/options'); } private function buildDashboardSummary(array $entries): array { $count = count($entries); $todayEntry = null; foreach ($entries as $entry) { if ($entry['date'] === today()) { $todayEntry = $entry; } } $avgScore = $count > 0 ? round(array_sum(array_map(static fn (array $entry): float => (float) $entry['evaluation']['total'], $entries)) / $count, 1) : 0.0; $avgBalance = $count > 0 ? round(array_sum(array_map(static fn (array $entry): float => (float) ($entry['evaluation']['balance']['raw'] ?? 0), $entries)) / $count, 1) : 0.0; $avgMood = $count > 0 ? round(array_sum(array_map(static fn (array $entry): int => (int) $entry['mood'], $entries)) / $count, 1) : 0.0; $avgStress = $count > 0 ? round(array_sum(array_map(static fn (array $entry): int => (int) $entry['stress'], $entries)) / $count, 1) : 0.0; return [ 'tracked_days' => $count, 'average_score' => $avgScore, 'average_balance' => $avgBalance, 'average_mood' => $avgMood, 'average_stress' => $avgStress, 'streak' => $this->calculateStreak($entries), 'today' => $todayEntry, ]; } private function persistUserEntries(string $username, array $settings, array $entries): void { $normalized = []; foreach ($entries as $entry) { if (!is_array($entry)) { continue; } $normalizedEntry = $this->scoring->normalize($entry); if (!$this->isValidDate((string) ($normalizedEntry['date'] ?? ''))) { continue; } $normalized[$normalizedEntry['date']] = $normalizedEntry; } ksort($normalized, SORT_STRING); $previousEntry = null; foreach ($normalized as $date => $entry) { $evaluation = $this->scoring->evaluate($entry, $settings, $previousEntry); $this->entries->save($username, $date, $entry, $evaluation); $previousEntry = $entry; } } private function downloadUserBackup(array $user, array $settings): never { if (!class_exists('ZipArchive')) { flash('error', 'Für den Backup-Download fehlt auf diesem Server die ZIP-Erweiterung.'); redirect('/options'); } $entries = $this->evaluateEntriesWithContext($this->entries->all($user['username']), $settings); $tempPath = tempnam(sys_get_temp_dir(), 'mood-backup-'); if ($tempPath === false) { throw new RuntimeException('Das Backup konnte gerade nicht vorbereitet werden.'); } $zip = new ZipArchive(); $opened = $zip->open($tempPath, ZipArchive::OVERWRITE); if ($opened !== true) { @unlink($tempPath); throw new RuntimeException('Das Backup konnte nicht als ZIP erstellt werden.'); } foreach ($entries as $entry) { $date = (string) ($entry['date'] ?? ''); if (!$this->isValidDate($date)) { continue; } $markdown = $this->entries->exportMarkdown( (string) ($user['username'] ?? ''), $date, $entry, $entry['evaluation'] ?? $this->scoring->evaluate($entry, $settings) ); $zip->addFromString($date . '.txt', $markdown); } foreach ($this->summaries->exportBackupFiles((string) ($user['username'] ?? '')) as $summaryFile) { $path = trim((string) ($summaryFile['path'] ?? '')); $content = (string) ($summaryFile['content'] ?? ''); if ($path === '' || $content === '') { continue; } $zip->addFromString($path, $content); } $zip->close(); $fileName = 'mood-board-backup-' . normalize_username((string) ($user['username'] ?? 'user')) . '-' . today() . '.zip'; header('Content-Type: application/zip'); header('Content-Disposition: attachment; filename="' . $fileName . '"'); header('Content-Length: ' . (string) filesize($tempPath)); header('Cache-Control: no-store, no-cache, must-revalidate'); header('Pragma: no-cache'); readfile($tempPath); @unlink($tempPath); exit; } private function importUserBackup(array $user, array $settings): int { $files = uploaded_files('backup_files'); if ($files === []) { throw new RuntimeException('Bitte wähle mindestens eine Backup-Datei aus.'); } $importedEntries = []; $importedSummaryCount = 0; foreach ($files as $file) { $error = (int) ($file['error'] ?? UPLOAD_ERR_NO_FILE); if ($error === UPLOAD_ERR_NO_FILE) { continue; } if ($error !== UPLOAD_ERR_OK) { throw new RuntimeException('Eine Backup-Datei konnte nicht hochgeladen werden.'); } $tmpName = (string) ($file['tmp_name'] ?? ''); $name = trim((string) ($file['name'] ?? '')); if ($tmpName === '' || !is_uploaded_file($tmpName)) { throw new RuntimeException('Eine Backup-Datei ist ungültig.'); } $extension = strtolower(pathinfo($name, PATHINFO_EXTENSION)); if ($extension === 'zip') { $zipContent = $this->entriesFromZip($user['username'], $tmpName); foreach ($zipContent['entries'] as $date => $entry) { $importedEntries[$date] = $entry; } $importedSummaryCount += (int) ($zipContent['summaries'] ?? 0); continue; } if ($extension !== 'txt') { throw new RuntimeException('Es werden nur .txt-Dateien oder ZIP-Backups unterstützt.'); } $content = (string) file_get_contents($tmpName); if ($this->summaries->importBackupFile((string) ($user['username'] ?? ''), $name, $content)) { $importedSummaryCount++; continue; } $date = $this->dateFromBackupFileName($name); $entry = $this->entries->parseMarkdown($content, $date); if ($entry === null) { throw new RuntimeException('Mindestens eine Tagesdatei konnte nicht gelesen werden.'); } $importedEntries[$date] = $entry; } if ($importedEntries === [] && $importedSummaryCount === 0) { throw new RuntimeException('Im Backup wurden keine gültigen Tagesdateien oder KI-Zusammenfassungen gefunden.'); } $existingEntries = $this->entries->all((string) ($user['username'] ?? '')); $entryMap = []; foreach ($existingEntries as $entry) { if (!is_array($entry) || !$this->isValidDate((string) ($entry['date'] ?? ''))) { continue; } $entryMap[$entry['date']] = $entry; } foreach ($importedEntries as $date => $entry) { $entryMap[$date] = $entry; } if ($importedEntries !== []) { $this->persistUserEntries((string) ($user['username'] ?? ''), $settings, array_values($entryMap)); } return count($importedEntries) + $importedSummaryCount; } private function entriesFromZip(string $username, string $path): array { if (!class_exists('ZipArchive')) { throw new RuntimeException('ZIP-Import ist auf diesem Server nicht verfügbar.'); } $zip = new ZipArchive(); $opened = $zip->open($path); if ($opened !== true) { throw new RuntimeException('Das ZIP-Backup konnte nicht geöffnet werden.'); } $entries = []; $summaryCount = 0; for ($index = 0; $index < $zip->numFiles; $index++) { $name = (string) $zip->getNameIndex($index); if ($name === '' || str_ends_with($name, '/')) { continue; } $baseName = basename($name); $content = $zip->getFromIndex($index); if (!is_string($content)) { continue; } if ($this->summaries->importBackupFile($username, $name, $content)) { $summaryCount++; continue; } if (!preg_match('/^\d{4}-\d{2}-\d{2}\.txt$/', $baseName)) { continue; } $date = $this->dateFromBackupFileName($baseName); $entry = $this->entries->parseMarkdown($content, $date); if ($entry !== null) { $entries[$date] = $entry; } } $zip->close(); return [ 'entries' => $entries, 'summaries' => $summaryCount, ]; } private function dateFromBackupFileName(string $fileName): string { $baseName = basename($fileName); if (!preg_match('/^(\d{4}-\d{2}-\d{2})\.txt$/', $baseName, $matches)) { throw new RuntimeException('Backup-Dateien müssen als YYYY-MM-DD.txt benannt sein.'); } $date = (string) ($matches[1] ?? ''); if (!$this->isValidDate($date)) { throw new RuntimeException('Eine Backup-Datei enthält ein ungültiges Datum.'); } return $date; } private function buildDashboardCharts(array $entries): array { $recent = array_slice($entries, -30); $calendar = array_slice($entries, -365); return [ 'calendar' => array_map(static function (array $entry): array { $balance = is_array($entry['evaluation']['balance'] ?? null) ? $entry['evaluation']['balance'] : []; return [ 'date' => $entry['date'], 'score' => (float) ($balance['percentage'] ?? $entry['evaluation']['percentage'] ?? 0), 'max' => 100, 'label' => $entry['evaluation']['label'], ]; }, $calendar), 'balance' => array_map(static function (array $entry): array { return [ 'date' => $entry['date'], 'value' => (float) ($entry['evaluation']['balance']['raw'] ?? 0), ]; }, $recent), 'mood' => array_map(static function (array $entry): array { return [ 'date' => $entry['date'], 'value' => $entry['mood'], ]; }, $recent), 'stress' => array_map(static function (array $entry): array { return [ 'date' => $entry['date'], 'value' => $entry['stress'], ]; }, $recent), 'pain' => array_map(static function (array $entry): array { return [ 'date' => $entry['date'], 'value' => $entry['pain'], ]; }, $recent), 'sport' => array_map(static function (array $entry): array { return [ 'date' => $entry['date'], 'value' => $entry['sport_minutes'] + walk_chart_value($entry), 'sport' => $entry['sport_minutes'], 'walk' => walk_chart_value($entry), 'walk_label' => format_walk_value($entry), 'sport_labels' => array_values(array_filter(array_map( static function (array $type): string { $label = (string) ($type['label'] ?? ''); $location = sport_location_label((string) ($type['location'] ?? '')); return $location !== '' ? $label . ' · ' . $location : $label; }, $entry['sport_type_meta'] ?? [] ))), 'sport_icons' => array_values(array_filter(array_map( static fn (array $type): ?string => isset($type['icon']) ? sport_icon_path((string) $type['icon']) : null, $entry['sport_type_meta'] ?? [] ))), 'sport_bonus' => (float) ($entry['evaluation']['components']['sport_bonus'] ?? 0), ]; }, $recent), ]; } private function calculateStreak(array $entries): int { if ($entries === []) { return 0; } $dates = array_map(static fn (array $entry): string => $entry['date'], $entries); rsort($dates, SORT_STRING); $streak = 1; $previous = new DateTimeImmutable($dates[0]); for ($index = 1, $count = count($dates); $index < $count; $index++) { $current = new DateTimeImmutable($dates[$index]); $diff = (int) $previous->diff($current)->format('%a'); if ($diff === 1) { $streak++; $previous = $current; continue; } break; } return $streak; } private function sanitizeSettings(array $input, ?array $existingSettings = null): array { $defaults = Defaults::settings(); $settings = array_replace_recursive($defaults, $existingSettings ?? []); $settings['walk'] = [ 'mode' => ($input['walk']['mode'] ?? ($settings['walk']['mode'] ?? 'time')) === 'steps' ? 'steps' : 'time', ]; $settings['sleep'] = [ 'optimal_hours' => max(1.0, min(16.0, round((float) ($input['sleep']['optimal_hours'] ?? ($settings['sleep']['optimal_hours'] ?? 7.0)), 1))), ]; $scoreMode = (string) ($input['display']['score_mode'] ?? ($settings['display']['score_mode'] ?? 'scale')); $settings['display'] = [ 'score_mode' => in_array($scoreMode, ['scale', 'percent', 'points'], true) ? $scoreMode : 'scale', ]; $settings['day_balance'] = [ 'mood_weight' => max(0, min(10, (int) ($input['day_balance']['mood_weight'] ?? ($settings['day_balance']['mood_weight'] ?? 3)))), 'energy_weight' => max(0, min(10, (int) ($input['day_balance']['energy_weight'] ?? ($settings['day_balance']['energy_weight'] ?? 2)))), 'stress_weight' => max(0, min(10, (int) ($input['day_balance']['stress_weight'] ?? ($settings['day_balance']['stress_weight'] ?? 2)))), 'adjustment_cap' => max(0.0, min(2.0, round((float) ($input['day_balance']['adjustment_cap'] ?? ($settings['day_balance']['adjustment_cap'] ?? 1.0)), 1))), 'points_per_step' => max(1, min(50, (int) ($input['day_balance']['points_per_step'] ?? ($settings['day_balance']['points_per_step'] ?? 12)))), ]; $settings['scoring']['mood_multiplier'] = max(0, min(10, (int) ($input['scoring']['mood_multiplier'] ?? ($settings['scoring']['mood_multiplier'] ?? 3)))); $settings['scoring']['energy_multiplier'] = max(0, min(10, (int) ($input['scoring']['energy_multiplier'] ?? ($settings['scoring']['energy_multiplier'] ?? 2)))); $settings['scoring']['stress_multiplier'] = max(0, min(10, (int) ($input['scoring']['stress_multiplier'] ?? ($settings['scoring']['stress_multiplier'] ?? 2)))); $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'] ?? ($settings['scoring']['sleep_feeling_multiplier'] ?? 2)))); $settings['scoring']['journal_points'] = max(0, min(20, (int) ($input['scoring']['journal_points'] ?? ($settings['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' => array_key_exists('tracking', $input) ? (isset($input['tracking']['pain_enabled']) && $input['tracking']['pain_enabled'] === '1') : !empty($settings['tracking']['pain_enabled']), ]; foreach ($defaults['scoring']['sleep_duration_points'] as $key => $default) { $settings['scoring']['sleep_duration_points'][$key] = max(0, min(20, (int) ($input['scoring']['sleep_duration_points'][$key] ?? ($settings['scoring']['sleep_duration_points'][$key] ?? $default)))); } foreach (['sport_bands', 'walk_bands'] as $bandKey) { foreach ($defaults['scoring'][$bandKey] as $index => $defaultBand) { $currentBand = $settings['scoring'][$bandKey][$index] ?? $defaultBand; $settings['scoring'][$bandKey][$index] = [ 'min' => max(0, min(2000, (int) ($input['scoring'][$bandKey][$index]['min'] ?? $currentBand['min'] ?? $defaultBand['min']))), 'max' => max(0, min(2000, (int) ($input['scoring'][$bandKey][$index]['max'] ?? $currentBand['max'] ?? $defaultBand['max']))), 'points' => max(-20, min(20, (int) ($input['scoring'][$bandKey][$index]['points'] ?? $currentBand['points'] ?? $defaultBand['points']))), ]; } } foreach ($defaults['scoring']['walk_step_targets'] as $index => $defaultTarget) { $currentTarget = $settings['scoring']['walk_step_targets'][$index] ?? $defaultTarget; $settings['scoring']['walk_step_targets'][$index] = [ 'steps' => max(0, min(100000, (int) ($input['scoring']['walk_step_targets'][$index]['steps'] ?? $currentTarget['steps'] ?? $defaultTarget['steps']))), 'points' => max(-20, min(20, (int) ($input['scoring']['walk_step_targets'][$index]['points'] ?? $currentTarget['points'] ?? $defaultTarget['points']))), ]; } foreach ($defaults['ratings'] as $index => $defaultRating) { $currentRating = $settings['ratings'][$index] ?? $defaultRating; $settings['ratings'][$index] = [ 'label' => trim((string) ($input['ratings'][$index]['label'] ?? $currentRating['label'] ?? $defaultRating['label'])) ?: $defaultRating['label'], 'min' => max(0, min(999, (int) ($input['ratings'][$index]['min'] ?? $currentRating['min'] ?? $defaultRating['min']))), 'max' => max(0, min(999, (int) ($input['ratings'][$index]['max'] ?? $currentRating['max'] ?? $defaultRating['max']))), ]; } foreach ($defaults['guardrails'] as $index => $defaultGuardrail) { $currentGuardrail = $settings['guardrails'][$index] ?? $defaultGuardrail; $energyRaw = $input['guardrails'][$index]['energy_max'] ?? $currentGuardrail['energy_max'] ?? $defaultGuardrail['energy_max']; $settings['guardrails'][$index] = [ 'mood_max' => max(1, min(10, (int) ($input['guardrails'][$index]['mood_max'] ?? $currentGuardrail['mood_max'] ?? $defaultGuardrail['mood_max']))), 'energy_max' => $energyRaw === '' || $energyRaw === null ? null : max(1, min(10, (int) $energyRaw)), 'cap_label' => trim((string) ($input['guardrails'][$index]['cap_label'] ?? $currentGuardrail['cap_label'] ?? $defaultGuardrail['cap_label'])) ?: $defaultGuardrail['cap_label'], ]; } $sportTypesProvided = array_key_exists('sport_types_present', $input) || array_key_exists('sport_types', $input); $settings['sport_types'] = normalized_sport_types([ 'sport_types' => $sportTypesProvided && is_array($input['sport_types'] ?? null) ? $input['sport_types'] : ($sportTypesProvided ? [] : ($settings['sport_types'] ?? $defaults['sport_types'])), ]); $time = trim((string) ($input['notifications']['time'] ?? ($settings['notifications']['time'] ?? $defaults['notifications']['time']))); if (!$this->isValidTime($time)) { $time = $defaults['notifications']['time']; } $settings['notifications'] = [ 'enabled' => array_key_exists('notifications', $input) ? (isset($input['notifications']['enabled']) && $input['notifications']['enabled'] === '1') : !empty($settings['notifications']['enabled']), 'time' => $time, ]; return $settings; } private function hydrateSettings(array $settings): array { $settings['sport_types'] = normalized_sport_types($settings); $settings['walk'] = array_replace( Defaults::settings()['walk'], is_array($settings['walk'] ?? null) ? $settings['walk'] : [] ); $settings['sleep'] = array_replace( Defaults::settings()['sleep'], is_array($settings['sleep'] ?? null) ? $settings['sleep'] : [] ); $settings['sleep']['optimal_hours'] = max(1.0, min(16.0, round((float) ($settings['sleep']['optimal_hours'] ?? 7.0), 1))); $settings['display'] = array_replace( Defaults::settings()['display'], is_array($settings['display'] ?? null) ? $settings['display'] : [] ); if (!in_array((string) ($settings['display']['score_mode'] ?? 'scale'), ['scale', 'percent', 'points'], true)) { $settings['display']['score_mode'] = 'scale'; } $settings['day_balance'] = array_replace( Defaults::settings()['day_balance'], is_array($settings['day_balance'] ?? null) ? $settings['day_balance'] : [] ); $settings['tracking'] = array_replace( Defaults::settings()['tracking'], is_array($settings['tracking'] ?? null) ? $settings['tracking'] : [] ); $settings['notifications'] = array_replace( Defaults::settings()['notifications'], is_array($settings['notifications'] ?? null) ? $settings['notifications'] : [] ); return $settings; } private function evaluateEntriesWithContext(array $entries, array $settings): array { $normalized = array_map(fn (array $entry): array => $this->scoring->normalize($entry), $entries); usort($normalized, static fn (array $a, array $b): int => strcmp($a['date'], $b['date'])); $entryMap = []; foreach ($normalized as $entry) { $entryMap[$entry['date']] = $entry; } $evaluated = []; foreach ($normalized as $entry) { $previousEntry = $entryMap[shift_date($entry['date'], -1)] ?? null; $evaluation = $this->scoring->evaluate($entry, $settings, $previousEntry); $evaluated[] = array_merge($entry, [ 'evaluation' => $evaluation, 'sport_type_meta' => find_sport_types($settings, $entry['sport_types']), ]); } return $evaluated; } private function buildWeeklyArchiveCards(array $entries, array $weeklySummaries): array { $groups = []; foreach ($entries as $entry) { $key = iso_week_key((string) ($entry['date'] ?? '')); if (!isset($groups[$key])) { $groups[$key] = []; } $groups[$key][] = $entry; } $summaryMap = []; foreach ($weeklySummaries as $summary) { $key = (string) ($summary['summary_key'] ?? ''); if ($key !== '') { $summaryMap[$key] = $summary; $groups[$key] = $groups[$key] ?? []; } } $cards = []; foreach ($groups as $key => $weekEntries) { $range = $this->weekRangeFromKey($key); $summary = $summaryMap[$key] ?? null; $noteEntries = array_values(array_filter( $weekEntries, static fn (array $entry): bool => trim((string) ($entry['note'] ?? '')) !== '' )); $canGenerate = count($noteEntries) >= 3; if ($summary !== null) { $statusLabel = 'KI vorhanden'; $statusTone = 'ready'; $statusHint = 'Erstellt am ' . format_compact_datetime((string) ($summary['created_at'] ?? '')); } elseif ($canGenerate) { $statusLabel = 'KI möglich'; $statusTone = 'pending'; $statusHint = 'KI-Wochenzusammenfassung kann erzeugt werden'; } else { $statusLabel = 'KI nicht möglich'; $statusTone = 'blocked'; $statusHint = 'Mindestens 3 Texteinträge nötig'; } $cards[] = [ 'summary_key' => $key, 'label' => iso_week_label($key), 'date_from' => $summary['date_from'] ?? $range['date_from'], 'date_to' => $summary['date_to'] ?? $range['date_to'], 'tracked_days' => count($weekEntries), 'note_entries_count' => count($noteEntries), 'can_generate' => $canGenerate, 'summary' => $summary, 'has_summary' => $summary !== null, 'status_label' => $statusLabel, 'status_tone' => $statusTone, 'status_hint' => $statusHint, 'trend_label' => $this->weeklyTrendLabel($weekEntries), ]; } usort($cards, static fn (array $left, array $right): int => strcmp((string) $right['date_to'], (string) $left['date_to'])); return $cards; } private function buildMonthlyArchiveCards(array $entries, array $weeklySummaries, array $monthlySummaries, array $weeklyArchive): array { $monthKeys = []; foreach ($entries as $entry) { $monthKeys[month_key((string) ($entry['date'] ?? ''))] = true; } foreach ($monthlySummaries as $summary) { $monthKeys[(string) ($summary['summary_key'] ?? '')] = true; } foreach ($weeklySummaries as $summary) { foreach ($this->monthKeysForRange((string) ($summary['date_from'] ?? ''), (string) ($summary['date_to'] ?? '')) as $monthKey) { $monthKeys[$monthKey] = true; } } $monthlySummaryMap = []; foreach ($monthlySummaries as $summary) { $key = (string) ($summary['summary_key'] ?? ''); if ($key !== '') { $monthlySummaryMap[$key] = $summary; } } $cards = []; foreach (array_keys($monthKeys) as $monthKey) { $range = $this->monthRangeFromKey($monthKey); $monthEntries = array_values(array_filter( $entries, static fn (array $entry): bool => month_key((string) ($entry['date'] ?? '')) === $monthKey )); $monthWeeklySummaries = array_values(array_filter( $weeklySummaries, fn (array $summary): bool => $this->summaryOverlapsMonth($summary, $monthKey) )); $monthWeeks = array_values(array_filter( $weeklyArchive, fn (array $week): bool => $this->archiveItemOverlapsMonth($week, $monthKey) )); usort($monthWeeklySummaries, static fn (array $left, array $right): int => strcmp((string) ($left['date_from'] ?? ''), (string) ($right['date_from'] ?? ''))); usort($monthWeeks, static fn (array $left, array $right): int => strcmp((string) ($left['date_from'] ?? ''), (string) ($right['date_from'] ?? ''))); $availableWeeklyCount = count($monthWeeklySummaries); $totalWeekCount = count($monthWeeks); $canGenerate = $availableWeeklyCount >= 2; $summary = $monthlySummaryMap[$monthKey] ?? null; if ($summary !== null) { $statusLabel = 'KI vorhanden'; $statusTone = 'ready'; $statusHint = 'Erstellt am ' . format_compact_datetime((string) ($summary['created_at'] ?? '')); } elseif ($canGenerate) { $statusLabel = 'KI möglich'; $statusTone = 'pending'; $statusHint = 'KI-Monatszusammenfassung kann erzeugt werden'; } else { $statusLabel = 'KI nicht möglich'; $statusTone = 'blocked'; $statusHint = 'Mindestens 2 KI-Wochenzusammenfassungen nötig'; } $cards[] = [ 'summary_key' => $monthKey, 'label' => month_label($monthKey), 'date_from' => $range['date_from'], 'date_to' => $range['date_to'], 'tracked_days' => count($monthEntries), 'weekly_summary_count' => $availableWeeklyCount, 'weekly_total_count' => $totalWeekCount, 'weekly_progress_label' => $availableWeeklyCount . ' von ' . ($totalWeekCount > 0 ? $totalWeekCount : 0) . ' Wochen mit KI', 'can_generate' => $canGenerate, 'summary' => $summary, 'has_summary' => $summary !== null, 'status_label' => $statusLabel, 'status_tone' => $statusTone, 'status_hint' => $statusHint, 'weeks' => array_map(static function (array $week): array { return [ 'label' => (string) ($week['label'] ?? ''), 'summary_key' => (string) ($week['summary_key'] ?? ''), 'has_summary' => !empty($week['has_summary']), ]; }, $monthWeeks), ]; } usort($cards, static fn (array $left, array $right): int => strcmp((string) $right['summary_key'], (string) $left['summary_key'])); return $cards; } private function buildWeeklySummaryContext(string $weekKey, array $entries): array { $range = $this->weekRangeFromKey($weekKey); $weekEntries = array_values(array_filter( $entries, static fn (array $entry): bool => iso_week_key((string) ($entry['date'] ?? '')) === $weekKey )); if ($weekEntries === []) { throw new RuntimeException('Für diese Kalenderwoche gibt es noch keine getrackten Tage.'); } $noteEntries = array_values(array_filter( $weekEntries, static fn (array $entry): bool => trim((string) ($entry['note'] ?? '')) !== '' )); if (count($noteEntries) < 3) { throw new RuntimeException('Für eine KI-Wochenzusammenfassung sind mindestens 3 Texteinträge nötig.'); } usort($weekEntries, static fn (array $left, array $right): int => strcmp((string) $left['date'], (string) $right['date'])); usort($noteEntries, static fn (array $left, array $right): int => strcmp((string) $left['date'], (string) $right['date'])); $bestDay = $this->bestEntry($weekEntries); $worstDay = $this->worstEntry($weekEntries); $alcoholDays = count(array_filter($weekEntries, static fn (array $entry): bool => !empty($entry['alcohol']))); $sportDays = count(array_filter($weekEntries, static fn (array $entry): bool => (int) ($entry['sport_minutes'] ?? 0) > 0)); return [ 'date_from' => $range['date_from'], 'date_to' => $range['date_to'], 'prompt' => [ 'week_label' => iso_week_label($weekKey) . ' (' . $range['date_from'] . ' bis ' . $range['date_to'] . ')', 'entry_count' => (string) count($noteEntries), 'tracked_days' => (string) count($weekEntries), 'avg_mood' => format_points($this->average($weekEntries, 'mood')), 'avg_stress' => format_points($this->average($weekEntries, 'stress')), 'avg_energy' => format_points($this->average($weekEntries, 'energy')), 'avg_sleep' => format_points($this->average($weekEntries, 'sleep_hours')) . ' h', 'walk_days' => (string) count(array_filter($weekEntries, static fn (array $entry): bool => walk_chart_value($entry) > 0)), 'sport_days' => (string) $sportDays, 'alcohol_days' => (string) $alcoholDays, 'best_day' => $bestDay !== null ? $this->summaryDayLabel($bestDay) : 'nicht bestimmbar', 'worst_day' => $worstDay !== null ? $this->summaryDayLabel($worstDay) : 'nicht bestimmbar', 'daily_entries' => $this->renderWeeklyDailyEntries($noteEntries), ], ]; } private function buildMonthlySummaryContext(string $monthKey, array $entries, array $weeklySummaries): array { $range = $this->monthRangeFromKey($monthKey); $monthEntries = array_values(array_filter( $entries, static fn (array $entry): bool => month_key((string) ($entry['date'] ?? '')) === $monthKey )); $monthWeeklySummaries = array_values(array_filter( $weeklySummaries, fn (array $summary): bool => $this->summaryOverlapsMonth($summary, $monthKey) )); usort($monthWeeklySummaries, static fn (array $left, array $right): int => strcmp((string) ($left['date_from'] ?? ''), (string) ($right['date_from'] ?? ''))); if (count($monthWeeklySummaries) < 2) { throw new RuntimeException('Für eine KI-Monatszusammenfassung sind mindestens 2 KI-Wochenzusammenfassungen nötig.'); } return [ 'date_from' => $range['date_from'], 'date_to' => $range['date_to'], 'prompt' => [ 'month_label' => month_label($monthKey) . ' (' . $range['date_from'] . ' bis ' . $range['date_to'] . ')', 'weekly_summary_count' => (string) count($monthWeeklySummaries), 'avg_mood_month' => format_points($this->average($monthEntries, 'mood')), 'avg_stress_month' => format_points($this->average($monthEntries, 'stress')), 'avg_energy_month' => format_points($this->average($monthEntries, 'energy')), 'avg_sleep_month' => format_points($this->average($monthEntries, 'sleep_hours')) . ' h', 'weekly_summaries' => $this->renderMonthlyWeeklySummaries($monthWeeklySummaries, $entries), ], ]; } private function renderWeeklyDailyEntries(array $entries): string { $chunks = []; foreach ($entries as $entry) { $details = [ 'Stimmung ' . (string) ($entry['mood'] ?? 0) . '/10', 'Energie ' . (string) ($entry['energy'] ?? 0) . '/10', 'Stress ' . (string) ($entry['stress'] ?? 0) . '/10', 'Schlaf ' . format_points((float) ($entry['sleep_hours'] ?? 0)) . ' h', 'Schlafgefühl ' . (string) ($entry['sleep_feeling'] ?? 0) . '/5', 'Sport ' . (string) ((int) ($entry['sport_minutes'] ?? 0)) . ' min', 'Spaziergang ' . format_walk_value($entry), 'Alkohol ' . (!empty($entry['alcohol']) ? 'ja' : 'nein'), 'Urteil ' . (string) ($entry['evaluation']['label'] ?? ''), ]; if (array_key_exists('pain', $entry) && !empty($entry['pain_enabled'])) { $details[] = 'Schmerzen ' . (string) ($entry['pain'] ?? 0) . '/10'; } $chunks[] = implode("\n", [ format_display_date((string) ($entry['date'] ?? ''), false) . ' (' . (string) ($entry['date'] ?? '') . ')', implode(' · ', $details), 'Notiz: ' . trim((string) ($entry['note'] ?? '')), ]); } return implode("\n\n", $chunks); } private function renderMonthlyWeeklySummaries(array $weeklySummaries, array $entries): string { $blocks = []; foreach ($weeklySummaries as $summary) { $weekKey = (string) ($summary['summary_key'] ?? ''); $weekEntries = array_values(array_filter( $entries, static fn (array $entry): bool => iso_week_key((string) ($entry['date'] ?? '')) === $weekKey )); $blocks[] = implode("\n", [ iso_week_label($weekKey) . ' (' . (string) ($summary['date_from'] ?? '') . ' bis ' . (string) ($summary['date_to'] ?? '') . ')', 'Wochenkennzahlen: Stimmung ' . format_points($this->average($weekEntries, 'mood')) . ' · Stress ' . format_points($this->average($weekEntries, 'stress')) . ' · Energie ' . format_points($this->average($weekEntries, 'energy')) . ' · Schlaf ' . format_points($this->average($weekEntries, 'sleep_hours')) . ' h', 'KI-Wochenrückblick:', trim((string) ($summary['text'] ?? '')), ]); } return implode("\n\n", $blocks); } private function average(array $entries, string $field): float { if ($entries === []) { return 0.0; } $sum = 0.0; foreach ($entries as $entry) { $sum += (float) ($entry[$field] ?? 0); } return round($sum / count($entries), 1); } private function bestEntry(array $entries): ?array { if ($entries === []) { return null; } usort($entries, static fn (array $left, array $right): int => ((float) ($right['evaluation']['total'] ?? 0)) <=> ((float) ($left['evaluation']['total'] ?? 0))); return $entries[0] ?? null; } private function worstEntry(array $entries): ?array { if ($entries === []) { return null; } usort($entries, static fn (array $left, array $right): int => ((float) ($left['evaluation']['total'] ?? 0)) <=> ((float) ($right['evaluation']['total'] ?? 0))); return $entries[0] ?? null; } private function summaryDayLabel(array $entry): string { return format_display_date((string) ($entry['date'] ?? ''), false) . ' · ' . format_points((float) ($entry['evaluation']['total'] ?? 0)) . ' Punkte' . ' · ' . (string) ($entry['evaluation']['label'] ?? ''); } private function weekRangeFromKey(string $weekKey): array { if (preg_match('/^(\d{4})-KW-(\d{2})$/', $weekKey, $matches) !== 1) { throw new RuntimeException('Die Kalenderwoche ist ungültig.'); } $year = (int) ($matches[1] ?? 0); $week = (int) ($matches[2] ?? 0); if ($week < 1 || $week > 53) { throw new RuntimeException('Die Kalenderwoche ist ungültig.'); } $start = (new DateTimeImmutable('now'))->setISODate($year, $week, 1); $end = $start->modify('+6 days'); return [ 'date_from' => $start->format('Y-m-d'), 'date_to' => $end->format('Y-m-d'), ]; } private function monthRangeFromKey(string $monthKey): array { if (preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $monthKey) !== 1) { throw new RuntimeException('Der Monat ist ungültig.'); } $current = DateTimeImmutable::createFromFormat('Y-m-d', $monthKey . '-01'); if ($current === false) { throw new RuntimeException('Der Monat ist ungültig.'); } return [ 'date_from' => $current->format('Y-m-01'), 'date_to' => $current->modify('last day of this month')->format('Y-m-d'), ]; } private function monthKeysForRange(string $dateFrom, string $dateTo): array { if (!$this->isValidDate($dateFrom) || !$this->isValidDate($dateTo)) { return []; } $keys = []; $current = DateTimeImmutable::createFromFormat('Y-m-d', $dateFrom); $end = DateTimeImmutable::createFromFormat('Y-m-d', $dateTo); if ($current === false || $end === false) { return []; } $current = $current->modify('first day of this month'); $end = $end->modify('first day of this month'); while ($current <= $end) { $keys[] = $current->format('Y-m'); $current = $current->modify('+1 month'); } return $keys; } private function summaryOverlapsMonth(array $summary, string $monthKey): bool { $range = $this->monthRangeFromKey($monthKey); $summaryFrom = (string) ($summary['date_from'] ?? ''); $summaryTo = (string) ($summary['date_to'] ?? ''); return $summaryFrom !== '' && $summaryTo !== '' && $summaryFrom <= $range['date_to'] && $summaryTo >= $range['date_from']; } private function buildArchiveMonthOptions(array $entries, array $weeklyArchive, array $monthlyArchive): array { $keys = []; foreach ($entries as $entry) { $keys[month_key((string) ($entry['date'] ?? ''))] = true; } foreach ($weeklyArchive as $week) { foreach ($this->monthKeysForRange((string) ($week['date_from'] ?? ''), (string) ($week['date_to'] ?? '')) as $monthKey) { $keys[$monthKey] = true; } } foreach ($monthlyArchive as $month) { $keys[(string) ($month['summary_key'] ?? '')] = true; } $options = array_values(array_filter(array_keys($keys), static fn (string $key): bool => $key !== '')); rsort($options, SORT_STRING); return $options; } private function normalizeArchiveView(string $view): string { $view = trim($view); return in_array($view, ['days', 'weeks', 'months'], true) ? $view : 'days'; } private function weeklyTrendLabel(array $entries): string { if ($entries === []) { return 'Keine Tendenz'; } $mood = $this->average($entries, 'mood'); $stress = $this->average($entries, 'stress'); if ($mood >= 7 && $stress <= 4) { return 'eher stabil'; } if ($mood <= 4 || $stress >= 7) { return 'eher belastet'; } return 'gemischte Tendenz'; } private function archiveItemOverlapsMonth(array $item, string $monthKey): bool { if (preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $monthKey) !== 1) { return true; } $dateFrom = (string) ($item['date_from'] ?? ''); $dateTo = (string) ($item['date_to'] ?? ''); if ($dateFrom === '' || $dateTo === '') { return false; } $range = $this->monthRangeFromKey($monthKey); return $dateFrom <= $range['date_to'] && $dateTo >= $range['date_from']; } private function archivePath(array $params = []): string { $filtered = array_filter($params, static fn (mixed $value): bool => $value !== null && $value !== ''); return $filtered === [] ? '/archive' : '/archive?' . http_build_query($filtered); } private function sendSecurityHeaders(): void { header('Referrer-Policy: strict-origin-when-cross-origin'); header('X-Frame-Options: DENY'); header('X-Content-Type-Options: nosniff'); 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: 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 { if (!verify_csrf($_POST['_token'] ?? null)) { http_response_code(419); exit('Ungültiges Formular-Token.'); } } private function enforceRequestCsrf(): void { if (!verify_request_csrf()) { json_response([ 'ok' => false, 'message' => 'Ungültiges Formular-Token.', ], 419); } } private function requireUser(): array { $user = $this->auth->user(); if ($user === null) { redirect('/login'); } return $user; } private function isValidDate(string $date): bool { $parsed = DateTimeImmutable::createFromFormat('Y-m-d', $date); return $parsed !== false && $parsed->format('Y-m-d') === $date; } private function isValidUsername(string $username): bool { return preg_match('/^[a-zA-Z0-9._-]{3,32}$/', $username) === 1; } private function isStrongPassword(string $password): bool { return strlen($password) >= 10; } private function isValidTime(string $time): bool { return preg_match('/^(?:[01]\d|2[0-3]):[0-5]\d$/', $time) === 1; } private function throttleKey(string $username): string { $remoteAddress = (string) ($_SERVER['REMOTE_ADDR'] ?? 'unknown'); return sha1($remoteAddress . '|' . normalize_username($username)); } private function handlePushSubscribe(): void { $this->enforceRequestCsrf(); $user = $this->requireUser(); $payload = request_json_body(); $subscription = $payload['subscription'] ?? null; if (!is_array($subscription)) { json_response(['ok' => false, 'message' => 'Die Push-Daten fehlen.'], 422); } try { $this->notifications->saveSubscription($user['username'], [ 'endpoint' => trim((string) ($subscription['endpoint'] ?? '')), 'keys' => [ 'p256dh' => trim((string) ($subscription['keys']['p256dh'] ?? '')), 'auth' => trim((string) ($subscription['keys']['auth'] ?? '')), ], 'content_encoding' => trim((string) ($payload['contentEncoding'] ?? 'aes128gcm')), 'user_agent' => (string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), ]); } catch (RuntimeException $exception) { json_response(['ok' => false, 'message' => $exception->getMessage()], 422); } json_response([ 'ok' => true, 'message' => 'Push ist auf diesem Gerät aktiviert.', 'count' => $this->notifications->subscriptionCount($user['username']), ]); } private function handlePushUnsubscribe(): void { $this->enforceRequestCsrf(); $user = $this->requireUser(); $payload = request_json_body(); $endpoint = trim((string) ($payload['endpoint'] ?? '')); if ($endpoint === '') { json_response(['ok' => false, 'message' => 'Kein Push-Endpunkt übergeben.'], 422); } $this->notifications->removeSubscription($user['username'], $endpoint); json_response([ 'ok' => true, 'message' => 'Push wurde für dieses Gerät entfernt.', 'count' => $this->notifications->subscriptionCount($user['username']), ]); } private function handlePushTest(): void { $this->enforceRequestCsrf(); $user = $this->requireUser(); $result = $this->sendNotificationsForUser($user['username'], [ 'title' => 'Mood-Board', 'body' => 'Die Push-Erinnerung ist auf diesem Gerät bereit.', 'url' => '/options', 'tag' => 'mood-push-test', ]); if ($result['sent'] <= 0) { json_response([ 'ok' => false, 'message' => 'Es konnte noch keine Test-Benachrichtigung gesendet werden. Bitte aktiviere Push zuerst auf diesem Gerät.', 'removed' => $result['removed'], ], 422); } json_response([ 'ok' => true, 'message' => 'Die Test-Benachrichtigung wurde verschickt.', 'sent' => $result['sent'], 'removed' => $result['removed'], ]); } private function handleReminderRun(): void { $providedToken = trim((string) ($_GET['token'] ?? ($_SERVER['HTTP_X_REMINDER_TOKEN'] ?? ''))); $expectedToken = $this->webPush->cronToken(); if ($providedToken === '' || !hash_equals($expectedToken, $providedToken)) { json_response(['ok' => false, 'message' => 'Ungültiger Reminder-Token.'], 403); } $stats = $this->runDueReminders(new DateTimeImmutable('now')); json_response([ 'ok' => true, 'processed' => $stats['processed'], 'sent_users' => $stats['sent_users'], 'already_tracked' => $stats['already_tracked'], 'skipped' => $stats['skipped'], 'removed_subscriptions' => $stats['removed_subscriptions'], ]); } private function sendNotificationsForUser(string $username, array $message): array { $subscriptions = $this->notifications->subscriptionsForUser($username); $sent = 0; $removedEndpoints = []; foreach ($subscriptions as $subscription) { try { $result = $this->webPush->send($subscription, $message); } catch (RuntimeException) { $result = [ 'ok' => false, 'remove' => false, ]; } if (!empty($result['ok'])) { $sent++; continue; } if (!empty($result['remove'])) { $endpoint = (string) ($subscription['endpoint'] ?? ''); if ($endpoint !== '') { $removedEndpoints[] = $endpoint; } } } if ($removedEndpoints !== []) { $this->notifications->removeInvalidSubscriptions($username, $removedEndpoints); } return [ 'sent' => $sent, 'removed' => count($removedEndpoints), ]; } private function isReminderDue(array $settings, array $state, string $today, string $currentTime): bool { if (empty($settings['notifications']['enabled'])) { return false; } $reminderTime = (string) ($settings['notifications']['time'] ?? ''); if (!$this->isValidTime($reminderTime)) { return false; } if ($currentTime < $reminderTime) { return false; } return (string) ($state['last_sent_date'] ?? '') !== $today; } private function triggerReminderCheckFromTraffic(string $method, string $path): void { if ($method !== 'GET' || $path === '/reminders/run') { return; } $lockPath = storage_path('system/reminder-traffic.lock'); $handle = fopen($lockPath, 'c+'); if ($handle === false) { return; } if (!flock($handle, LOCK_EX | LOCK_NB)) { fclose($handle); return; } try { $this->runDueReminders(new DateTimeImmutable('now')); } catch (Throwable) { // Reminder checks should never break normal page delivery. } flock($handle, LOCK_UN); fclose($handle); } private function runDueReminders(DateTimeImmutable $now): array { $today = $now->format('Y-m-d'); $currentTime = $now->format('H:i'); $processed = 0; $sentUsers = 0; $alreadyTracked = 0; $skipped = 0; $removed = 0; foreach ($this->users->all() as $account) { $username = (string) ($account['username'] ?? ''); if ($username === '') { continue; } $processed++; $settings = $this->hydrateSettings($this->settings->forUser($username)); $state = $this->notifications->reminderState($username); if (!$this->isReminderDue($settings, $state, $today, $currentTime)) { $skipped++; continue; } if ($this->entries->find($username, $today) !== null) { $alreadyTracked++; continue; } $result = $this->sendNotificationsForUser($username, [ 'title' => 'Mood-Board', 'body' => 'Nimm dir kurz Zeit für deinen heutigen Eintrag.', 'url' => '/track?date=' . rawurlencode($today), 'tag' => 'mood-reminder-' . $today, ]); $removed += $result['removed']; if ($result['sent'] > 0) { $sentUsers++; $this->notifications->saveReminderState($username, [ 'last_sent_date' => $today, 'last_sent_at' => $now->format(DATE_ATOM), ]); } } return [ 'processed' => $processed, 'sent_users' => $sentUsers, 'already_tracked' => $alreadyTracked, 'skipped' => $skipped, 'removed_subscriptions' => $removed, ]; } }