diff --git a/assets/css/app.css b/assets/css/app.css index de68ba4..4f7e39e 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -462,6 +462,51 @@ body.page-dashboard .content { padding-top: 6rem; } +.dashboard-day-slider { + --day-prev-hint: 0; + --day-next-hint: 0; + position: relative; + overflow: hidden; + border-radius: 1.8rem; + margin-bottom: 2rem; +} + +.day-slide-hint { + position: absolute; + top: 50%; + z-index: 0; + display: inline-flex; + align-items: center; + min-height: 3rem; + max-width: min(12rem, 42vw); + padding: 0.8rem 1rem; + border: 1px solid rgba(255, 255, 255, 0.18); + border-radius: 999px; + background: rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.9); + font-size: 0.86rem; + font-weight: 700; + line-height: 1.1; + letter-spacing: -0.01em; + opacity: 0; + pointer-events: none; + transform: translateY(-50%) scale(0.94); + transition: opacity 120ms ease, transform 180ms cubic-bezier(0.2, 0.8, 0.2, 1); +} + +.day-slide-hint--prev { + left: 0.75rem; + opacity: var(--day-prev-hint); + transform: translateY(-50%) translateX(calc((1 - var(--day-prev-hint)) * -0.7rem)) scale(calc(0.94 + (var(--day-prev-hint) * 0.06))); +} + +.day-slide-hint--next { + right: 0.75rem; + opacity: var(--day-next-hint); + text-align: right; + transform: translateY(-50%) translateX(calc((1 - var(--day-next-hint)) * 0.7rem)) scale(calc(0.94 + (var(--day-next-hint) * 0.06))); +} + .dashboard-compare-strip { position: relative; width: min(100%, 20rem); @@ -475,8 +520,11 @@ body.page-dashboard .content { } .dashboard-day__hero[data-day-slider] { - transform: translateX(var(--day-slider-offset, 0)); - transition: transform 180ms ease; + position: relative; + z-index: 1; + margin-bottom: 0; + transform: translate3d(var(--day-slider-offset, 0), 0, 0) scale(var(--day-slider-scale, 1)); + transition: transform 260ms cubic-bezier(0.2, 0.8, 0.2, 1); will-change: transform; } @@ -1028,6 +1076,15 @@ body.page-dashboard .content { background: linear-gradient(135deg, rgba(54, 147, 173, 0.92), rgba(38, 106, 135, 0.92)); } +.sleep-phase-bar__segment--total { + border-radius: 999px 0 0 999px; + background: linear-gradient(135deg, rgba(90, 188, 242, 0.9), rgba(44, 126, 190, 0.9)); +} + +.sleep-phase-bar__segment--total.is-after-phase { + border-radius: 0; +} + .sleep-phase-bar__segment--rest { min-width: 0; padding: 0; diff --git a/assets/js/app.js b/assets/js/app.js index ba71070..f18bf61 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -1223,7 +1223,7 @@ valueInput.placeholder = config.placeholder; valueInput.required = !!config.showValue; valueInput.value = config.showValue ? valueInput.value : ""; - valueInput.step = type === "sleep" ? "0.25" : "1"; + valueInput.step = type === "sleep" ? "0.01" : "1"; } if (unitInput) { unitInput.value = config.unit; @@ -1454,11 +1454,61 @@ let dragging = false; let didSwipe = false; let activePointerId = null; + let currentOffset = 0; + let targetOffset = 0; + let animationFrame = null; + const prefetchedDays = new Set(); + + const setSlideProgress = offset => { + const progress = Math.min(1, Math.abs(offset) / 120); + daySlider.style.setProperty("--day-slider-offset", `${offset.toFixed(1)}px`); + daySlider.style.setProperty("--day-slider-scale", (1 - (progress * 0.025)).toFixed(3)); + swipeContainer.style.setProperty("--day-prev-hint", offset > 0 ? progress.toFixed(3) : "0"); + swipeContainer.style.setProperty("--day-next-hint", offset < 0 ? progress.toFixed(3) : "0"); + }; + + const animateSlide = () => { + currentOffset += (targetOffset - currentOffset) * 0.34; + if (Math.abs(targetOffset - currentOffset) < 0.4) { + currentOffset = targetOffset; + } + + setSlideProgress(currentOffset); + + if (currentOffset !== targetOffset) { + animationFrame = window.requestAnimationFrame(animateSlide); + } else { + animationFrame = null; + } + }; + + const setTargetOffset = offset => { + targetOffset = offset; + if (animationFrame === null) { + animationFrame = window.requestAnimationFrame(animateSlide); + } + }; + + const preloadDay = date => { + if (!date || prefetchedDays.has(date)) { + return; + } + + prefetchedDays.add(date); + window.fetch(dashboardDayPath(date), { + credentials: "same-origin", + cache: "force-cache", + priority: "low" + }).catch(() => {}); + }; + + preloadDay(swipeContainer.dataset.prevDate); + preloadDay(swipeContainer.dataset.nextDate); const resetStrip = () => { dayStrip.classList.remove("is-dragging"); daySlider.classList.remove("is-dragging"); - daySlider.style.setProperty("--day-slider-offset", "0px"); + setTargetOffset(0); }; const handleSwipe = (deltaX, deltaY) => { @@ -1469,18 +1519,18 @@ if (deltaX < 0 && swipeContainer.dataset.nextDate) { didSwipe = true; - daySlider.style.setProperty("--day-slider-offset", "-120%"); + setSlideProgress(-window.innerWidth); window.location.href = dashboardDayPath(swipeContainer.dataset.nextDate); } else if (deltaX > 0 && swipeContainer.dataset.prevDate) { didSwipe = true; - daySlider.style.setProperty("--day-slider-offset", "120%"); + setSlideProgress(window.innerWidth); window.location.href = dashboardDayPath(swipeContainer.dataset.prevDate); } else { resetStrip(); } }; - dayStrip.addEventListener("pointerdown", event => { + daySlider.addEventListener("pointerdown", event => { if (event.target.closest("input, textarea, select, label, [data-stepper], .dashboard-overlay")) { dragging = false; return; @@ -1493,10 +1543,10 @@ pointerStartY = event.clientY; dayStrip.classList.add("is-dragging"); daySlider.classList.add("is-dragging"); - dayStrip.setPointerCapture?.(event.pointerId); + daySlider.setPointerCapture?.(event.pointerId); }); - dayStrip.addEventListener("pointermove", event => { + daySlider.addEventListener("pointermove", event => { if (!dragging || (activePointerId !== null && event.pointerId !== activePointerId)) { return; } @@ -1510,10 +1560,17 @@ return; } - daySlider.style.setProperty("--day-slider-offset", `${Math.max(-120, Math.min(120, deltaX))}px`); + const dampedOffset = Math.sign(deltaX) * Math.min(148, Math.pow(Math.abs(deltaX), 0.88) * 1.6); + setTargetOffset(dampedOffset); + + if (deltaX < -32) { + preloadDay(swipeContainer.dataset.nextDate); + } else if (deltaX > 32) { + preloadDay(swipeContainer.dataset.prevDate); + } }); - dayStrip.addEventListener("pointerup", event => { + daySlider.addEventListener("pointerup", event => { if (!dragging) { return; } @@ -1523,13 +1580,13 @@ handleSwipe(event.clientX - pointerStartX, event.clientY - pointerStartY); }); - dayStrip.addEventListener("pointercancel", () => { + daySlider.addEventListener("pointercancel", () => { dragging = false; activePointerId = null; resetStrip(); }); - dayStrip.addEventListener("lostpointercapture", () => { + daySlider.addEventListener("lostpointercapture", () => { if (!dragging) { return; } @@ -1539,7 +1596,7 @@ resetStrip(); }); - dayStrip.addEventListener("click", event => { + daySlider.addEventListener("click", event => { if (!didSwipe) { return; } diff --git a/src/App.php b/src/App.php index 1cd56b8..cc1af5a 100644 --- a/src/App.php +++ b/src/App.php @@ -739,7 +739,9 @@ final class App if ($name === 'sleep_analysis') { foreach ($data as $point) { - $date = $this->healthPointDate($point['date'] ?? ($point['sleepEnd'] ?? ($point['endDate'] ?? null))); + $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; } @@ -770,8 +772,6 @@ final class App } } - $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; } @@ -1816,7 +1816,7 @@ final class App $comment = trim((string) ($input['event_comment'] ?? '')); - $value = max(0, min(50000, (float) ($input['event_value'] ?? 0))); + $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.'); } @@ -1847,6 +1847,13 @@ final class App ]; } + 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'); diff --git a/src/helpers.php b/src/helpers.php index 4b171db..a03f6c9 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -115,6 +115,23 @@ function format_points(float $value): string return number_format($rounded, 1, ',', '.'); } +function format_duration_hours(float $hours): string +{ + $minutes = max(0, (int) round($hours * 60)); + $wholeHours = intdiv($minutes, 60); + $remainingMinutes = $minutes % 60; + + if ($wholeHours <= 0) { + return $remainingMinutes . ' min'; + } + + if ($remainingMinutes === 0) { + return $wholeHours . ' h'; + } + + return $wholeHours . ' h ' . $remainingMinutes . ' min'; +} + function normalize_username(string $username): string { return strtolower(trim($username)); diff --git a/templates/pages/dashboard.php b/templates/pages/dashboard.php index 3014fc1..8ee2dd3 100644 --- a/templates/pages/dashboard.php +++ b/templates/pages/dashboard.php @@ -55,20 +55,24 @@ $formatBalanceValue = static function (?array $entry) use ($settings): string {
-
-

-

+
+ Vorherigen Tag laden + Nächster Tag laden +
+

+

- + +
@@ -573,7 +588,7 @@ $formatBalanceValue = static function (?array $entry) use ($settings): string { $eventTone = signal_value_class($eventScore); $sportType = $eventType === 'sport' ? find_sport_type($settings, (string) ($event['sport_type_id'] ?? '')) : null; $eventValue = (float) ($event['value'] ?? 0); - $eventValueText = $eventValue > 0 ? rtrim(rtrim(number_format($eventValue, 2, ',', '.'), '0'), ',') . ' ' . (string) ($event['unit'] ?? '') : ''; + $eventValueText = $eventValue > 0 ? ($eventType === 'sleep' ? format_duration_hours($eventValue) : rtrim(rtrim(number_format($eventValue, 2, ',', '.'), '0'), ',') . ' ' . (string) ($event['unit'] ?? '')) : ''; $eventTitle = trim((string) ($event['comment'] ?? '')) !== '' ? trim((string) $event['comment']) : day_event_type_label($eventType); $eventDetail = $eventValueText; @@ -581,7 +596,7 @@ $formatBalanceValue = static function (?array $entry) use ($settings): string { $eventTitle = (string) ($sportType['label'] ?? 'Sport'); } - if ($eventType === 'sleep') { + if ($eventType === 'sleep' && trim((string) ($event['comment'] ?? '')) === '') { $eventTitle = 'Schlaf'; } ?>