Refine Health import event presentation

This commit is contained in:
2026-05-19 15:54:50 +02:00
parent 59c7d89e81
commit 3e497a8047
6 changed files with 392 additions and 47 deletions
+156 -20
View File
@@ -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();