Refine Health import event presentation
This commit is contained in:
+111
-6
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
+156
-20
@@ -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();
|
||||
|
||||
@@ -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) ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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'] ?? []),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -82,11 +82,13 @@ $dayStepBonus = (float) ($dayEntry['evaluation']['components']['step_bonus'] ??
|
||||
<?php foreach ($dashboardTimeline as $item): ?>
|
||||
<?php $eventType = (string) ($item['type'] ?? 'event'); ?>
|
||||
<?php $eventComment = trim((string) ($item['comment'] ?? '')); ?>
|
||||
<?php $isImportedHealth = (string) ($item['source'] ?? '') === 'health_auto_export'; ?>
|
||||
<?php $sportType = $eventType === 'sport' ? find_sport_type($settings, (string) ($item['sport_type_id'] ?? '')) : null; ?>
|
||||
<?php $isImportedWalkSport = $isImportedHealth && $eventType === 'sport' && str_contains(strtolower((string) ($sportType['label'] ?? $eventComment)), 'spaziergang'); ?>
|
||||
<?php $eventTone = signal_value_class(normalize_signal_value($item['mood'] ?? 0)); ?>
|
||||
<?php $eventValueText = (float) $item['value'] > 0 ? rtrim(rtrim(number_format((float) $item['value'], 2, ',', '.'), '0'), ',') . ' ' . (string) $item['unit'] : ''; ?>
|
||||
<?php $eventTitle = match ($eventType) {
|
||||
'sport' => (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'] ?? '') : '')),
|
||||
}; ?>
|
||||
<?php $showEventComment = in_array($eventType, ['sport', 'walk', 'sleep'], true) && $eventComment !== ''; ?>
|
||||
<?php $showEventComment = in_array($eventType, ['sport', 'walk', 'sleep'], true) && $eventComment !== '' && !$isImportedHealth; ?>
|
||||
<?php
|
||||
$sleepPhases = ['deep' => (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));
|
||||
?>
|
||||
<?php $eventStats = array_values(array_filter([
|
||||
(string) ($item['duration_label'] ?? ''),
|
||||
$eventType !== 'sleep' ? (string) ($item['duration_label'] ?? '') : '',
|
||||
(string) ($item['distance_label'] ?? ''),
|
||||
(string) ($item['energy_label'] ?? ''),
|
||||
'',
|
||||
(string) ($item['heart_rate_label'] ?? ''),
|
||||
], static fn (string $value): bool => trim($value) !== '')); ?>
|
||||
], static function (string $value): bool {
|
||||
$value = trim($value);
|
||||
return $value !== '' && !preg_match('/^-\s*(Distanz|Energie|Puls|Route)-?Label:?$/u', $value);
|
||||
})); ?>
|
||||
<?php $eventPayload = encode_payload([
|
||||
'id' => (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),
|
||||
]); ?>
|
||||
<?php $hasEventImage = is_string($item['image_url'] ?? null); ?>
|
||||
<?php $routeMap = is_array($item['route_map'] ?? null) ? $item['route_map'] : null; ?>
|
||||
<article class="timeline-card timeline-card--event timeline-card--<?= e($eventTone) ?><?= $hasEventImage ? ' timeline-card--with-image' : '' ?> glass-panel" data-event-editable data-event-payload="<?= e($eventPayload) ?>">
|
||||
<?php if ($hasEventImage): ?>
|
||||
<img class="timeline-card__image" src="<?= e((string) $item['image_url']) ?>" alt="">
|
||||
<button class="timeline-media-button" type="button" data-lightbox-src="<?= e((string) $item['image_url']) ?>" data-lightbox-kind="image" aria-label="Bild vergrößern">
|
||||
<img class="timeline-card__image" src="<?= e((string) $item['image_url']) ?>" alt="">
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
<span class="timeline-card__time-chip"><?= e($item['time'] !== '' ? $item['time'] : '--:--') ?></span>
|
||||
<div class="timeline-card__meta">
|
||||
@@ -156,6 +181,18 @@ $dayStepBonus = (float) ($dayEntry['evaluation']['components']['step_bonus'] ??
|
||||
<p class="timeline-card__value"><?= e((string) $item['comment']) ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($eventType === 'sleep' && $sleepPhaseTotal > 0): ?>
|
||||
<div class="sleep-phase-bar" aria-label="Schlafphasen">
|
||||
<?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; } ?>
|
||||
<span class="sleep-phase-bar__segment sleep-phase-bar__segment--<?= e($class) ?>" style="flex-grow: <?= e((string) max(0.1, $phaseHours)) ?>">
|
||||
<strong><?= e($label) ?></strong> <?= e(format_points($phaseHours)) ?> h
|
||||
</span>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($eventStats !== []): ?>
|
||||
<div class="timeline-card__stats" aria-label="Importdetails">
|
||||
<?php foreach ($eventStats as $stat): ?>
|
||||
@@ -164,18 +201,6 @@ $dayStepBonus = (float) ($dayEntry['evaluation']['components']['step_bonus'] ??
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($routeMap !== null): ?>
|
||||
<div class="timeline-route-map" aria-label="Route auf OpenStreetMap">
|
||||
<svg viewBox="0 0 <?= e((string) $routeMap['width']) ?> <?= e((string) $routeMap['height']) ?>" aria-hidden="true">
|
||||
<?php foreach ($routeMap['tiles'] as $tile): ?>
|
||||
<image href="<?= e((string) $tile['url']) ?>" x="<?= e((string) $tile['left']) ?>" y="<?= e((string) $tile['top']) ?>" width="256" height="256"></image>
|
||||
<?php endforeach; ?>
|
||||
<polyline points="<?= e((string) $routeMap['line']) ?>"></polyline>
|
||||
</svg>
|
||||
<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener noreferrer">© OpenStreetMap</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="signal-row">
|
||||
<?php foreach (['mood' => 'Stimmung', 'energy' => 'Energie', 'stress' => 'Stress'] as $metric => $label): ?>
|
||||
<?php $value = normalize_signal_value($item[$metric] ?? 0); ?>
|
||||
@@ -189,6 +214,18 @@ $dayStepBonus = (float) ($dayEntry['evaluation']['components']['step_bonus'] ??
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($routeMap !== null): ?>
|
||||
<button class="timeline-route-map" type="button" data-lightbox-kind="html" aria-label="Route vergrößern">
|
||||
<svg viewBox="0 0 <?= e((string) $routeMap['width']) ?> <?= e((string) $routeMap['height']) ?>" aria-hidden="true">
|
||||
<?php foreach ($routeMap['tiles'] as $tile): ?>
|
||||
<image href="<?= e((string) $tile['url']) ?>" x="<?= e((string) $tile['left']) ?>" y="<?= e((string) $tile['top']) ?>" width="256" height="256"></image>
|
||||
<?php endforeach; ?>
|
||||
<polyline points="<?= e((string) $routeMap['line']) ?>"></polyline>
|
||||
</svg>
|
||||
<span class="timeline-route-map__credit">© OpenStreetMap</span>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" action="/" class="timeline-card__delete">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="form_name" value="delete_event">
|
||||
@@ -518,9 +555,10 @@ $dayStepBonus = (float) ($dayEntry['evaluation']['components']['step_bonus'] ??
|
||||
</div>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
</section>
|
||||
<?php else: ?>
|
||||
<section class="dashboard-range-view dashboard-range-view--month">
|
||||
<header class="dashboard-range-view__hero">
|
||||
@@ -602,4 +640,12 @@ $dayStepBonus = (float) ($dayEntry['evaluation']['components']['step_bonus'] ??
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="media-lightbox" data-media-lightbox hidden>
|
||||
<button class="media-lightbox__backdrop" type="button" data-media-lightbox-close aria-label="Ansicht schließen"></button>
|
||||
<div class="media-lightbox__panel" role="dialog" aria-modal="true" aria-label="Medienansicht">
|
||||
<button class="media-lightbox__close" type="button" data-media-lightbox-close aria-label="Ansicht schließen">×</button>
|
||||
<div class="media-lightbox__content" data-media-lightbox-content></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user