diff --git a/.htaccess b/.htaccess index dd178b0..59b5bac 100644 --- a/.htaccess +++ b/.htaccess @@ -2,6 +2,10 @@ Options -Indexes DirectoryIndex index.php AddType application/manifest+json .webmanifest + + SetEnvIf Authorization "(.+)" HTTP_AUTHORIZATION=$1 + + RewriteEngine On diff --git a/assets/css/app.css b/assets/css/app.css index 3c5837c..9fe8f4a 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -625,9 +625,35 @@ body.page-dashboard .content { .day-summary-card__title { display: block; - font-size: clamp(1.05rem, 2.5vw, 1.5rem); - font-weight: 400; + font-size: clamp(1.35rem, 4vw, 2.35rem); + font-weight: 650; line-height: 1.2; + color: #fff; + text-shadow: 0 8px 24px rgba(0, 0, 0, 0.38); +} + +.day-summary-card__chips { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; + margin-top: 0.85rem; +} + +.day-chip { + display: inline-flex; + align-items: center; + min-height: 2rem; + padding: 0.35rem 0.72rem; + border-radius: 999px; + background: rgba(255, 255, 255, 0.12); + border: 1px solid rgba(255, 255, 255, 0.16); + color: rgba(255, 255, 255, 0.92); + font-size: 0.88rem; +} + +.day-chip--bonus { + background: rgba(139, 255, 207, 0.16); + border-color: rgba(139, 255, 207, 0.32); } .day-summary-card__head { @@ -778,6 +804,72 @@ body.page-dashboard .content { color: var(--muted); } +.timeline-card__comment { + margin: 0.28rem 0 0; + color: rgba(239, 247, 255, 0.9); + font-size: 0.98rem; + line-height: 1.35; +} + +.timeline-card__stats { + display: flex; + flex-wrap: wrap; + gap: 0.42rem; + margin-top: 0.72rem; +} + +.timeline-card__stats span { + display: inline-flex; + align-items: center; + min-height: 1.9rem; + padding: 0.32rem 0.66rem; + border-radius: 999px; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.14); + color: rgba(239, 247, 255, 0.88); + font-size: 0.82rem; +} + +.timeline-route-map { + position: relative; + width: 100%; + max-width: 24rem; + height: 10.5rem; + margin-top: 0.8rem; + overflow: hidden; + border-radius: 1.1rem; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.08); +} + +.timeline-route-map svg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.timeline-route-map polyline { + fill: none; + stroke: #1494de; + stroke-width: 5; + stroke-linecap: round; + stroke-linejoin: round; + filter: drop-shadow(0 1px 2px rgba(255, 255, 255, 0.9)); +} + +.timeline-route-map a { + position: absolute; + right: 0.35rem; + bottom: 0.28rem; + padding: 0.12rem 0.35rem; + border-radius: 0.45rem; + background: rgba(255, 255, 255, 0.82); + color: rgba(10, 22, 35, 0.82); + font-size: 0.66rem; + text-decoration: none; +} + .timeline-card__meta strong { font-size: 0.92rem; color: rgba(239, 247, 255, 0.68); @@ -1620,6 +1712,13 @@ body.page-dashboard .content { border-color: rgba(69, 201, 141, 0.34); } + .week-insight-card { + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.78), rgba(248, 253, 255, 0.62)), + radial-gradient(circle at top left, rgba(90, 188, 242, 0.14), transparent 42%); + border-color: rgba(92, 129, 160, 0.2); + } + .range-moment-list__item, .timeline-card__body h3, .day-summary-card__title, @@ -1655,6 +1754,27 @@ body.page-dashboard .content { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.week-insight-card { + display: grid; + gap: 0.55rem; + margin-bottom: 1rem; + padding: 1rem 1.1rem; + border-radius: 1.55rem; + background: + linear-gradient(180deg, rgba(25, 36, 56, 0.72), rgba(16, 25, 40, 0.58)), + radial-gradient(circle at top left, rgba(139, 228, 255, 0.16), transparent 44%); +} + +.week-insight-card p { + margin: 0; + color: var(--muted); + line-height: 1.45; +} + +.week-insight-card strong { + color: var(--text); +} + .range-card { padding: 1rem 1.1rem; border-radius: 1.45rem; @@ -1746,6 +1866,38 @@ body.page-dashboard .content { border-color: rgba(255, 143, 143, 0.18); } +.health-import-progress { + display: grid; + gap: 0.45rem; + margin-bottom: 1rem; +} + +.health-import-progress__bar { + display: block; + width: 100%; + height: 0.72rem; + overflow: hidden; + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.1); + appearance: none; + -webkit-appearance: none; +} + +.health-import-progress__bar::-webkit-progress-bar { + background: transparent; +} + +.health-import-progress__bar::-webkit-progress-value { + border-radius: inherit; + background: linear-gradient(90deg, var(--primary), var(--accent)); +} + +.health-import-progress__bar::-moz-progress-bar { + border-radius: inherit; + background: linear-gradient(90deg, var(--primary), var(--accent)); +} + .options-logout-form { margin: 0; } @@ -2116,6 +2268,145 @@ body.page-dashboard .content { } } +@media (prefers-color-scheme: light) { + .dashboard-overlay__backdrop, + .options-overlay__backdrop { + background: rgba(224, 235, 245, 0.64); + } + + .dashboard-modal, + .dashboard-modal--settings, + .options-modal { + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.97), rgba(242, 248, 253, 0.93)), + radial-gradient(circle at 50% 0%, rgba(90, 188, 242, 0.16), transparent 46%); + border-color: rgba(92, 129, 160, 0.2); + color: var(--text); + } + + .dashboard-modal__controls, + .options-modal__controls { + background: linear-gradient(180deg, rgba(247, 252, 255, 0.96), rgba(247, 252, 255, 0.76), transparent); + } + + .dashboard-modal__round { + background: rgba(255, 255, 255, 0.74); + border-color: rgba(92, 129, 160, 0.22); + color: var(--text); + } + + .dashboard-modal__round--confirm { + background: linear-gradient(180deg, rgba(90, 188, 242, 0.24), rgba(99, 217, 180, 0.2)); + color: #0e2b45; + } + + .dashboard-modal__subtitle, + .overlay-signal-card p { + color: rgba(18, 48, 75, 0.62); + } + + .dashboard-modal__textarea textarea, + .options-modal input[type="text"], + .options-modal input[type="password"], + .options-modal input[type="number"], + .options-modal input[type="date"], + .options-modal select, + .options-modal textarea { + background: rgba(255, 255, 255, 0.72); + border-color: rgba(92, 129, 160, 0.22); + color: var(--text); + } + + .overlay-signal-card { + border-top-color: rgba(92, 129, 160, 0.16); + } + + .overlay-signal-card__buttons { + background: rgba(18, 48, 75, 0.08); + } + + .overlay-signal-card__buttons button + button { + border-left-color: rgba(18, 48, 75, 0.12); + } + + .dashboard-fab-menu { + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.88), rgba(245, 251, 255, 0.76)), + radial-gradient(circle at top left, rgba(90, 188, 242, 0.16), transparent 48%); + border-color: rgba(92, 129, 160, 0.2); + } + + .dashboard-fab-menu button, + .moment-type-card, + .moment-choice-pill span, + .options-menu-card, + .options-modal .settings-section, + .options-modal .band-card, + .options-modal .sport-type-card, + .options-modal .checkbox-row--panel, + .options-modal .push-panel, + .options-modal .detail-card--overlay { + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.78), rgba(247, 252, 255, 0.62)), + radial-gradient(circle at top left, rgba(90, 188, 242, 0.12), transparent 48%); + border-color: rgba(92, 129, 160, 0.2); + color: var(--text); + } + + .options-menu-card--danger { + background: rgba(219, 107, 107, 0.1); + border-color: rgba(219, 107, 107, 0.22); + } + + .health-import-progress__bar { + background: rgba(18, 48, 75, 0.08); + border-color: rgba(92, 129, 160, 0.16); + } + + .week-insight-card { + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.78), rgba(248, 253, 255, 0.62)), + radial-gradient(circle at top left, rgba(90, 188, 242, 0.14), transparent 42%); + border-color: rgba(92, 129, 160, 0.2); + } + + .moment-type-card.is-selected, + .moment-choice-pill input:checked + span { + background: rgba(90, 188, 242, 0.18); + border-color: rgba(20, 148, 222, 0.34); + } + + .day-summary-card__title { + color: #fff; + } + + .day-chip { + background: rgba(255, 255, 255, 0.62); + border-color: rgba(92, 129, 160, 0.2); + color: rgba(18, 48, 75, 0.9); + } + + .day-chip--bonus { + background: rgba(99, 217, 180, 0.18); + border-color: rgba(69, 201, 141, 0.3); + } + + .timeline-card__comment { + color: rgba(18, 48, 75, 0.82); + } + + .timeline-card__stats span { + background: rgba(255, 255, 255, 0.58); + border-color: rgba(92, 129, 160, 0.18); + color: rgba(18, 48, 75, 0.82); + } + + .timeline-route-map { + border-color: rgba(92, 129, 160, 0.2); + background: rgba(255, 255, 255, 0.5); + } +} + .site-footer { display: flex; justify-content: space-between; diff --git a/assets/js/app.js b/assets/js/app.js index 69235ff..e14c0f6 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -1557,6 +1557,100 @@ } } + function initHealthImportStatus() { + const panel = document.querySelector("[data-health-import-status]"); + if (!panel) { + return; + } + + const progressWrap = panel.querySelector("[data-health-progress-wrap]"); + const progress = panel.querySelector("[data-health-progress-bar]"); + const progressText = panel.querySelector("[data-health-progress-text]"); + const lastImport = panel.querySelector("[data-health-last-import]"); + const lastMessage = panel.querySelector("[data-health-last-message]"); + let timer = null; + + const formatDuration = seconds => { + const rounded = Math.max(0, Math.round(Number(seconds) || 0)); + if (rounded < 60) { + return `${rounded} s`; + } + + return `${Math.ceil(rounded / 60)} min`; + }; + + const render = status => { + const done = Math.max(0, Number(status.progress_done || 0)); + const total = Math.max(0, Number(status.progress_total || 0)); + const percent = total > 0 ? Math.min(100, Math.round((done / total) * 100)) : 0; + + if (progress) { + progress.value = String(percent); + progress.textContent = `${percent}%`; + } + + if (progressWrap) { + progressWrap.dataset.progressDone = String(done); + progressWrap.dataset.progressTotal = String(total); + } + + if (lastImport) { + lastImport.textContent = status.last_import_at ? new Date(status.last_import_at).toLocaleString("de-DE") : "-"; + } + + if (lastMessage) { + lastMessage.textContent = status.last_message || "-"; + } + + if (progressText) { + if (status.last_status === "running") { + let eta = "wird berechnet"; + const started = Date.parse(status.started_at || ""); + if (started && done > 0 && total > done) { + const elapsed = (Date.now() - started) / 1000; + eta = formatDuration((elapsed / done) * (total - done)); + } + progressText.textContent = `Import läuft: ${done} von ${total} verarbeitet. Restzeit ca. ${eta}.`; + return; + } + + if (status.last_status === "error") { + progressText.textContent = `${status.last_message || "Import fehlgeschlagen."} Derselbe Export kann erneut gesendet werden und wird idempotent übernommen.`; + return; + } + + progressText.textContent = status.last_message || "Noch kein Import gelaufen."; + } + }; + + const initialDone = progressWrap ? Number(progressWrap.dataset.progressDone || 0) : 0; + const initialTotal = progressWrap ? Number(progressWrap.dataset.progressTotal || 0) : 0; + render({ progress_done: initialDone, progress_total: initialTotal }); + + const refresh = async () => { + try { + const response = await fetch("/api/health/status", { credentials: "same-origin" }); + if (!response.ok) { + return; + } + const data = await response.json(); + if (!data || !data.ok || !data.status) { + return; + } + render(data.status); + if (data.status.last_status !== "running" && timer !== null) { + window.clearInterval(timer); + timer = null; + } + } catch (error) { + // Status polling is best-effort; the import itself is server-side. + } + }; + + refresh(); + timer = window.setInterval(refresh, 3500); + } + function csrfToken() { return document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") || ""; } @@ -1888,6 +1982,7 @@ initDashboardCharts(); initDashboardExperience(); initOptionsPanels(); + initHealthImportStatus(); initSportTypeManager(); initPwaShell(); initPullToRefresh(); diff --git a/src/App.php b/src/App.php index cd197dc..e3ea305 100644 --- a/src/App.php +++ b/src/App.php @@ -40,7 +40,7 @@ final class App $this->triggerReminderCheckFromTraffic($method, $path); $hasUsers = $this->users->hasAnyUsers(); $isAuthenticated = $this->auth->check(); - $systemPaths = ['/reminders/run']; + $systemPaths = ['/reminders/run', '/api/health']; // A failed setup must never leave the app in a half-authenticated redirect loop. if (!$hasUsers && $isAuthenticated) { @@ -141,6 +141,22 @@ final class App $this->handleReminderRun(); return; + case '/api/health': + if ($method !== 'POST') { + json_response(['ok' => false, 'message' => 'Method Not Allowed'], 405); + } + + $this->handleHealthImport(); + return; + + case '/api/health/status': + if ($method !== 'GET') { + json_response(['ok' => false, 'message' => 'Method Not Allowed'], 405); + } + + $this->handleHealthImportStatus(); + return; + default: http_response_code(404); View::render('not-found', [ @@ -234,6 +250,631 @@ final class App redirect('/'); } + private function handleHealthImport(): void + { + ignore_user_abort(true); + @set_time_limit(0); + + $token = $this->healthImportBearerToken(); + if ($token === '') { + json_response(['ok' => false, 'message' => 'Bearer-Token fehlt.'], 401); + } + + $user = $this->users->findByHealthImportToken($token); + if ($user === null) { + json_response(['ok' => false, 'message' => 'Bearer-Token ist ungültig.'], 401); + } + + $username = (string) ($user['username'] ?? ''); + $payload = request_json_body(); + if ($payload === []) { + $this->users->recordHealthImport($username, 'error', 'Leerer oder ungültiger JSON-Import.'); + json_response(['ok' => false, 'message' => 'Leerer oder ungültiger JSON-Import.'], 400); + } + + try { + $settings = $this->hydrateSettings($this->settings->forUser($username)); + $result = $this->importHealthPayload($username, $settings, $payload); + $message = sprintf( + 'Importiert: %d Tage, %d Schlaf, %d Sport, %d Spaziergänge.', + (int) $result['days'], + (int) $result['sleep'], + (int) $result['sport'], + (int) $result['walk'] + ); + $this->users->recordHealthImport($username, 'ok', $message); + json_response(['ok' => true, 'message' => $message, 'result' => $result]); + } catch (RuntimeException $exception) { + $this->users->recordHealthImport($username, 'error', $exception->getMessage()); + json_response(['ok' => false, 'message' => $exception->getMessage()], 400); + } + } + + private function handleHealthImportStatus(): void + { + $user = $this->requireUser(); + + json_response([ + 'ok' => true, + 'status' => $this->users->healthImportConfig((string) ($user['username'] ?? '')), + ]); + } + + private function healthImportBearerToken(): string + { + $header = trim((string) ($_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? '')); + if (preg_match('/^Bearer\s+(.+)$/i', $header, $matches) === 1) { + return trim((string) ($matches[1] ?? '')); + } + + return trim((string) ($_SERVER['HTTP_X_MOOD_HEALTH_TOKEN'] ?? '')); + } + + private function importHealthPayload(string $username, array $settings, array $payload): array + { + $metrics = $this->healthMetricsFromPayload($payload); + $workouts = $this->healthWorkoutsFromPayload($payload); + $metricImport = $this->healthEventsFromMetrics($metrics); + $workoutImport = $this->healthEventsFromWorkouts($workouts, $settings); + + $entries = $this->entries->all($username); + $entryMap = []; + foreach ($entries as $entry) { + if (is_array($entry) && $this->isValidDate((string) ($entry['date'] ?? ''))) { + $entryMap[(string) $entry['date']] = $entry; + } + } + + $dates = array_unique(array_merge( + array_keys($metricImport['steps']), + array_keys($metricImport['sleep']), + array_keys($workoutImport['sport']), + array_keys($workoutImport['walk']) + )); + sort($dates, SORT_STRING); + + if ($dates === []) { + throw new RuntimeException('Der Import enthielt keine unterstützten Health-Daten.'); + } + + $totalItems = max(1, $this->countHealthImportItems($metricImport, $workoutImport)); + $processedItems = 0; + $startedAt = date(DATE_ATOM); + $this->users->recordHealthImportProgress($username, 'Import vorbereitet.', 0, $totalItems, $startedAt); + + $sleepCount = 0; + $sportCount = 0; + $walkCount = 0; + $now = date(DATE_ATOM); + + foreach ($dates as $date) { + if (!$this->isValidDate((string) $date)) { + continue; + } + + $current = $entryMap[$date] ?? $this->scoring->normalize([ + 'date' => $date, + 'pain_enabled' => !empty($settings['tracking']['pain_enabled']), + 'walk_mode' => (string) ($settings['walk']['mode'] ?? 'time'), + 'summary' => ['comment' => '', 'mood' => 0, 'energy' => 0, 'stress' => 0, 'alcohol' => false], + 'events' => [], + 'background_image' => '', + ]); + + $events = array_values(array_filter( + is_array($current['events'] ?? null) ? $current['events'] : [], + 'is_array' + )); + + if (isset($metricImport['steps'][$date])) { + $health = is_array($current['health'] ?? null) ? $current['health'] : []; + $health['steps'] = max(0, (int) $metricImport['steps'][$date]); + $health['steps_imported_at'] = $now; + $current['health'] = $health; + $processedItems++; + } + + if (isset($metricImport['sleep'][$date])) { + $events = array_values(array_filter($events, static fn (array $event): bool => (string) ($event['type'] ?? '') !== 'sleep')); + foreach ($metricImport['sleep'][$date] as $event) { + $events[] = $event; + $sleepCount++; + $processedItems++; + } + } + + if (isset($workoutImport['sport'][$date])) { + $events = array_values(array_filter($events, static fn (array $event): bool => (string) ($event['type'] ?? '') !== 'sport')); + foreach ($workoutImport['sport'][$date] as $event) { + $events[] = $event; + $sportCount++; + $processedItems++; + } + } + + if (isset($workoutImport['walk'][$date])) { + $importIDs = []; + foreach ($workoutImport['walk'][$date] as $event) { + $importID = (string) ($event['import_id'] ?? ''); + if ($importID !== '') { + $importIDs[$importID] = true; + } + } + + $events = array_values(array_filter($events, static function (array $event) use ($importIDs): bool { + $importID = (string) ($event['import_id'] ?? ''); + + return $importID === '' || !isset($importIDs[$importID]); + })); + + foreach ($workoutImport['walk'][$date] as $event) { + $events[] = $event; + $walkCount++; + $processedItems++; + } + } + + $current['events'] = $events; + $entryMap[$date] = $current; + $this->users->recordHealthImportProgress( + $username, + 'Verarbeite ' . format_display_date((string) $date, false) . '.', + $processedItems, + $totalItems, + $startedAt + ); + } + + if (!empty($workoutImport['settings_changed'])) { + $this->settings->saveForUser($username, $settings); + } + + $this->persistUserEntries($username, $settings, array_values($entryMap)); + + return [ + 'days' => count($dates), + 'steps' => count($metricImport['steps']), + 'sleep' => $sleepCount, + 'sport' => $sportCount, + 'walk' => $walkCount, + 'sport_types_added' => (int) ($workoutImport['sport_types_added'] ?? 0), + ]; + } + + private function countHealthImportItems(array $metricImport, array $workoutImport): int + { + return count($metricImport['steps'] ?? []) + + array_sum(array_map('count', $metricImport['sleep'] ?? [])) + + array_sum(array_map('count', $workoutImport['sport'] ?? [])) + + array_sum(array_map('count', $workoutImport['walk'] ?? [])); + } + + private function healthMetricsFromPayload(array $payload): array + { + return array_values(array_filter( + is_array($payload['metrics'] ?? null) ? $payload['metrics'] : [], + 'is_array' + )); + } + + private function healthWorkoutsFromPayload(array $payload): array + { + if (is_array($payload['workouts'] ?? null)) { + return array_values(array_filter($payload['workouts'], 'is_array')); + } + + if (array_is_list($payload) && isset($payload[0]) && is_array($payload[0])) { + return array_values(array_filter($payload, 'is_array')); + } + + return []; + } + + private function healthEventsFromMetrics(array $metrics): array + { + $steps = []; + $sleepBuckets = []; + + foreach ($metrics as $metric) { + $name = strtolower((string) ($metric['name'] ?? '')); + $data = array_values(array_filter(is_array($metric['data'] ?? null) ? $metric['data'] : [], 'is_array')); + + if ($name === 'step_count') { + foreach ($data as $point) { + $date = $this->healthPointDate($point['date'] ?? null); + if ($date === null) { + continue; + } + + $steps[$date] = ($steps[$date] ?? 0) + max(0, (int) round((float) ($point['qty'] ?? 0))); + } + } + + if ($name === 'sleep_analysis') { + foreach ($data as $point) { + $date = $this->healthPointDate($point['date'] ?? ($point['sleepEnd'] ?? ($point['endDate'] ?? null))); + if ($date === null) { + continue; + } + + $hours = $this->healthSleepHours($point); + if ($hours <= 0) { + continue; + } + + $bucket = $sleepBuckets[$date] ?? [ + 'hours' => 0.0, + 'start' => null, + 'end' => null, + 'core' => 0.0, + 'deep' => 0.0, + 'rem' => 0.0, + ]; + + if (isset($point['totalSleep']) || isset($point['asleep'])) { + $bucket['hours'] = max((float) $bucket['hours'], $hours); + } else { + $bucket['hours'] = (float) $bucket['hours'] + $hours; + } + + foreach (['core', 'deep', 'rem'] as $phase) { + if (is_numeric($point[$phase] ?? null)) { + $bucket[$phase] = max((float) $bucket[$phase], (float) $point[$phase]); + } + } + + $start = $this->healthDateTime($point['sleepStart'] ?? ($point['inBedStart'] ?? ($point['startDate'] ?? null))); + $end = $this->healthDateTime($point['sleepEnd'] ?? ($point['inBedEnd'] ?? ($point['endDate'] ?? null))); + if ($start !== null && ($bucket['start'] === null || $start < $bucket['start'])) { + $bucket['start'] = $start; + } + if ($end !== null && ($bucket['end'] === null || $end > $bucket['end'])) { + $bucket['end'] = $end; + } + + $sleepBuckets[$date] = $bucket; + } + } + } + + $sleep = []; + foreach ($sleepBuckets as $date => $bucket) { + $commentParts = ['Automatisch importierter Schlaf']; + if ($bucket['start'] instanceof DateTimeImmutable && $bucket['end'] instanceof DateTimeImmutable) { + $commentParts[] = $bucket['start']->format('H:i') . '-' . $bucket['end']->format('H:i'); + } + foreach (['deep' => 'Tief', 'rem' => 'REM', 'core' => 'Kern'] as $phase => $label) { + if ((float) ($bucket[$phase] ?? 0) > 0) { + $commentParts[] = $label . ' ' . format_points((float) $bucket[$phase]) . ' h'; + } + } + + $sleep[$date][] = [ + 'id' => 'health-sleep-' . $date, + 'type' => 'sleep', + 'time' => $bucket['start'] instanceof DateTimeImmutable ? $bucket['start']->format('H:i') : '', + 'comment' => implode(' · ', $commentParts), + 'value' => round((float) $bucket['hours'], 2), + 'unit' => 'h', + 'sport_type_id' => '', + 'consumed' => true, + 'mood' => 0, + 'energy' => 0, + 'stress' => 0, + 'source' => 'health_auto_export', + 'import_id' => 'health-sleep-' . $date, + 'route' => [], + ]; + } + + return [ + 'steps' => $steps, + 'sleep' => $sleep, + ]; + } + + private function healthEventsFromWorkouts(array $workouts, array &$settings): array + { + $sport = []; + $walk = []; + $settingsChanged = false; + $sportTypesAdded = 0; + + foreach ($workouts as $workout) { + $start = $this->healthDateTime($workout['start'] ?? null); + $end = $this->healthDateTime($workout['end'] ?? null); + if ($start === null) { + continue; + } + + $date = $start->format('Y-m-d'); + $duration = is_numeric($workout['duration'] ?? null) + ? ((float) $workout['duration'] / 60) + : ($end !== null ? max(0, ($end->getTimestamp() - $start->getTimestamp()) / 60) : 0); + if ($duration <= 0) { + continue; + } + + $name = trim((string) ($workout['name'] ?? 'Workout')) ?: 'Workout'; + $importID = 'health-workout-' . trim((string) ($workout['id'] ?? sha1($name . $start->format(DATE_ATOM) . (string) ($workout['duration'] ?? '')))); + $route = $this->healthRouteFromWorkout($workout); + $comment = $this->healthWorkoutComment($name, $workout, $start, $end); + $durationLabel = format_points($duration) . ' min'; + $distanceLabel = $this->healthQuantityLabel($workout['distance'] ?? null); + $energyLabel = $this->healthQuantityLabel($workout['activeEnergyBurned'] ?? ($workout['activeEnergy'] ?? null)); + $heartRate = $workout['heartRate']['avg']['qty'] ?? ($workout['avgHeartRate']['qty'] ?? null); + $heartRateLabel = is_numeric($heartRate) ? 'Ø Puls ' . (string) round((float) $heartRate) . ' bpm' : ''; + + if ($this->healthWorkoutIsWalk($name)) { + $walk[$date][] = [ + 'id' => 'health-walk-' . substr(sha1($importID), 0, 12), + 'type' => 'walk', + 'time' => $start->format('H:i'), + 'comment' => $comment, + 'value' => round($duration), + 'unit' => 'min', + 'sport_type_id' => '', + 'consumed' => true, + 'mood' => 0, + 'energy' => 0, + 'stress' => 0, + 'source' => 'health_auto_export', + 'import_id' => $importID, + 'duration_label' => $durationLabel, + 'distance_label' => $distanceLabel, + 'energy_label' => $energyLabel, + 'heart_rate_label' => $heartRateLabel, + 'route' => $route, + ]; + continue; + } + + [$sportTypeID, $wasAdded] = $this->healthSportTypeForWorkout($name, $settings); + if ($wasAdded) { + $settingsChanged = true; + $sportTypesAdded++; + } + + $sport[$date][] = [ + 'id' => 'health-sport-' . substr(sha1($importID), 0, 12), + 'type' => 'sport', + 'time' => $start->format('H:i'), + 'comment' => $comment, + 'value' => round($duration), + 'unit' => 'min', + 'sport_type_id' => $sportTypeID, + 'consumed' => true, + 'mood' => 0, + 'energy' => 0, + 'stress' => 0, + 'source' => 'health_auto_export', + 'import_id' => $importID, + 'duration_label' => $durationLabel, + 'distance_label' => $distanceLabel, + 'energy_label' => $energyLabel, + 'heart_rate_label' => $heartRateLabel, + 'route' => $route, + ]; + } + + return [ + 'sport' => $sport, + 'walk' => $walk, + 'settings_changed' => $settingsChanged, + 'sport_types_added' => $sportTypesAdded, + ]; + } + + private function healthPointDate(mixed $value): ?string + { + $dateTime = $this->healthDateTime($value); + + return $dateTime?->format('Y-m-d'); + } + + private function healthDateTime(mixed $value): ?DateTimeImmutable + { + $raw = trim((string) $value); + if ($raw === '') { + return null; + } + + $timezone = new DateTimeZone(date_default_timezone_get()); + + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $raw) === 1) { + $date = DateTimeImmutable::createFromFormat('!Y-m-d', $raw, $timezone); + + return $date instanceof DateTimeImmutable ? $date : null; + } + + try { + return (new DateTimeImmutable($raw))->setTimezone($timezone); + } catch (Exception) { + return null; + } + } + + private function healthSleepHours(array $point): float + { + foreach (['totalSleep', 'asleep', 'qty', 'inBed'] as $key) { + if (is_numeric($point[$key] ?? null)) { + return max(0.0, min(24.0, (float) $point[$key])); + } + } + + $start = $this->healthDateTime($point['sleepStart'] ?? ($point['startDate'] ?? null)); + $end = $this->healthDateTime($point['sleepEnd'] ?? ($point['endDate'] ?? null)); + + if ($start !== null && $end !== null && $end > $start) { + return min(24.0, ($end->getTimestamp() - $start->getTimestamp()) / 3600); + } + + return 0.0; + } + + private function healthWorkoutIsWalk(string $name): bool + { + $normalized = normalize_sport_type_id($name); + + return in_array($normalized, ['walking', 'walk', 'outdoor-walk', 'indoor-walk'], true) + || str_contains($normalized, 'walking'); + } + + private function healthSportTypeForWorkout(string $name, array &$settings): array + { + $mapping = $this->healthWorkoutSportMapping($name); + $candidates = array_values(array_unique(array_filter(array_merge( + [(string) $mapping['id'], normalize_sport_type_id($name)], + $mapping['aliases'] + )))); + + foreach (normalized_sport_types($settings) as $type) { + $typeValues = array_filter([ + normalize_sport_type_id((string) ($type['id'] ?? '')), + normalize_sport_type_id((string) ($type['label'] ?? '')), + normalize_sport_type_id((string) ($type['recovery_group'] ?? '')), + ]); + + foreach ($candidates as $candidate) { + foreach ($typeValues as $typeValue) { + if ($candidate === $typeValue || str_contains($typeValue, $candidate) || str_contains($candidate, $typeValue)) { + return [(string) ($type['id'] ?? $candidate), false]; + } + } + + if (in_array($candidate, $typeValues, true)) { + return [(string) ($type['id'] ?? $candidate), false]; + } + } + } + + $settings['sport_types'][] = [ + 'id' => (string) $mapping['id'], + 'label' => (string) $mapping['label'], + 'icon' => (string) $mapping['icon'], + 'location' => '', + 'recovery_group' => (string) $mapping['id'], + 'bonus_points' => 2, + 'allow_consecutive' => false, + ]; + $settings['sport_types'] = normalized_sport_types($settings); + + return [(string) $mapping['id'], true]; + } + + private function healthWorkoutSportMapping(string $name): array + { + $normalized = normalize_sport_type_id($name); + $mappings = [ + 'running' => ['label' => 'Joggen', 'icon' => 'run', 'aliases' => ['running', 'run', 'jogging', 'joggen']], + 'cycling' => ['label' => 'Radfahren', 'icon' => 'bike', 'aliases' => ['cycling', 'biking', 'bike', 'radfahren', 'fahrrad']], + 'swimming' => ['label' => 'Schwimmen', 'icon' => 'swim', 'aliases' => ['swimming', 'swim', 'schwimmen']], + 'hiking' => ['label' => 'Wandern', 'icon' => 'hike', 'aliases' => ['hiking', 'wandern', 'hike']], + 'rowing' => ['label' => 'Rudergerät', 'icon' => 'row', 'aliases' => ['rowing', 'rower', 'rudergeraet', 'rudern']], + 'yoga' => ['label' => 'Yoga', 'icon' => 'yoga', 'aliases' => ['yoga']], + 'strength' => ['label' => 'Krafttraining', 'icon' => 'strength', 'aliases' => ['strength', 'strength-training', 'traditional-strength-training', 'functional-strength-training', 'krafttraining']], + 'hiit-workout' => ['label' => 'HIIT / Workout', 'icon' => 'hiit', 'aliases' => ['hiit', 'high-intensity-interval-training', 'workout', 'cross-training', 'functional-training']], + 'dance' => ['label' => 'Tanzen', 'icon' => 'dance', 'aliases' => ['dance', 'dancing', 'tanzen']], + 'core' => ['label' => 'Core', 'icon' => 'core', 'aliases' => ['core', 'core-training']], + 'pilates' => ['label' => 'Pilates', 'icon' => 'yoga', 'aliases' => ['pilates']], + 'elliptical' => ['label' => 'Crosstrainer', 'icon' => 'hiit', 'aliases' => ['elliptical', 'cross-trainer', 'crosstrainer']], + 'stair-climbing' => ['label' => 'Treppensteigen', 'icon' => 'hike', 'aliases' => ['stair-climbing', 'stairs', 'treppensteigen']], + ]; + + foreach ($mappings as $id => $mapping) { + foreach ($mapping['aliases'] as $alias) { + if ($normalized === $alias || str_contains($normalized, $alias)) { + return ['id' => $id] + $mapping; + } + } + } + + $id = normalize_sport_type_id($name) ?: 'workout'; + + return [ + 'id' => $id, + 'label' => trim($name) !== '' ? trim($name) : 'Workout', + 'icon' => 'hiit', + 'aliases' => [$id], + ]; + } + + private function healthWorkoutComment(string $name, array $workout, DateTimeImmutable $start, ?DateTimeImmutable $end): string + { + $parts = ['Apple Health · ' . $name]; + if ($end !== null) { + $parts[] = $start->format('H:i') . '-' . $end->format('H:i'); + } + + $heartRate = $workout['heartRate']['avg']['qty'] ?? ($workout['avgHeartRate']['qty'] ?? null); + if (is_numeric($heartRate)) { + $parts[] = 'Ø Puls ' . (string) round((float) $heartRate) . ' bpm'; + } + + return implode(' · ', $parts); + } + + private function healthQuantityLabel(mixed $quantity): string + { + if (!is_array($quantity) || !is_numeric($quantity['qty'] ?? null)) { + return ''; + } + + $qty = (float) $quantity['qty']; + $units = trim((string) ($quantity['units'] ?? '')); + + if ($units === '') { + return format_points($qty); + } + + return format_points($qty) . ' ' . $units; + } + + private function healthRouteFromWorkout(array $workout): array + { + $route = is_array($workout['route'] ?? null) ? $workout['route'] : []; + $points = []; + + foreach ($route as $point) { + if (!is_array($point)) { + continue; + } + + $lat = $point['latitude'] ?? ($point['lat'] ?? null); + $lon = $point['longitude'] ?? ($point['lon'] ?? null); + if (!is_numeric($lat) || !is_numeric($lon)) { + continue; + } + + $lat = (float) $lat; + $lon = (float) $lon; + if ($lat < -90 || $lat > 90 || $lon < -180 || $lon > 180) { + continue; + } + + $points[] = ['lat' => round($lat, 6), 'lon' => round($lon, 6)]; + } + + if (count($points) <= 180) { + return $points; + } + + $step = max(1, (int) floor(count($points) / 180)); + $reduced = []; + foreach ($points as $index => $point) { + if ($index % $step === 0) { + $reduced[] = $point; + } + } + + $last = $points[count($points) - 1]; + if ($reduced === [] || $reduced[count($reduced) - 1] !== $last) { + $reduced[] = $last; + } + + return $reduced; + } + private function showDashboard(): void { $user = $this->requireUser(); @@ -370,6 +1011,13 @@ final class App if ((string) ($event['id'] ?? '') === $eventID) { $updatedEvent['image'] = (string) ($event['image'] ?? ''); + $updatedEvent['source'] = (string) ($event['source'] ?? ''); + $updatedEvent['import_id'] = (string) ($event['import_id'] ?? ''); + $updatedEvent['duration_label'] = (string) ($event['duration_label'] ?? ''); + $updatedEvent['distance_label'] = (string) ($event['distance_label'] ?? ''); + $updatedEvent['energy_label'] = (string) ($event['energy_label'] ?? ''); + $updatedEvent['heart_rate_label'] = (string) ($event['heart_rate_label'] ?? ''); + $updatedEvent['route'] = is_array($event['route'] ?? null) ? $event['route'] : []; if (is_array($upload) && (int) ($upload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) { $this->deleteDashboardImage($user['username'], (string) ($event['image'] ?? '')); $updatedEvent['image'] = $this->storeDashboardImage($user['username'], $date, $upload); @@ -473,12 +1121,106 @@ final class App 'mood' => normalize_signal_value($event['mood'] ?? 0), 'energy' => normalize_signal_value($event['energy'] ?? 0), 'stress' => normalize_signal_value($event['stress'] ?? 0), + 'source' => (string) ($event['source'] ?? ''), + 'import_id' => (string) ($event['import_id'] ?? ''), + 'duration_label' => (string) ($event['duration_label'] ?? ''), + 'distance_label' => (string) ($event['distance_label'] ?? ''), + 'energy_label' => (string) ($event['energy_label'] ?? ''), + 'heart_rate_label' => (string) ($event['heart_rate_label'] ?? ''), + 'route_map' => $this->buildOsmRouteMap(is_array($event['route'] ?? null) ? $event['route'] : []), ]; } return $timeline; } + private function buildOsmRouteMap(array $route): ?array + { + $points = array_values(array_filter($route, static function (mixed $point): bool { + return is_array($point) + && is_numeric($point['lat'] ?? null) + && is_numeric($point['lon'] ?? null) + && (float) $point['lat'] >= -90 + && (float) $point['lat'] <= 90 + && (float) $point['lon'] >= -180 + && (float) $point['lon'] <= 180; + })); + + if (count($points) < 2) { + return null; + } + + $width = 320; + $height = 168; + $tileSize = 256; + $padding = 24; + $zoom = 15; + + for ($candidateZoom = 16; $candidateZoom >= 3; $candidateZoom--) { + $projected = array_map(fn (array $point): array => $this->projectOsmPoint((float) $point['lat'], (float) $point['lon'], $candidateZoom, $tileSize), $points); + $xs = array_column($projected, 'x'); + $ys = array_column($projected, 'y'); + $spanX = max($xs) - min($xs); + $spanY = max($ys) - min($ys); + + if ($spanX <= ($width - ($padding * 2)) && $spanY <= ($height - ($padding * 2))) { + $zoom = $candidateZoom; + break; + } + } + + $projected = array_map(fn (array $point): array => $this->projectOsmPoint((float) $point['lat'], (float) $point['lon'], $zoom, $tileSize), $points); + $xs = array_column($projected, 'x'); + $ys = array_column($projected, 'y'); + $left = ((min($xs) + max($xs)) / 2) - ($width / 2); + $top = ((min($ys) + max($ys)) / 2) - ($height / 2); + + $tileMinX = (int) floor($left / $tileSize); + $tileMaxX = (int) floor(($left + $width) / $tileSize); + $tileMinY = (int) floor($top / $tileSize); + $tileMaxY = (int) floor(($top + $height) / $tileSize); + $tileLimit = 2 ** $zoom; + $tiles = []; + + for ($x = $tileMinX; $x <= $tileMaxX; $x++) { + for ($y = $tileMinY; $y <= $tileMaxY; $y++) { + if ($y < 0 || $y >= $tileLimit) { + continue; + } + + $wrappedX = (($x % $tileLimit) + $tileLimit) % $tileLimit; + $tiles[] = [ + 'url' => 'https://tile.openstreetmap.org/' . $zoom . '/' . $wrappedX . '/' . $y . '.png', + 'left' => round(($x * $tileSize) - $left, 2), + 'top' => round(($y * $tileSize) - $top, 2), + ]; + } + } + + $linePoints = implode(' ', array_map( + static fn (array $point): string => round($point['x'] - $left, 1) . ',' . round($point['y'] - $top, 1), + $projected + )); + + return [ + 'width' => $width, + 'height' => $height, + 'tiles' => $tiles, + 'line' => $linePoints, + ]; + } + + private function projectOsmPoint(float $lat, float $lon, int $zoom, int $tileSize): array + { + $lat = max(-85.05112878, min(85.05112878, $lat)); + $scale = (2 ** $zoom) * $tileSize; + $x = (($lon + 180.0) / 360.0) * $scale; + $latRad = deg2rad($lat); + $y = (0.5 - (log(tan($latRad) + (1 / cos($latRad))) / (2 * M_PI))) * $scale; + + return ['x' => $x, 'y' => $y]; + } + private function buildDashboardCompareDays(string $date, array $entryMap, array $settings): array { $days = []; @@ -571,6 +1313,52 @@ final class App 'range' => $start->format('j.n.Y') . ' - ' . $start->modify('+6 day')->format('j.n.Y'), 'is_selected' => $isSelected, 'days' => $days, + 'insights' => $this->buildWeekHealthInsights($start, $days, $entryMap), + ]; + } + + private function buildWeekHealthInsights(DateTimeImmutable $start, array $days, array $entryMap): array + { + $weekSteps = []; + $sportMinutes = 0; + + foreach ($days as $day) { + $entry = is_array($day['entry'] ?? null) ? $day['entry'] : null; + if ($entry === null) { + continue; + } + + $steps = (int) ($entry['health']['steps'] ?? 0); + if ($steps > 0) { + $weekSteps[] = $steps; + } + + $sportMinutes += (int) ($entry['sport_minutes'] ?? 0); + } + + $weekAverageSteps = $weekSteps !== [] ? (int) round(array_sum($weekSteps) / count($weekSteps)) : 0; + $previousMonthStart = $start->modify('first day of previous month'); + $previousMonthEnd = $previousMonthStart->modify('last day of this month'); + $previousMonthSteps = []; + + for ($day = $previousMonthStart; $day <= $previousMonthEnd; $day = $day->modify('+1 day')) { + $entry = $entryMap[$day->format('Y-m-d')] ?? null; + $steps = is_array($entry) ? (int) ($entry['health']['steps'] ?? 0) : 0; + if ($steps > 0) { + $previousMonthSteps[] = $steps; + } + } + + $previousAverageSteps = $previousMonthSteps !== [] ? (int) round(array_sum($previousMonthSteps) / count($previousMonthSteps)) : 0; + $stepDifference = $previousAverageSteps > 0 ? $weekAverageSteps - $previousAverageSteps : 0; + + return [ + 'average_steps' => $weekAverageSteps, + 'previous_month_average_steps' => $previousAverageSteps, + 'step_difference' => $stepDifference, + 'step_direction' => $stepDifference >= 0 ? 'mehr' : 'weniger', + 'daily_sport_minutes' => (int) round($sportMinutes / 7), + 'has_step_comparison' => $weekAverageSteps > 0 && $previousAverageSteps > 0, ]; } @@ -656,6 +1444,10 @@ final class App return true; } + if ((int) ($entry['health']['steps'] ?? 0) > 0) { + return true; + } + return count(array_filter(is_array($entry['events'] ?? null) ? $entry['events'] : [], 'is_array')) > 0; } @@ -1169,6 +1961,14 @@ final class App } } + $pendingHealthTokens = is_array($_SESSION['_health_import_token'] ?? null) ? $_SESSION['_health_import_token'] : []; + $healthImportToken = $pendingHealthTokens[$user['username']] ?? null; + if (is_string($healthImportToken)) { + unset($_SESSION['_health_import_token'][$user['username']]); + } else { + $healthImportToken = null; + } + $optionsOpenPanel = trim((string) ($_GET['panel'] ?? '')); if ($optionsOpenPanel === 'score') { $optionsOpenPanel = ''; @@ -1186,6 +1986,9 @@ final class App 'pushAvailable' => $pushAvailable, 'pushPublicKey' => $pushPublicKey, 'pushSubscriptionCount' => $this->notifications->subscriptionCount($user['username']), + 'healthImportConfig' => $this->users->healthImportConfig($user['username']), + 'healthImportToken' => $healthImportToken, + 'healthImportUrl' => app_origin() . '/api/health', 'backupAvailable' => class_exists('ZipArchive'), 'aiConfig' => $user['is_admin'] ? $this->aiConfig->get() : null, 'aiStatus' => $user['is_admin'] ? $this->openAi->configuration() : null, @@ -1258,6 +2061,19 @@ final class App redirect('/options'); } + if ($form === 'health_import_token') { + $token = $this->users->issueHealthImportToken($user['username']); + $_SESSION['_health_import_token'][$user['username']] = $token; + flash('success', 'Der Health-Import-Token wurde erstellt. Kopiere ihn jetzt in Health Auto Export.'); + redirect('/options?panel=health'); + } + + if ($form === 'health_import_revoke') { + $this->users->revokeHealthImportToken($user['username']); + flash('success', 'Der Health-Import-Token wurde deaktiviert.'); + redirect('/options?panel=health'); + } + if ($form === 'password') { $current = (string) ($_POST['current_password'] ?? ''); $new = (string) ($_POST['new_password'] ?? ''); @@ -1690,6 +2506,15 @@ final class App $settings['scoring']['pain_multiplier'] = max(0, min(10, (int) ($input['scoring']['pain_multiplier'] ?? ($settings['scoring']['pain_multiplier'] ?? 3)))); $settings['scoring']['sleep_feeling_multiplier'] = max(0, min(10, (int) ($input['scoring']['sleep_feeling_multiplier'] ?? 2))); $settings['scoring']['journal_points'] = max(0, min(20, (int) ($input['scoring']['journal_points'] ?? 2))); + $stepBonus = is_array($settings['scoring']['step_bonus'] ?? null) ? $settings['scoring']['step_bonus'] : $defaults['scoring']['step_bonus']; + $settings['scoring']['step_bonus'] = [ + 'min' => max(0, min(100000, (int) ($input['scoring']['step_bonus']['min'] ?? $stepBonus['min'] ?? 10000))), + 'max' => max(0, min(100000, (int) ($input['scoring']['step_bonus']['max'] ?? $stepBonus['max'] ?? 15000))), + 'points' => max(0, min(20, (int) ($input['scoring']['step_bonus']['points'] ?? $stepBonus['points'] ?? 1))), + ]; + if ($settings['scoring']['step_bonus']['max'] < $settings['scoring']['step_bonus']['min']) { + $settings['scoring']['step_bonus']['max'] = $settings['scoring']['step_bonus']['min']; + } $settings['tracking'] = [ 'pain_enabled' => isset($input['tracking']['pain_enabled']) && $input['tracking']['pain_enabled'] === '1', ]; @@ -2300,7 +3125,7 @@ final class App header('X-Robots-Tag: noindex, nofollow, noarchive, nosnippet'); header('Cross-Origin-Opener-Policy: same-origin'); header('Permissions-Policy: camera=(), microphone=(), geolocation=()'); - header("Content-Security-Policy: default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'"); + header("Content-Security-Policy: default-src 'self'; img-src 'self' data: https://tile.openstreetmap.org; style-src 'self'; script-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'"); } private function enforceCsrf(): void diff --git a/src/Domain/EntryRepository.php b/src/Domain/EntryRepository.php index ffa97ef..1461317 100644 --- a/src/Domain/EntryRepository.php +++ b/src/Domain/EntryRepository.php @@ -187,6 +187,7 @@ final class EntryRepository 'stress' => normalize_signal_value($entry['summary_stress'] ?? legacy_to_signal_scale($entry['stress'] ?? 5)), ]; $events = is_array($entry['events'] ?? null) ? $entry['events'] : []; + $health = is_array($entry['health'] ?? null) ? $entry['health'] : []; $sportTypes = $evaluation['sport_types'] ?? []; $sportTypeValues = array_map( static fn (array $type): string => (string) $type['label'] . ' [' . (string) $type['id'] . ']', @@ -211,6 +212,14 @@ final class EntryRepository $eventLines[] = '- Stimmung: ' . (string) ($event['mood'] ?? 0); $eventLines[] = '- Energie: ' . (string) ($event['energy'] ?? 0); $eventLines[] = '- Stress: ' . (string) ($event['stress'] ?? 0); + $eventLines[] = '- Quelle: ' . (string) ($event['source'] ?? ''); + $eventLines[] = '- Import-ID: ' . (string) ($event['import_id'] ?? ''); + $eventLines[] = '- Dauer-Label: ' . (string) ($event['duration_label'] ?? ''); + $eventLines[] = '- Distanz-Label: ' . (string) ($event['distance_label'] ?? ''); + $eventLines[] = '- Energie-Label: ' . (string) ($event['energy_label'] ?? ''); + $eventLines[] = '- Puls-Label: ' . (string) ($event['heart_rate_label'] ?? ''); + $route = is_array($event['route'] ?? null) ? $event['route'] : []; + $eventLines[] = '- Route: ' . ($route !== [] ? base64_encode((string) json_encode($route, JSON_UNESCAPED_SLASHES)) : ''); $eventLines[] = ''; } @@ -230,6 +239,10 @@ final class EntryRepository '', '## Ereignisse', ...($eventLines !== [] ? $eventLines : ['- Keine Ereignisse', '']), + '## Gesundheitsdaten', + '- Schritte: ' . (int) ($health['steps'] ?? 0), + '- Schritte importiert am: ' . (string) ($health['steps_imported_at'] ?? ''), + '', '## Tracking', '## Werte', '- Stimmung: ' . $entry['mood'], @@ -258,6 +271,7 @@ final class EntryRepository '- Sport: ' . format_points((float) $evaluation['components']['sport_minutes']), '- Sportbonus: ' . format_points((float) ($evaluation['components']['sport_bonus'] ?? 0)), '- Spaziergang: ' . format_points((float) $evaluation['components']['walk_minutes']), + '- Schrittebonus: ' . format_points((float) ($evaluation['components']['step_bonus'] ?? 0)), '- Momente: ' . format_points((float) ($evaluation['components']['events'] ?? 0)), '- Alkohol: ' . format_points((float) ($evaluation['components']['alcohol'] ?? 0)), '- Notiz: ' . format_points((float) $evaluation['components']['note']), @@ -278,7 +292,9 @@ final class EntryRepository $backgroundImage = ''; } $summarySection = $this->extractSection($content, '## Tagesbilanz', '## Ereignisse'); - $eventsSection = $this->extractSection($content, '## Ereignisse', '## Tracking'); + $eventsSection = $this->extractSection($content, '## Ereignisse', '## Gesundheitsdaten') + ?? $this->extractSection($content, '## Ereignisse', '## Tracking'); + $healthSection = $this->extractSection($content, '## Gesundheitsdaten', '## Tracking'); $legacySection = $this->extractSection($content, '## Werte', '## Bewertung'); $legacyContent = $legacySection !== null ? "## Werte\n" . $legacySection : $content; @@ -320,9 +336,21 @@ final class EntryRepository 'mood' => normalize_signal_value($this->extract('/^- Stimmung:\s*(-?\d+)$/m', $block) ?? 0), 'energy' => normalize_signal_value($this->extract('/^- Energie:\s*(-?\d+)$/m', $block) ?? 0), 'stress' => normalize_signal_value($this->extract('/^- Stress:\s*(-?\d+)$/m', $block) ?? 0), + 'source' => (string) ($this->extract('/^- Quelle:\s*(.*)$/mu', $block) ?? ''), + 'import_id' => (string) ($this->extract('/^- Import-ID:\s*(.*)$/mu', $block) ?? ''), + 'duration_label' => (string) ($this->extract('/^- Dauer-Label:\s*(.*)$/mu', $block) ?? ''), + 'distance_label' => (string) ($this->extract('/^- Distanz-Label:\s*(.*)$/mu', $block) ?? ''), + 'energy_label' => (string) ($this->extract('/^- Energie-Label:\s*(.*)$/mu', $block) ?? ''), + 'heart_rate_label' => (string) ($this->extract('/^- Puls-Label:\s*(.*)$/mu', $block) ?? ''), + 'route' => $this->decodeRoute((string) ($this->extract('/^- Route:\s*(.*)$/mu', $block) ?? '')), ]; } + $health = [ + 'steps' => max(0, (int) ($this->extract('/^- Schritte:\s*(\d+)$/m', $healthSection ?? '') ?? 0)), + 'steps_imported_at' => (string) ($this->extract('/^- Schritte importiert am:\s*(.*)$/mu', $healthSection ?? '') ?? ''), + ]; + $base['date'] = $date; $base['background_image'] = $backgroundImage; $base['summary'] = $summary; @@ -331,6 +359,7 @@ final class EntryRepository $base['summary_energy'] = $summary['energy']; $base['summary_stress'] = $summary['stress']; $base['summary_alcohol'] = !empty($summary['alcohol']); + $base['health'] = $health; $base['events'] = $events; $base['alcohol'] = !empty($summary['alcohol']); $base['note'] = $summary['comment']; @@ -355,4 +384,42 @@ final class EntryRepository return preg_match('/^[A-Za-z0-9._-]+\.(?:jpe?g|png|webp)$/i', $fileName) === 1 ? $fileName : ''; } + + private function decodeRoute(string $encoded): array + { + $encoded = trim($encoded); + if ($encoded === '') { + return []; + } + + $decoded = base64_decode($encoded, true); + if (!is_string($decoded)) { + return []; + } + + $route = json_decode($decoded, true); + if (!is_array($route)) { + return []; + } + + $points = []; + foreach ($route as $point) { + if (!is_array($point) || !is_numeric($point['lat'] ?? null) || !is_numeric($point['lon'] ?? null)) { + continue; + } + + $lat = (float) $point['lat']; + $lon = (float) $point['lon']; + if ($lat < -90 || $lat > 90 || $lon < -180 || $lon > 180) { + continue; + } + + $points[] = [ + 'lat' => round($lat, 6), + 'lon' => round($lon, 6), + ]; + } + + return $points; + } } diff --git a/src/Domain/ScoringService.php b/src/Domain/ScoringService.php index 3e6e07b..c36ebc3 100644 --- a/src/Domain/ScoringService.php +++ b/src/Domain/ScoringService.php @@ -21,6 +21,7 @@ final class ScoringService $events = $this->normalizeEvents($input['events'] ?? []); $derived = $this->deriveLegacyFieldsFromEvents($events, $summary, $input); $sportTypes = normalize_sport_type_selection($hasEventInput ? ($derived['sport_types'] ?? []) : ($input['sport_types'] ?? ($derived['sport_types'] ?? ($input['sport_type'] ?? [])))); + $health = $this->normalizeHealth($input['health'] ?? []); return [ 'date' => $input['date'] ?? today(), @@ -46,6 +47,7 @@ final class ScoringService 'summary_stress' => $summary['stress'], 'summary_alcohol' => !empty($summary['alcohol']), 'background_image' => trim((string) ($input['background_image'] ?? '')), + 'health' => $health, 'events' => $events, ]; } @@ -69,7 +71,8 @@ final class ScoringService 'sleep_feeling' => $entry['sleep_feeling'] * (float) $scoring['sleep_feeling_multiplier'], 'sport_minutes' => $this->bandPoints((int) $entry['sport_minutes'], $scoring['sport_bands']), 'sport_bonus' => $sportBonus, - 'walk_minutes' => $this->walkPoints($entry, $settings), + 'walk_minutes' => 0.0, + 'step_bonus' => $this->stepBonusPoints($entry, $scoring['step_bonus'] ?? []), 'events' => $eventSignalPoints, 'alcohol' => !empty($entry['alcohol']) ? ((float) abs((float) ($scoring['alcohol_penalty'] ?? 5)) * -1) : 0.0, 'note' => $entry['note'] === '' ? 0 : (float) $scoring['journal_points'], @@ -89,7 +92,7 @@ final class ScoringService (5 * (float) $scoring['sleep_feeling_multiplier']) + $this->maxBandPoints($scoring['sport_bands']) + $this->maxSportBonusPoints($settings) + - $this->maxWalkPoints($entry, $settings) + + max(0.0, (float) ($scoring['step_bonus']['points'] ?? 0)) + ($eventSignalPoints !== 0.0 ? 8.0 : 0.0) + (float) $scoring['journal_points'], 1 @@ -217,6 +220,19 @@ final class ScoringService return $this->bandPoints((int) $entry['walk_minutes'], $scoring['walk_bands'] ?? []); } + private function stepBonusPoints(array $entry, array $config): float + { + $steps = (int) ($entry['health']['steps'] ?? 0); + $min = max(0, (int) ($config['min'] ?? 10000)); + $max = max($min, (int) ($config['max'] ?? 15000)); + + if ($steps > $min && $steps <= $max) { + return max(0.0, (float) ($config['points'] ?? 1)); + } + + return 0.0; + } + private function maxWalkPoints(array $entry, array $settings): float { $scoring = $settings['scoring'] ?? []; @@ -403,6 +419,13 @@ final class ScoringService 'mood' => normalize_signal_value($event['mood'] ?? 0), 'energy' => normalize_signal_value($event['energy'] ?? 0), 'stress' => normalize_signal_value($event['stress'] ?? 0), + 'source' => trim((string) ($event['source'] ?? '')), + 'import_id' => trim((string) ($event['import_id'] ?? '')), + 'duration_label' => trim((string) ($event['duration_label'] ?? '')), + 'distance_label' => trim((string) ($event['distance_label'] ?? '')), + 'energy_label' => trim((string) ($event['energy_label'] ?? '')), + 'heart_rate_label' => trim((string) ($event['heart_rate_label'] ?? '')), + 'route' => $this->normalizeRoute($event['route'] ?? []), ]; } @@ -413,6 +436,73 @@ final class ScoringService return $normalized; } + private function normalizeHealth(mixed $health): array + { + if (!is_array($health)) { + return [ + 'steps' => 0, + 'steps_imported_at' => '', + ]; + } + + return [ + 'steps' => max(0, min(100000, (int) ($health['steps'] ?? 0))), + 'steps_imported_at' => trim((string) ($health['steps_imported_at'] ?? '')), + ]; + } + + private function normalizeRoute(mixed $route): array + { + if (!is_array($route)) { + return []; + } + + $points = []; + foreach ($route as $point) { + if (!is_array($point)) { + continue; + } + + $lat = $point['lat'] ?? $point['latitude'] ?? null; + $lon = $point['lon'] ?? $point['longitude'] ?? null; + + if (!is_numeric($lat) || !is_numeric($lon)) { + continue; + } + + $lat = (float) $lat; + $lon = (float) $lon; + + if ($lat < -90 || $lat > 90 || $lon < -180 || $lon > 180) { + continue; + } + + $points[] = [ + 'lat' => round($lat, 6), + 'lon' => round($lon, 6), + ]; + } + + if (count($points) <= 180) { + return $points; + } + + $step = max(1, (int) floor(count($points) / 180)); + $reduced = []; + foreach ($points as $index => $point) { + if ($index % $step === 0) { + $reduced[] = $point; + } + } + + $last = $points[count($points) - 1]; + if ($reduced === [] || $reduced[count($reduced) - 1] !== $last) { + $reduced[] = $last; + } + + return $reduced; + } + private function deriveLegacyFieldsFromEvents(array $events, array $summary, array $input): array { $sportMinutes = 0; diff --git a/src/Domain/UserRepository.php b/src/Domain/UserRepository.php index cff3e75..fec6014 100644 --- a/src/Domain/UserRepository.php +++ b/src/Domain/UserRepository.php @@ -38,7 +38,7 @@ final class UserRepository public function verify(string $username, string $password): ?array { - $user = $this->find($username); + $user = $this->find($username) ?? []; if ($user === null) { return null; @@ -51,6 +51,257 @@ final class UserRepository return $user; } + public function findByRememberToken(string $selector, string $validator): ?array + { + $validatorHash = hash('sha256', $validator); + $now = time(); + + foreach ($this->all() as $user) { + $token = $user['remember_token'] ?? null; + + if (!is_array($token) || !hash_equals((string) ($token['selector'] ?? ''), $selector)) { + continue; + } + + $expiresAt = strtotime((string) ($token['expires_at'] ?? '')) ?: 0; + + if ($expiresAt < $now) { + return null; + } + + if (!hash_equals((string) ($token['validator_hash'] ?? ''), $validatorHash)) { + return null; + } + + return $user; + } + + return null; + } + + public function storeRememberToken(string $username, string $selector, string $validatorHash, int $expiresAt): void + { + $normalized = normalize_username($username); + $users = $this->all(); + $updated = false; + + foreach ($users as &$user) { + if (($user['username'] ?? '') !== $normalized) { + continue; + } + + $user['remember_token'] = [ + 'selector' => $selector, + 'validator_hash' => $validatorHash, + 'expires_at' => date(DATE_ATOM, $expiresAt), + 'created_at' => date(DATE_ATOM), + ]; + $user['updated_at'] = date(DATE_ATOM); + $updated = true; + break; + } + unset($user); + + if (!$updated) { + throw new RuntimeException('Der Remember-Me-Token konnte keinem Benutzer zugeordnet werden.'); + } + + $this->write(['users' => $users]); + } + + public function clearRememberToken(string $username): void + { + $normalized = normalize_username($username); + $users = $this->all(); + $updated = false; + + foreach ($users as &$user) { + if (($user['username'] ?? '') !== $normalized || !array_key_exists('remember_token', $user)) { + continue; + } + + unset($user['remember_token']); + $user['updated_at'] = date(DATE_ATOM); + $updated = true; + break; + } + unset($user); + + if ($updated) { + $this->write(['users' => $users]); + } + } + + public function findByHealthImportToken(string $token): ?array + { + $tokenHash = hash('sha256', $token); + + foreach ($this->all() as $user) { + $config = $user['health_import'] ?? null; + + if (!is_array($config) || empty($config['enabled'])) { + continue; + } + + if (hash_equals((string) ($config['token_hash'] ?? ''), $tokenHash)) { + return $user; + } + } + + return null; + } + + public function healthImportConfig(string $username): array + { + $user = $this->find($username); + $config = is_array($user['health_import'] ?? null) ? $user['health_import'] : []; + + return [ + 'enabled' => !empty($config['enabled']), + 'token_prefix' => (string) ($config['token_prefix'] ?? ''), + 'created_at' => (string) ($config['created_at'] ?? ''), + 'last_import_at' => (string) ($config['last_import_at'] ?? ''), + 'last_status' => (string) ($config['last_status'] ?? ''), + 'last_message' => (string) ($config['last_message'] ?? ''), + 'progress_done' => max(0, (int) ($config['progress_done'] ?? 0)), + 'progress_total' => max(0, (int) ($config['progress_total'] ?? 0)), + 'started_at' => (string) ($config['started_at'] ?? ''), + 'updated_at' => (string) ($config['updated_at'] ?? ''), + 'finished_at' => (string) ($config['finished_at'] ?? ''), + ]; + } + + public function issueHealthImportToken(string $username): string + { + $token = 'mood_health_' . bin2hex(random_bytes(24)); + $normalized = normalize_username($username); + $users = $this->all(); + $updated = false; + + foreach ($users as &$user) { + if (($user['username'] ?? '') !== $normalized) { + continue; + } + + $currentConfig = is_array($user['health_import'] ?? null) ? $user['health_import'] : []; + $user['health_import'] = [ + 'enabled' => true, + 'token_hash' => hash('sha256', $token), + 'token_prefix' => substr($token, 0, 18), + 'created_at' => date(DATE_ATOM), + 'last_import_at' => (string) ($currentConfig['last_import_at'] ?? ''), + 'last_status' => (string) ($currentConfig['last_status'] ?? ''), + 'last_message' => (string) ($currentConfig['last_message'] ?? ''), + 'progress_done' => max(0, (int) ($currentConfig['progress_done'] ?? 0)), + 'progress_total' => max(0, (int) ($currentConfig['progress_total'] ?? 0)), + 'started_at' => (string) ($currentConfig['started_at'] ?? ''), + 'updated_at' => (string) ($currentConfig['updated_at'] ?? ''), + 'finished_at' => (string) ($currentConfig['finished_at'] ?? ''), + ]; + $user['updated_at'] = date(DATE_ATOM); + $updated = true; + break; + } + unset($user); + + if (!$updated) { + throw new RuntimeException('Der Health-Import-Token konnte keinem Benutzer zugeordnet werden.'); + } + + $this->write(['users' => $users]); + + return $token; + } + + public function revokeHealthImportToken(string $username): void + { + $normalized = normalize_username($username); + $users = $this->all(); + $updated = false; + + foreach ($users as &$user) { + if (($user['username'] ?? '') !== $normalized || !array_key_exists('health_import', $user)) { + continue; + } + + unset($user['health_import']); + $user['updated_at'] = date(DATE_ATOM); + $updated = true; + break; + } + unset($user); + + if ($updated) { + $this->write(['users' => $users]); + } + } + + public function recordHealthImport(string $username, string $status, string $message): void + { + $normalized = normalize_username($username); + $users = $this->all(); + $updated = false; + + foreach ($users as &$user) { + if (($user['username'] ?? '') !== $normalized) { + continue; + } + + $config = is_array($user['health_import'] ?? null) ? $user['health_import'] : []; + $config['last_import_at'] = date(DATE_ATOM); + $config['last_status'] = $status; + $config['last_message'] = substr($message, 0, 240); + $config['updated_at'] = date(DATE_ATOM); + if ($status !== 'running') { + $config['finished_at'] = date(DATE_ATOM); + if ($status === 'ok') { + $total = max(0, (int) ($config['progress_total'] ?? 0)); + $config['progress_done'] = $total > 0 ? $total : max(0, (int) ($config['progress_done'] ?? 0)); + } + } + $user['health_import'] = $config; + $user['updated_at'] = date(DATE_ATOM); + $updated = true; + break; + } + unset($user); + + if ($updated) { + $this->write(['users' => $users]); + } + } + + public function recordHealthImportProgress(string $username, string $message, int $done, int $total, ?string $startedAt = null): void + { + $normalized = normalize_username($username); + $users = $this->all(); + $updated = false; + + foreach ($users as &$user) { + if (($user['username'] ?? '') !== $normalized) { + continue; + } + + $config = is_array($user['health_import'] ?? null) ? $user['health_import'] : []; + $config['last_status'] = 'running'; + $config['last_message'] = substr($message, 0, 240); + $config['progress_done'] = max(0, min($done, max($total, 0))); + $config['progress_total'] = max(0, $total); + $config['started_at'] = $startedAt ?? (string) ($config['started_at'] ?? date(DATE_ATOM)); + $config['updated_at'] = date(DATE_ATOM); + $config['finished_at'] = ''; + $user['health_import'] = $config; + $user['updated_at'] = date(DATE_ATOM); + $updated = true; + break; + } + unset($user); + + if ($updated) { + $this->write(['users' => $users]); + } + } + public function create(string $username, string $password, bool $isAdmin = false): array { $normalized = normalize_username($username); diff --git a/src/Support/Auth.php b/src/Support/Auth.php index 2574f98..64d435d 100644 --- a/src/Support/Auth.php +++ b/src/Support/Auth.php @@ -11,7 +11,7 @@ final class Auth public function check(): bool { if (!isset($_SESSION['user']) || !is_array($_SESSION['user'])) { - return false; + return $this->attemptRememberedLogin(); } $username = $_SESSION['user']['username'] ?? null; @@ -62,17 +62,76 @@ final class Auth $_SESSION['remember_me'] = $remember; if ($remember) { + $this->issueRememberCookie($user['username']); setcookie(session_name(), session_id(), session_cookie_options_for(time() + remember_me_lifetime())); } else { + $this->users->clearRememberToken($user['username']); + $this->clearRememberCookie(); setcookie(session_name(), session_id(), session_cookie_options_for()); } } public function logout(): void { + $username = $_SESSION['user']['username'] ?? null; + + if (is_string($username) && $username !== '') { + $this->users->clearRememberToken($username); + } + unset($_SESSION['user']); unset($_SESSION['remember_me']); + $this->clearRememberCookie(); setcookie(session_name(), '', session_cookie_options_for(time() - 3600)); session_regenerate_id(true); } + + private function attemptRememberedLogin(): bool + { + $cookie = $_COOKIE[remember_cookie_name()] ?? ''; + + if (!is_string($cookie) || $cookie === '') { + return false; + } + + $parts = explode(':', $cookie, 2); + + if (count($parts) !== 2) { + $this->clearRememberCookie(); + return false; + } + + [$selector, $validator] = $parts; + + if (!ctype_xdigit($selector) || strlen($selector) !== 32 || !ctype_xdigit($validator) || strlen($validator) !== 64) { + $this->clearRememberCookie(); + return false; + } + + $user = $this->users->findByRememberToken($selector, $validator); + + if ($user === null) { + $this->clearRememberCookie(); + return false; + } + + $this->login($user, true); + + return true; + } + + private function issueRememberCookie(string $username): void + { + $selector = bin2hex(random_bytes(16)); + $validator = bin2hex(random_bytes(32)); + $expiresAt = time() + remember_me_lifetime(); + + $this->users->storeRememberToken($username, $selector, hash('sha256', $validator), $expiresAt); + setcookie(remember_cookie_name(), $selector . ':' . $validator, session_cookie_options_for($expiresAt)); + } + + private function clearRememberCookie(): void + { + setcookie(remember_cookie_name(), '', session_cookie_options_for(time() - 3600)); + } } diff --git a/src/Support/Defaults.php b/src/Support/Defaults.php index 7aca432..da8833e 100644 --- a/src/Support/Defaults.php +++ b/src/Support/Defaults.php @@ -88,7 +88,7 @@ final class Defaults ], [ 'id' => 'rowing', - 'label' => 'Rudern', + 'label' => 'Rudergerät', 'icon' => 'row', 'location' => '', 'recovery_group' => 'rudern', @@ -152,6 +152,11 @@ final class Defaults ['steps' => 15000, 'points' => 4], ['steps' => 20000, 'points' => 0], ], + 'step_bonus' => [ + 'min' => 10000, + 'max' => 15000, + 'points' => 1, + ], 'journal_points' => 2, 'alcohol_penalty' => 5, ], diff --git a/src/helpers.php b/src/helpers.php index be55b96..4b171db 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -335,6 +335,11 @@ function remember_me_lifetime(): int return 60 * 60 * 24 * 30; } +function remember_cookie_name(): string +{ + return 'mood_remember'; +} + function session_cookie_params_for(int $lifetime = 0): array { return [ @@ -786,6 +791,10 @@ function day_entry_has_content(array $entry): bool 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; } diff --git a/templates/pages/dashboard.php b/templates/pages/dashboard.php index 4830bd1..5773815 100644 --- a/templates/pages/dashboard.php +++ b/templates/pages/dashboard.php @@ -7,6 +7,9 @@ $summaryMood = normalize_signal_value($dayEntry['summary']['mood'] ?? $dayEntry[ $summaryEnergy = normalize_signal_value($dayEntry['summary']['energy'] ?? $dayEntry['summary_energy'] ?? 0); $summaryStress = normalize_signal_value($dayEntry['summary']['stress'] ?? $dayEntry['summary_stress'] ?? 0); $summaryAlcohol = !empty($dayEntry['summary']['alcohol'] ?? $dayEntry['summary_alcohol'] ?? false); +$dayHealth = is_array($dayEntry['health'] ?? null) ? $dayEntry['health'] : []; +$daySteps = (int) ($dayHealth['steps'] ?? 0); +$dayStepBonus = (float) ($dayEntry['evaluation']['components']['step_bonus'] ?? 0); ?>
@@ -48,6 +51,14 @@ $summaryAlcohol = !empty($dayEntry['summary']['alcohol'] ?? $dayEntry['summary_a
@@ -70,6 +81,7 @@ $summaryAlcohol = !empty($dayEntry['summary']['alcohol'] ?? $dayEntry['summary_a + 0 ? rtrim(rtrim(number_format((float) $item['value'], 2, ',', '.'), '0'), ',') . ' ' . (string) $item['unit'] : ''; ?> @@ -84,6 +96,13 @@ $summaryAlcohol = !empty($dayEntry['summary']['alcohol'] ?? $dayEntry['summary_a 'walk', 'sleep' => trim($eventValueText), default => trim($eventValueText . ($sportType !== null ? ' · ' . (string) ($sportType['label'] ?? '') : '')), }; ?> + + trim($value) !== '')); ?> (string) ($item['id'] ?? ''), 'type' => (string) ($item['type'] ?? 'event'), @@ -97,8 +116,15 @@ $summaryAlcohol = !empty($dayEntry['summary']['alcohol'] ?? $dayEntry['summary_a 'mood' => normalize_signal_value($item['mood'] ?? 0), 'energy' => normalize_signal_value($item['energy'] ?? 0), 'stress' => normalize_signal_value($item['stress'] ?? 0), + 'source' => (string) ($item['source'] ?? ''), + 'import_id' => (string) ($item['import_id'] ?? ''), + 'duration_label' => (string) ($item['duration_label'] ?? ''), + 'distance_label' => (string) ($item['distance_label'] ?? ''), + 'energy_label' => (string) ($item['energy_label'] ?? ''), + 'heart_rate_label' => (string) ($item['heart_rate_label'] ?? ''), ]); ?> +
@@ -115,6 +141,9 @@ $summaryAlcohol = !empty($dayEntry['summary']['alcohol'] ?? $dayEntry['summary_a

+ +

+

@@ -127,6 +156,26 @@ $summaryAlcohol = !empty($dayEntry['summary']['alcohol'] ?? $dayEntry['summary_a

+ +
+ + + +
+ + + +
+ + © OpenStreetMap +
+ +
'Stimmung', 'energy' => 'Energie', 'stress' => 'Stress'] as $metric => $label): ?> @@ -371,6 +420,23 @@ $summaryAlcohol = !empty($dayEntry['summary']['alcohol'] ?? $dayEntry['summary_a

+ + 0 || (int) ($weekInsights['daily_sport_minutes'] ?? 0) > 0): ?> +
+ 0): ?> +

+ Du bist in dieser Woche durchschnittlich Schritte gegangen. + + Das sind Schritte als im vergangenen Monat. + +

+ + 0): ?> +

Täglich hast du im Schnitt Minuten Sport gemacht.

+ +
+ +
@@ -522,6 +588,7 @@ $summaryAlcohol = !empty($dayEntry['summary']['alcohol'] ?? $dayEntry['summary_a
Sportarten anpassenEigene Sportarten und Bonuspunkte Spaziergang anpassenZeit oder Schritte auswerten + Health ImportApple Health automatisch übernehmen Erinnerungen setzenPush und tägliche Erinnerung Bewertungsskala ändernLabels und Schutzregeln StatistikVerlauf und Aktivität diff --git a/templates/pages/options.php b/templates/pages/options.php index 48cb356..21bbd04 100644 --- a/templates/pages/options.php +++ b/templates/pages/options.php @@ -19,6 +19,7 @@ + @@ -94,13 +95,21 @@
@@ -121,6 +130,63 @@
+ +