From 3e497a8047d81f4c2855f95b43382c4e55a379ac Mon Sep 17 00:00:00 2001 From: Florian Heinz Date: Tue, 19 May 2026 15:54:50 +0200 Subject: [PATCH] Refine Health import event presentation --- assets/css/app.css | 117 ++++++++++++++++++++-- assets/js/app.js | 49 +++++++++ src/App.php | 176 +++++++++++++++++++++++++++++---- src/Domain/EntryRepository.php | 6 ++ src/Domain/ScoringService.php | 3 + templates/pages/dashboard.php | 88 +++++++++++++---- 6 files changed, 392 insertions(+), 47 deletions(-) diff --git a/assets/css/app.css b/assets/css/app.css index 9fe8f4a..cc196f8 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -743,12 +743,26 @@ body.page-dashboard .content { } .timeline-card__image { - grid-column: 1 / -1; width: 100%; + height: 100%; max-height: 18rem; object-fit: cover; border-radius: 1.25rem; - margin-bottom: 0.2rem; + display: block; +} + +.timeline-media-button { + grid-column: 1 / -1; + width: 100%; + min-height: 11rem; + max-height: 18rem; + padding: 0; + margin: 0 0 0.2rem; + border: 0; + border-radius: 1.25rem; + overflow: hidden; + background: rgba(255, 255, 255, 0.08); + cursor: zoom-in; } .timeline-card__time-chip { @@ -832,14 +846,16 @@ body.page-dashboard .content { .timeline-route-map { position: relative; + grid-column: 1 / -1; width: 100%; - max-width: 24rem; height: 10.5rem; - margin-top: 0.8rem; + margin-top: 0.2rem; + padding: 0; overflow: hidden; border-radius: 1.1rem; border: 1px solid rgba(255, 255, 255, 0.12); background: rgba(255, 255, 255, 0.08); + cursor: zoom-in; } .timeline-route-map svg { @@ -858,7 +874,7 @@ body.page-dashboard .content { filter: drop-shadow(0 1px 2px rgba(255, 255, 255, 0.9)); } -.timeline-route-map a { +.timeline-route-map__credit { position: absolute; right: 0.35rem; bottom: 0.28rem; @@ -867,7 +883,96 @@ body.page-dashboard .content { background: rgba(255, 255, 255, 0.82); color: rgba(10, 22, 35, 0.82); font-size: 0.66rem; - text-decoration: none; +} + +.sleep-phase-bar { + display: flex; + min-height: 2.35rem; + margin-top: 0.75rem; + overflow: hidden; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.14); + background: rgba(255, 255, 255, 0.08); +} + +.sleep-phase-bar__segment { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.25rem; + min-width: max-content; + padding: 0.35rem 0.65rem; + color: rgba(255, 255, 255, 0.94); + font-size: 0.82rem; + white-space: nowrap; +} + +.sleep-phase-bar__segment--deep { + background: linear-gradient(135deg, rgba(44, 82, 180, 0.92), rgba(38, 52, 124, 0.92)); +} + +.sleep-phase-bar__segment--rem { + background: linear-gradient(135deg, rgba(120, 83, 210, 0.92), rgba(73, 60, 154, 0.92)); +} + +.sleep-phase-bar__segment--core { + background: linear-gradient(135deg, rgba(54, 147, 173, 0.92), rgba(38, 106, 135, 0.92)); +} + +.media-lightbox[hidden] { + display: none; +} + +.media-lightbox { + position: fixed; + inset: 0; + z-index: 1200; + display: grid; + place-items: center; + padding: 1rem; +} + +.media-lightbox__backdrop { + position: absolute; + inset: 0; + border: 0; + background: rgba(0, 0, 0, 0.72); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); +} + +.media-lightbox__panel { + position: relative; + z-index: 1; + width: min(96vw, 72rem); + max-height: 92vh; +} + +.media-lightbox__close { + position: absolute; + right: 0.75rem; + top: 0.75rem; + z-index: 2; + width: 2.6rem; + height: 2.6rem; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.24); + background: rgba(20, 28, 38, 0.72); + color: #fff; + font-size: 1.5rem; +} + +.media-lightbox__content img, +.media-lightbox__content .timeline-route-map { + width: 100%; + max-width: none; + height: min(72vh, 42rem); + border-radius: 1.4rem; + object-fit: contain; +} + +.media-lightbox__content .timeline-route-map { + cursor: default; } .timeline-card__meta strong { diff --git a/assets/js/app.js b/assets/js/app.js index e14c0f6..ea3cf29 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -1651,6 +1651,54 @@ timer = window.setInterval(refresh, 3500); } + function initMediaLightbox() { + const lightbox = document.querySelector("[data-media-lightbox]"); + const content = document.querySelector("[data-media-lightbox-content]"); + if (!(lightbox instanceof HTMLElement) || !(content instanceof HTMLElement)) { + return; + } + + const close = () => { + lightbox.setAttribute("hidden", "hidden"); + content.replaceChildren(); + document.body.classList.remove("is-dashboard-overlay-open"); + }; + + document.querySelectorAll("[data-media-lightbox-close]").forEach(button => { + button.addEventListener("click", close); + }); + + document.addEventListener("keydown", event => { + if (event.key === "Escape" && !lightbox.hasAttribute("hidden")) { + close(); + } + }); + + document.querySelectorAll("[data-lightbox-kind]").forEach(trigger => { + trigger.addEventListener("click", event => { + event.preventDefault(); + event.stopPropagation(); + + content.replaceChildren(); + if (trigger.dataset.lightboxKind === "image") { + const image = document.createElement("img"); + image.src = trigger.dataset.lightboxSrc || ""; + image.alt = ""; + content.appendChild(image); + } else { + const clone = trigger.cloneNode(true); + clone.removeAttribute("data-lightbox-kind"); + clone.removeAttribute("aria-label"); + clone.disabled = true; + content.appendChild(clone); + } + + lightbox.removeAttribute("hidden"); + document.body.classList.add("is-dashboard-overlay-open"); + }); + }); + } + function csrfToken() { return document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") || ""; } @@ -1983,6 +2031,7 @@ initDashboardExperience(); initOptionsPanels(); initHealthImportStatus(); + initMediaLightbox(); initSportTypeManager(); initPwaShell(); initPullToRefresh(); diff --git a/src/App.php b/src/App.php index e9807e0..ea720d2 100644 --- a/src/App.php +++ b/src/App.php @@ -786,30 +786,25 @@ final class App $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'; - } - } + $signals = $this->healthSleepSignals($bucket); $sleep[$date][] = [ 'id' => 'health-sleep-' . $date, 'type' => 'sleep', 'time' => $bucket['start'] instanceof DateTimeImmutable ? $bucket['start']->format('H:i') : '', - 'comment' => implode(' · ', $commentParts), + 'comment' => '', 'value' => round((float) $bucket['hours'], 2), 'unit' => 'h', 'sport_type_id' => '', 'consumed' => true, - 'mood' => 0, - 'energy' => 0, - 'stress' => 0, + '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' => [], ]; } @@ -820,6 +815,39 @@ final class App ]; } + private function healthSleepSignals(array $bucket): array + { + $hours = (float) ($bucket['hours'] ?? 0); + $deep = (float) ($bucket['deep'] ?? 0); + $rem = (float) ($bucket['rem'] ?? 0); + $core = (float) ($bucket['core'] ?? 0); + $quality = 0; + + if ($hours >= 7 && $hours <= 9) { + $quality++; + } elseif ($hours < 5 || $hours > 10) { + $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))), + 'stress' => max(-2, min(2, $core >= 3.5 && $hours >= 6 ? -1 : ($hours < 5 ? 1 : 0))), + ]; + } + private function healthMetricName(string $name): string { $normalized = normalize_sport_type_id($name); @@ -856,10 +884,9 @@ final class App $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); + $comment = ''; $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' : ''; @@ -880,7 +907,7 @@ final class App 'import_id' => $importID, 'duration_label' => $durationLabel, 'distance_label' => $distanceLabel, - 'energy_label' => $energyLabel, + 'energy_label' => '', 'heart_rate_label' => $heartRateLabel, 'route' => $route, ]; @@ -909,7 +936,7 @@ final class App 'import_id' => $importID, 'duration_label' => $durationLabel, 'distance_label' => $distanceLabel, - 'energy_label' => $energyLabel, + 'energy_label' => '', 'heart_rate_label' => $heartRateLabel, 'route' => $route, ]; @@ -1231,8 +1258,11 @@ final class App $upload = uploaded_files('background_image')[0] ?? null; if (is_array($upload) && (int) ($upload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) { - $this->deleteDashboardImage($user['username'], (string) ($current['background_image'] ?? '')); + $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; @@ -1276,10 +1306,16 @@ final class App $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) { - $this->deleteDashboardImage($user['username'], (string) ($event['image'] ?? '')); + $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; @@ -1386,6 +1422,9 @@ final class App '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), 'route_map' => $this->buildOsmRouteMap(is_array($event['route'] ?? null) ? $event['route'] : []), ]; } @@ -1852,9 +1891,29 @@ final class App mkdir($directory, 0775, true); } - $fileName = $date . '-' . substr(sha1((string) microtime(true) . $username), 0, 10) . '.' . $extension; + $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)) { + 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.'); } @@ -1862,14 +1921,91 @@ final class App 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; + } + + $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 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(); diff --git a/src/Domain/EntryRepository.php b/src/Domain/EntryRepository.php index 1461317..5496a04 100644 --- a/src/Domain/EntryRepository.php +++ b/src/Domain/EntryRepository.php @@ -218,6 +218,9 @@ final class EntryRepository $eventLines[] = '- Distanz-Label: ' . (string) ($event['distance_label'] ?? ''); $eventLines[] = '- Energie-Label: ' . (string) ($event['energy_label'] ?? ''); $eventLines[] = '- Puls-Label: ' . (string) ($event['heart_rate_label'] ?? ''); + $eventLines[] = '- Tiefschlaf: ' . (string) ($event['sleep_deep'] ?? 0); + $eventLines[] = '- REM-Schlaf: ' . (string) ($event['sleep_rem'] ?? 0); + $eventLines[] = '- Kernschlaf: ' . (string) ($event['sleep_core'] ?? 0); $route = is_array($event['route'] ?? null) ? $event['route'] : []; $eventLines[] = '- Route: ' . ($route !== [] ? base64_encode((string) json_encode($route, JSON_UNESCAPED_SLASHES)) : ''); $eventLines[] = ''; @@ -342,6 +345,9 @@ final class EntryRepository '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) ?? ''), + 'sleep_deep' => (float) ($this->extract('/^- Tiefschlaf:\s*(.*)$/mu', $block) ?? 0), + 'sleep_rem' => (float) ($this->extract('/^- REM-Schlaf:\s*(.*)$/mu', $block) ?? 0), + 'sleep_core' => (float) ($this->extract('/^- Kernschlaf:\s*(.*)$/mu', $block) ?? 0), 'route' => $this->decodeRoute((string) ($this->extract('/^- Route:\s*(.*)$/mu', $block) ?? '')), ]; } diff --git a/src/Domain/ScoringService.php b/src/Domain/ScoringService.php index c36ebc3..166f93f 100644 --- a/src/Domain/ScoringService.php +++ b/src/Domain/ScoringService.php @@ -425,6 +425,9 @@ final class ScoringService 'distance_label' => trim((string) ($event['distance_label'] ?? '')), 'energy_label' => trim((string) ($event['energy_label'] ?? '')), 'heart_rate_label' => trim((string) ($event['heart_rate_label'] ?? '')), + 'sleep_deep' => max(0.0, min(24.0, is_numeric($event['sleep_deep'] ?? null) ? (float) $event['sleep_deep'] : 0.0)), + 'sleep_rem' => max(0.0, min(24.0, is_numeric($event['sleep_rem'] ?? null) ? (float) $event['sleep_rem'] : 0.0)), + 'sleep_core' => max(0.0, min(24.0, is_numeric($event['sleep_core'] ?? null) ? (float) $event['sleep_core'] : 0.0)), 'route' => $this->normalizeRoute($event['route'] ?? []), ]; } diff --git a/templates/pages/dashboard.php b/templates/pages/dashboard.php index 5773815..b5f3cb5 100644 --- a/templates/pages/dashboard.php +++ b/templates/pages/dashboard.php @@ -82,11 +82,13 @@ $dayStepBonus = (float) ($dayEntry['evaluation']['components']['step_bonus'] ?? + + 0 ? rtrim(rtrim(number_format((float) $item['value'], 2, ',', '.'), '0'), ',') . ' ' . (string) $item['unit'] : ''; ?> (string) ($sportType['label'] ?? 'Sport'), + 'sport' => $isImportedWalkSport ? 'Spaziergang' : (string) ($sportType['label'] ?? 'Sport'), 'walk' => 'Spaziergang', 'sleep' => 'Schlaf', default => (string) ($item['comment'] !== '' ? $item['comment'] : day_event_type_label($eventType)), @@ -96,13 +98,31 @@ $dayStepBonus = (float) ($dayEntry['evaluation']['components']['step_bonus'] ?? 'walk', 'sleep' => trim($eventValueText), default => trim($eventValueText . ($sportType !== null ? ' · ' . (string) ($sportType['label'] ?? '') : '')), }; ?> - + + (float) ($item['sleep_deep'] ?? 0), 'rem' => (float) ($item['sleep_rem'] ?? 0), 'core' => (float) ($item['sleep_core'] ?? 0)]; + if ($eventType === 'sleep' && array_sum($sleepPhases) <= 0 && $eventComment !== '') { + if (preg_match('/Tief\s+([0-9]+(?:[,.][0-9]+)?)/u', $eventComment, $match) === 1) { + $sleepPhases['deep'] = (float) str_replace(',', '.', $match[1]); + } + if (preg_match('/REM\s+([0-9]+(?:[,.][0-9]+)?)/u', $eventComment, $match) === 1) { + $sleepPhases['rem'] = (float) str_replace(',', '.', $match[1]); + } + if (preg_match('/Kern\s+([0-9]+(?:[,.][0-9]+)?)/u', $eventComment, $match) === 1) { + $sleepPhases['core'] = (float) str_replace(',', '.', $match[1]); + } + } + $sleepPhaseTotal = max(0.0, array_sum($sleepPhases)); + ?> trim($value) !== '')); ?> + ], static function (string $value): bool { + $value = trim($value); + return $value !== '' && !preg_match('/^-\s*(Distanz|Energie|Puls|Route)-?Label:?$/u', $value); + })); ?> (string) ($item['id'] ?? ''), 'type' => (string) ($item['type'] ?? 'event'), @@ -122,12 +142,17 @@ $dayStepBonus = (float) ($dayEntry['evaluation']['components']['step_bonus'] ?? 'distance_label' => (string) ($item['distance_label'] ?? ''), 'energy_label' => (string) ($item['energy_label'] ?? ''), 'heart_rate_label' => (string) ($item['heart_rate_label'] ?? ''), + 'sleep_deep' => (float) ($item['sleep_deep'] ?? 0), + 'sleep_rem' => (float) ($item['sleep_rem'] ?? 0), + 'sleep_core' => (float) ($item['sleep_core'] ?? 0), ]); ?>
- +
@@ -156,6 +181,18 @@ $dayStepBonus = (float) ($dayEntry['evaluation']['components']['step_bonus'] ??

+ 0): ?> +
+ ['Tief', 'deep'], 'rem' => ['REM', 'rem'], 'core' => ['Kern', 'core']] as $phase => [$label, $class]): ?> + + + + h + + +
+ +
@@ -164,18 +201,6 @@ $dayStepBonus = (float) ($dayEntry['evaluation']['components']['step_bonus'] ??
- -
- - © OpenStreetMap -
- -
'Stimmung', 'energy' => 'Energie', 'stress' => 'Stress'] as $metric => $label): ?> @@ -189,6 +214,18 @@ $dayStepBonus = (float) ($dayEntry['evaluation']['components']['step_bonus'] ??
+ + + +
@@ -518,9 +555,10 @@ $dayStepBonus = (float) ($dayEntry['evaluation']['components']['step_bonus'] ?? - - - + + + +
@@ -602,4 +640,12 @@ $dayStepBonus = (float) ($dayEntry['evaluation']['components']['step_bonus'] ??
+ +