Improve day swipe and sleep handling

This commit is contained in:
2026-05-21 13:00:10 +02:00
parent 2047cae61c
commit a087eb508b
5 changed files with 206 additions and 53 deletions
+59 -2
View File
@@ -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;
+69 -12
View File
@@ -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;
}
+11 -4
View File
@@ -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');
+17
View File
@@ -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));
+30 -15
View File
@@ -55,6 +55,9 @@ $formatBalanceValue = static function (?array $entry) use ($settings): string {
<?php if ($dashboardView === 'day'): ?>
<div class="dashboard-day" data-day-swipe data-prev-date="<?= e($dashboardPrevDate) ?>" data-next-date="<?= e($dashboardNextDate) ?>">
<div class="dashboard-day-slider" data-day-slider-shell>
<span class="day-slide-hint day-slide-hint--prev" data-day-slide-prev-hint>Vorherigen Tag laden</span>
<span class="day-slide-hint day-slide-hint--next" data-day-slide-next-hint>Nächster Tag laden</span>
<div class="dashboard-day__hero" data-day-slider>
<p class="dashboard-day__eyebrow"><?= e((string) $dayWeekday) ?></p>
<h1><?= e(format_display_date((string) $dayEntry['date'], false)) ?></h1>
@@ -70,6 +73,7 @@ $formatBalanceValue = static function (?array $entry) use ($settings): string {
<?php endforeach; ?>
</nav>
</div>
</div>
<button class="day-summary-card glass-panel<?= $dashboardHasContent ? ' is-filled' : '' ?>" type="button" data-summary-overlay-open>
<span class="day-summary-card__label">Tagesbilanz</span>
@@ -124,24 +128,26 @@ $formatBalanceValue = static function (?array $entry) use ($settings): string {
'walk', 'sleep' => trim($eventValueText),
default => trim($eventValueText . ($sportType !== null ? ' · ' . (string) ($sportType['label'] ?? '') : '')),
}; ?>
<?php $showEventComment = in_array($eventType, ['sport', 'walk', 'sleep'], true) && $eventComment !== '' && !$isImportedHealth; ?>
<?php $showEventComment = in_array($eventType, ['sport', 'walk', 'sleep'], true) && $eventComment !== ''; ?>
<?php
$sleepPhases = ['deep' => (float) ($item['sleep_deep'] ?? 0), 'rem' => (float) ($item['sleep_rem'] ?? 0), 'core' => (float) ($item['sleep_core'] ?? 0)];
$sleepPhaseSource = trim($eventComment . ' ' . (string) ($item['duration_label'] ?? '') . ' ' . (string) ($item['distance_label'] ?? '') . ' ' . (string) ($item['energy_label'] ?? '') . ' ' . (string) ($item['heart_rate_label'] ?? ''));
if ($eventType === 'sleep' && array_sum($sleepPhases) <= 0 && $sleepPhaseSource !== '') {
if (preg_match('/(?:Tief|Tiefschlaf)\s*:?[\s]+([0-9]+(?:[,.][0-9]+)?)/u', $sleepPhaseSource, $match) === 1) {
if (preg_match('/(?:Tief|Tiefschlaf)\s*:?\s*([0-9]+(?:[,.][0-9]+)?)/u', $sleepPhaseSource, $match) === 1) {
$sleepPhases['deep'] = (float) str_replace(',', '.', $match[1]);
}
if (preg_match('/REM(?:-Schlaf)?\s*:?[\s]+([0-9]+(?:[,.][0-9]+)?)/u', $sleepPhaseSource, $match) === 1) {
if (preg_match('/REM(?:-Schlaf)?\s*:?\s*([0-9]+(?:[,.][0-9]+)?)/u', $sleepPhaseSource, $match) === 1) {
$sleepPhases['rem'] = (float) str_replace(',', '.', $match[1]);
}
if (preg_match('/(?:Kern|Kernschlaf)\s*:?[\s]+([0-9]+(?:[,.][0-9]+)?)/u', $sleepPhaseSource, $match) === 1) {
if (preg_match('/(?:Kern|Kernschlaf)\s*:?\s*([0-9]+(?:[,.][0-9]+)?)/u', $sleepPhaseSource, $match) === 1) {
$sleepPhases['core'] = (float) str_replace(',', '.', $match[1]);
}
}
$sleepPhaseTotal = max(0.0, array_sum($sleepPhases));
$sleepBarTotal = $eventType === 'sleep' ? max((float) ($item['value'] ?? 0), $sleepPhaseTotal, $optimalSleepHours / 0.75) : 0.0;
$sleepPhaseRemainder = max(0.0, $sleepBarTotal - $sleepPhaseTotal);
$sleepActualTotal = $eventType === 'sleep' ? max((float) ($item['value'] ?? 0), $sleepPhaseTotal) : 0.0;
$sleepBarTotal = $eventType === 'sleep' ? max($sleepActualTotal, $optimalSleepHours / 0.75) : 0.0;
$sleepUnclassified = max(0.0, $sleepActualTotal - $sleepPhaseTotal);
$sleepPhaseRemainder = max(0.0, $sleepBarTotal - $sleepActualTotal);
$sleepOptimalPercent = $sleepBarTotal > 0 ? max(0, min(100, ($optimalSleepHours / $sleepBarTotal) * 100)) : 0;
$sleepPhaseLeft = 0.0;
?>
@@ -212,24 +218,33 @@ $formatBalanceValue = static function (?array $entry) use ($settings): string {
<p class="timeline-card__value"><?= e((string) $item['comment']) ?></p>
<?php endif; ?>
<?php if ($eventType === 'sleep' && $sleepPhaseTotal > 0): ?>
<?php if ($eventType === 'sleep' && $sleepActualTotal > 0): ?>
<div class="sleep-phase-bar" aria-label="Schlafphasen" style="--sleep-optimal-left: <?= e((string) $sleepOptimalPercent) ?>%">
<?php if ($sleepPhaseTotal > 0): ?>
<?php foreach (['deep' => ['Tief', 'deep'], 'rem' => ['REM', 'rem'], 'core' => ['Kern', 'core']] as $phase => [$label, $class]): ?>
<?php $phaseHours = max(0.0, (float) ($sleepPhases[$phase] ?? 0)); ?>
<?php if ($phaseHours <= 0) { continue; } ?>
<?php $phasePercent = $sleepBarTotal > 0 ? max(0.5, min(100, ($phaseHours / $sleepBarTotal) * 100)) : 0; ?>
<?php $phaseLeftPercent = $sleepBarTotal > 0 ? max(0, min(100, ($sleepPhaseLeft / $sleepBarTotal) * 100)) : 0; ?>
<span class="sleep-phase-bar__segment sleep-phase-bar__segment--<?= e($class) ?><?= $phasePercent < 13 ? ' is-compact' : '' ?>" style="--sleep-segment-left: <?= e((string) $phaseLeftPercent) ?>%; --sleep-segment-width: <?= e((string) $phasePercent) ?>%" title="<?= e($label) ?>: <?= e(format_points($phaseHours)) ?> h" data-tooltip="<?= e($label) ?>: <?= e(format_points($phaseHours)) ?> h">
<strong><?= e($label) ?></strong> <?= e(format_points($phaseHours)) ?> h
<span class="sleep-phase-bar__segment sleep-phase-bar__segment--<?= e($class) ?><?= $phasePercent < 13 ? ' is-compact' : '' ?>" style="--sleep-segment-left: <?= e((string) $phaseLeftPercent) ?>%; --sleep-segment-width: <?= e((string) $phasePercent) ?>%" title="<?= e($label) ?>: <?= e(format_duration_hours($phaseHours)) ?>" data-tooltip="<?= e($label) ?>: <?= e(format_duration_hours($phaseHours)) ?>">
<strong><?= e($label) ?></strong> <?= e(format_duration_hours($phaseHours)) ?>
</span>
<?php $sleepPhaseLeft += $phaseHours; ?>
<?php endforeach; ?>
<?php endif; ?>
<?php if ($sleepUnclassified > 0): ?>
<?php $unclassifiedPercent = $sleepBarTotal > 0 ? max(0.5, min(100, ($sleepUnclassified / $sleepBarTotal) * 100)) : 0; ?>
<?php $unclassifiedLeftPercent = $sleepBarTotal > 0 ? max(0, min(100, ($sleepPhaseTotal / $sleepBarTotal) * 100)) : 0; ?>
<span class="sleep-phase-bar__segment sleep-phase-bar__segment--total<?= $sleepPhaseTotal > 0 ? ' is-after-phase' : '' ?><?= $unclassifiedPercent < 13 ? ' is-compact' : '' ?>" style="--sleep-segment-left: <?= e((string) $unclassifiedLeftPercent) ?>%; --sleep-segment-width: <?= e((string) $unclassifiedPercent) ?>%" title="Schlaf: <?= e(format_duration_hours($sleepUnclassified)) ?>" data-tooltip="Schlaf: <?= e(format_duration_hours($sleepUnclassified)) ?>">
<strong>Schlaf</strong> <?= e(format_duration_hours($sleepUnclassified)) ?>
</span>
<?php endif; ?>
<?php if ($sleepPhaseRemainder > 0): ?>
<?php $remainderPercent = $sleepBarTotal > 0 ? max(0.5, min(100, ($sleepPhaseRemainder / $sleepBarTotal) * 100)) : 0; ?>
<?php $remainderLeftPercent = $sleepBarTotal > 0 ? max(0, min(100, ($sleepPhaseTotal / $sleepBarTotal) * 100)) : 0; ?>
<span class="sleep-phase-bar__segment sleep-phase-bar__segment--rest" style="--sleep-segment-left: <?= e((string) $remainderLeftPercent) ?>%; --sleep-segment-width: <?= e((string) $remainderPercent) ?>%" title="Bis Ziel-/Skalenende: <?= e(format_points($sleepPhaseRemainder)) ?> h" data-tooltip="Bis Ziel-/Skalenende: <?= e(format_points($sleepPhaseRemainder)) ?> h"></span>
<?php $remainderLeftPercent = $sleepBarTotal > 0 ? max(0, min(100, ($sleepActualTotal / $sleepBarTotal) * 100)) : 0; ?>
<span class="sleep-phase-bar__segment sleep-phase-bar__segment--rest" style="--sleep-segment-left: <?= e((string) $remainderLeftPercent) ?>%; --sleep-segment-width: <?= e((string) $remainderPercent) ?>%" title="Bis Ziel-/Skalenende: <?= e(format_duration_hours($sleepPhaseRemainder)) ?>" data-tooltip="Bis Ziel-/Skalenende: <?= e(format_duration_hours($sleepPhaseRemainder)) ?>"></span>
<?php endif; ?>
<span class="sleep-phase-bar__target"><span><?= e(format_points($optimalSleepHours)) ?> h</span></span>
<span class="sleep-phase-bar__target"><span><?= e(format_duration_hours($optimalSleepHours)) ?></span></span>
</div>
<?php endif; ?>
@@ -421,7 +436,7 @@ $formatBalanceValue = static function (?array $entry) use ($settings): string {
</label>
<label data-moment-value-field>
<span data-moment-value-label>Wert</span>
<input type="number" name="event_value" min="0" max="50000" step="0.25" placeholder="optional" data-moment-value-input>
<input type="number" name="event_value" min="0" max="50000" step="0.01" placeholder="optional" data-moment-value-input>
</label>
</div>
@@ -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';
}
?>