feat(dashboard): add immersive day range views

This commit is contained in:
2026-05-18 16:32:22 +02:00
parent e953d0fd42
commit 83b4686b6f
12 changed files with 3724 additions and 567 deletions
+573 -2
View File
@@ -87,7 +87,11 @@ final class App
redirect('/login');
case '/':
$this->showDashboard();
$method === 'POST' ? $this->handleDashboard() : $this->showDashboard();
return;
case '/day-image':
$this->serveDayImage();
return;
case '/track':
@@ -232,21 +236,584 @@ final class App
$settings = $this->hydrateSettings($this->settings->forUser($user['username']));
$entries = $this->entries->all($user['username']);
$evaluatedEntries = $this->evaluateEntriesWithContext($entries, $settings);
$evaluatedEntries = array_map(
fn (array $entry): array => $this->withDashboardImageState($user['username'], $entry),
$evaluatedEntries
);
$dashboardView = $this->normalizeDashboardView((string) ($_GET['view'] ?? 'day'));
$dashboardDate = (string) ($_GET['date'] ?? today());
if (!$this->isValidDate($dashboardDate)) {
$dashboardDate = today();
}
$entryMap = [];
foreach ($evaluatedEntries as $entry) {
$entryMap[(string) ($entry['date'] ?? '')] = $entry;
}
$selectedEntry = $entryMap[$dashboardDate] ?? $this->withDashboardImageState($user['username'], $this->buildEmptyDashboardEntry($dashboardDate, $settings, $entryMap[shift_date($dashboardDate, -1)] ?? null));
$summary = $this->buildDashboardSummary($evaluatedEntries);
$chartData = $this->buildDashboardCharts($evaluatedEntries);
View::render('dashboard', [
'pageTitle' => 'Dashboard',
'pageTitle' => 'Mood',
'page' => 'dashboard',
'pageBodyClass' => 'page-dashboard-immersive',
'authUser' => $user,
'settings' => $settings,
'summary' => $summary,
'entries' => array_reverse($evaluatedEntries),
'chartPayload' => encode_payload($chartData),
'dashboardView' => $dashboardView,
'dashboardDate' => $dashboardDate,
'dayEntry' => $selectedEntry,
'dashboardEventTypes' => day_event_type_options(),
'dashboardSignals' => signal_scale_options(),
'dashboardTimeline' => $this->buildDashboardTimeline($selectedEntry),
'dashboardCompareDays' => $this->buildDashboardCompareDays($dashboardDate, $entryMap, $settings),
'dashboardWeek' => $this->buildDashboardWeekView($dashboardDate, $entryMap),
'dashboardMonth' => $this->buildDashboardMonthView($dashboardDate, $entryMap),
'dashboardPrevDate' => shift_date($dashboardDate, -1),
'dashboardNextDate' => shift_date($dashboardDate, 1),
'dashboardSportTypes' => normalized_sport_types($settings),
'dashboardWalkMode' => (string) ($settings['walk']['mode'] ?? 'time'),
'dashboardHasContent' => $this->entryHasContent($selectedEntry, isset($entryMap[$dashboardDate])),
'dashboardVisualScore' => $this->dashboardVisualScore($selectedEntry, isset($entryMap[$dashboardDate])),
'dashboardLineLevel' => $this->dashboardLineLevel($selectedEntry, isset($entryMap[$dashboardDate])),
'dashboardLineTone' => $this->dashboardLineTone($selectedEntry, isset($entryMap[$dashboardDate])),
]);
}
private function handleDashboard(): void
{
$this->enforceCsrf();
$user = $this->requireUser();
$settings = $this->hydrateSettings($this->settings->forUser($user['username']));
$form = (string) ($_POST['form_name'] ?? '');
$date = (string) ($_POST['date'] ?? today());
if (!$this->isValidDate($date)) {
flash('error', 'Bitte wähle einen gültigen Tag.');
redirect('/');
}
$entries = $this->entries->all($user['username']);
$entryMap = [];
foreach ($entries as $existingEntry) {
$entryMap[(string) ($existingEntry['date'] ?? '')] = $existingEntry;
}
$current = $entryMap[$date] ?? $this->buildEmptyDashboardEntry($date, $settings, $entryMap[shift_date($date, -1)] ?? null);
try {
if ($form === 'save_day_summary') {
$current['summary'] = [
'comment' => trim((string) ($_POST['summary_comment'] ?? '')),
'mood' => normalize_signal_value($_POST['summary_mood'] ?? 0),
'energy' => normalize_signal_value($_POST['summary_energy'] ?? 0),
'stress' => normalize_signal_value($_POST['summary_stress'] ?? 0),
'alcohol' => isset($_POST['summary_alcohol']) && (string) $_POST['summary_alcohol'] === '1',
];
$current['summary_comment'] = $current['summary']['comment'];
$current['summary_mood'] = $current['summary']['mood'];
$current['summary_energy'] = $current['summary']['energy'];
$current['summary_stress'] = $current['summary']['stress'];
$current['summary_alcohol'] = !empty($current['summary']['alcohol']);
$current['note'] = $current['summary']['comment'];
$current['alcohol'] = !empty($current['summary']['alcohol']);
$upload = uploaded_files('background_image')[0] ?? null;
if (is_array($upload) && (int) ($upload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) {
$this->deleteDashboardBackgroundImage($user['username'], $date, (string) ($current['background_image'] ?? ''));
$current['background_image'] = $this->storeDashboardBackgroundImage($user['username'], $date, $upload);
}
$entryMap[$date] = $current;
$this->persistUserEntries($user['username'], $settings, array_values($entryMap));
flash('success', 'Die Tagesbilanz wurde gespeichert.');
redirect('/?view=day&date=' . rawurlencode($date));
}
if ($form === 'add_event') {
$event = $this->dashboardEventFromPost($_POST);
$events = is_array($current['events'] ?? null) ? $current['events'] : [];
$events[] = $event;
$current['events'] = $events;
$entryMap[$date] = $current;
$this->persistUserEntries($user['username'], $settings, array_values($entryMap));
flash('success', 'Die Aktivität wurde hinzugefügt.');
redirect('/?view=day&date=' . rawurlencode($date));
}
if ($form === 'update_event') {
$eventID = trim((string) ($_POST['event_id'] ?? ''));
$updatedEvent = $this->dashboardEventFromPost($_POST);
$updatedEvent['id'] = $eventID !== '' ? $eventID : $updatedEvent['id'];
$events = [];
foreach (is_array($current['events'] ?? null) ? $current['events'] : [] as $event) {
if (!is_array($event)) {
continue;
}
if ((string) ($event['id'] ?? '') === $eventID) {
$events[] = $updatedEvent;
continue;
}
$events[] = $event;
}
$current['events'] = $events;
$entryMap[$date] = $current;
$this->persistUserEntries($user['username'], $settings, array_values($entryMap));
flash('success', 'Der Moment wurde aktualisiert.');
redirect('/?view=day&date=' . rawurlencode($date));
}
if ($form === 'delete_event') {
$eventID = trim((string) ($_POST['event_id'] ?? ''));
$current['events'] = array_values(array_filter(
is_array($current['events'] ?? null) ? $current['events'] : [],
static fn (array $event): bool => (string) ($event['id'] ?? '') !== $eventID
));
$entryMap[$date] = $current;
$this->persistUserEntries($user['username'], $settings, array_values($entryMap));
flash('success', 'Die Aktivität wurde entfernt.');
redirect('/?view=day&date=' . rawurlencode($date));
}
if ($form === 'remove_background') {
$this->deleteDashboardBackgroundImage($user['username'], $date, (string) ($current['background_image'] ?? ''));
$current['background_image'] = '';
$entryMap[$date] = $current;
$this->persistUserEntries($user['username'], $settings, array_values($entryMap));
flash('success', 'Das Tagesbild wurde entfernt.');
redirect('/?view=day&date=' . rawurlencode($date));
}
} catch (RuntimeException $exception) {
flash('error', $exception->getMessage());
redirect('/?view=day&date=' . rawurlencode($date));
}
redirect('/?view=day&date=' . rawurlencode($date));
}
private function normalizeDashboardView(string $view): string
{
return in_array($view, ['day', 'week', 'month'], true) ? $view : 'day';
}
private function buildEmptyDashboardEntry(string $date, array $settings, ?array $previousEntry = null): array
{
$entry = $this->scoring->normalize([
'date' => $date,
'pain_enabled' => !empty($settings['tracking']['pain_enabled']),
'walk_mode' => (string) ($settings['walk']['mode'] ?? 'time'),
'summary' => [
'comment' => '',
'mood' => 0,
'energy' => 0,
'stress' => 0,
'alcohol' => false,
],
'events' => [],
'background_image' => '',
]);
return array_merge($entry, [
'evaluation' => $this->scoring->evaluate($entry, $settings, $previousEntry),
'sport_type_meta' => [],
]);
}
private function buildDashboardTimeline(array $entry): array
{
$timeline = [];
foreach (is_array($entry['events'] ?? null) ? $entry['events'] : [] as $event) {
if (!is_array($event)) {
continue;
}
$timeline[] = [
'kind' => 'event',
'id' => (string) ($event['id'] ?? ''),
'type' => (string) ($event['type'] ?? 'event'),
'time' => (string) ($event['time'] ?? ''),
'comment' => (string) ($event['comment'] ?? ''),
'value' => (float) ($event['value'] ?? 0),
'unit' => (string) ($event['unit'] ?? ''),
'sport_type_id' => (string) ($event['sport_type_id'] ?? ''),
'consumed' => !empty($event['consumed']),
'mood' => normalize_signal_value($event['mood'] ?? 0),
'energy' => normalize_signal_value($event['energy'] ?? 0),
'stress' => normalize_signal_value($event['stress'] ?? 0),
];
}
return $timeline;
}
private function buildDashboardCompareDays(string $date, array $entryMap, array $settings): array
{
$days = [];
for ($offset = -3; $offset <= 1; $offset++) {
$dayDate = shift_date($date, $offset);
$entry = $entryMap[$dayDate] ?? $this->buildEmptyDashboardEntry($dayDate, $settings, $entryMap[shift_date($dayDate, -1)] ?? null);
$isPersisted = isset($entryMap[$dayDate]);
$hasContent = $isPersisted || $this->entryHasContent($entry);
$visualScore = $this->dashboardVisualScore($entry, $isPersisted);
$days[] = [
'date' => $dayDate,
'short' => (new DateTimeImmutable($dayDate))->format('D'),
'day' => format_compact_date($dayDate),
'offset' => $offset,
'is_current' => $dayDate === $date,
'has_content' => $hasContent,
'visual_score' => $visualScore,
'score_level' => $visualScore,
'line_level' => $this->dashboardLineLevel($entry, $isPersisted),
'line_tone' => $visualScore === null ? 'empty' : signal_value_class($visualScore),
];
}
return $days;
}
private function buildDashboardWeekView(string $date, array $entryMap): array
{
$current = new DateTimeImmutable($date);
$selectedStart = $current->modify('monday this week');
$selectedKey = $selectedStart->format('Y-m-d');
$currentStart = (new DateTimeImmutable(today()))->modify('monday this week');
$currentKey = $currentStart->format('Y-m-d');
$weekKeys = [$currentKey => true, $selectedKey => true];
foreach (array_keys($entryMap) as $entryDate) {
if (!$this->isValidDate((string) $entryDate)) {
continue;
}
$weekKeys[(new DateTimeImmutable((string) $entryDate))->modify('monday this week')->format('Y-m-d')] = true;
}
unset($weekKeys[$currentKey]);
$otherWeekKeys = array_keys($weekKeys);
rsort($otherWeekKeys, SORT_STRING);
$orderedWeekKeys = array_merge([$currentKey], $otherWeekKeys);
$periods = [];
foreach ($orderedWeekKeys as $weekKey) {
$periods[] = $this->buildDashboardWeekPeriod(new DateTimeImmutable((string) $weekKey), $date, $entryMap, $weekKey === $selectedKey);
}
$selectedPeriod = $this->buildDashboardWeekPeriod($selectedStart, $date, $entryMap, true);
return array_merge($selectedPeriod, [
'periods' => $periods,
]);
}
private function buildDashboardWeekPeriod(DateTimeImmutable $start, string $selectedDate, array $entryMap, bool $isSelected): array
{
$days = [];
for ($index = 0; $index < 7; $index++) {
$day = $start->modify('+' . $index . ' day');
$iso = $day->format('Y-m-d');
$entry = $entryMap[$iso] ?? null;
$hasContent = $entry !== null && $this->entryHasContent($entry);
$visualScore = $entry !== null ? $this->dashboardVisualScore($entry, true) : null;
$days[] = [
'date' => $iso,
'weekday' => format_display_date($iso, true),
'short' => $day->format('D'),
'day' => $day->format('j'),
'entry' => $entry,
'has_content' => $hasContent,
'score_level' => $visualScore,
'line_tone' => $visualScore === null ? 'empty' : signal_value_class($visualScore),
'is_current' => $iso === $selectedDate,
];
}
return [
'key' => $start->format('Y-m-d'),
'title' => 'Woche ' . $start->format('W'),
'range' => $start->format('j.n.Y') . ' - ' . $start->modify('+6 day')->format('j.n.Y'),
'is_selected' => $isSelected,
'days' => $days,
];
}
private function buildDashboardMonthView(string $date, array $entryMap): array
{
$current = new DateTimeImmutable($date);
$selectedStart = $current->modify('first day of this month');
$selectedKey = $selectedStart->format('Y-m-d');
$currentStart = (new DateTimeImmutable(today()))->modify('first day of this month');
$currentKey = $currentStart->format('Y-m-d');
$monthKeys = [$currentKey => true, $selectedKey => true];
foreach (array_keys($entryMap) as $entryDate) {
if (!$this->isValidDate((string) $entryDate)) {
continue;
}
$monthKeys[(new DateTimeImmutable((string) $entryDate))->modify('first day of this month')->format('Y-m-d')] = true;
}
unset($monthKeys[$currentKey]);
$otherMonthKeys = array_keys($monthKeys);
rsort($otherMonthKeys, SORT_STRING);
$orderedMonthKeys = array_merge([$currentKey], $otherMonthKeys);
$periods = [];
foreach ($orderedMonthKeys as $monthKey) {
$periods[] = $this->buildDashboardMonthPeriod(new DateTimeImmutable((string) $monthKey), $date, $entryMap, $monthKey === $selectedKey);
}
$selectedPeriod = $this->buildDashboardMonthPeriod($selectedStart, $date, $entryMap, true);
return array_merge($selectedPeriod, [
'periods' => $periods,
]);
}
private function buildDashboardMonthPeriod(DateTimeImmutable $start, string $selectedDate, array $entryMap, bool $isSelected): array
{
$end = $start->modify('last day of this month');
$days = [];
for ($day = $start; $day <= $end; $day = $day->modify('+1 day')) {
$iso = $day->format('Y-m-d');
$entry = $entryMap[$iso] ?? null;
$hasContent = $entry !== null && $this->entryHasContent($entry);
$visualScore = $entry !== null ? $this->dashboardVisualScore($entry, true) : null;
$days[] = [
'date' => $iso,
'day' => $day->format('j'),
'weekday' => format_display_date($iso, true),
'entry' => $entry,
'has_content' => $hasContent,
'score_level' => $visualScore,
'line_tone' => $visualScore === null ? 'empty' : signal_value_class($visualScore),
'is_future' => $iso > $selectedDate,
];
}
return [
'key' => $start->format('Y-m-d'),
'title' => month_label($start->format('Y-m')),
'is_selected' => $isSelected,
'days' => $days,
];
}
private function entryHasContent(array $entry, bool $isPersisted = false): bool
{
if ($isPersisted) {
return true;
}
if (trim((string) ($entry['summary']['comment'] ?? '')) !== '') {
return true;
}
if (!empty($entry['summary']['alcohol'] ?? $entry['summary_alcohol'] ?? false)) {
return true;
}
if (trim((string) ($entry['background_image'] ?? '')) !== '') {
return true;
}
return count(array_filter(is_array($entry['events'] ?? null) ? $entry['events'] : [], 'is_array')) > 0;
}
private function dashboardVisualScore(array $entry, bool $isPersisted = false): ?int
{
if (!$this->entryHasContent($entry, $isPersisted)) {
return null;
}
$summary = is_array($entry['summary'] ?? null) ? $entry['summary'] : [];
$mood = normalize_signal_value($summary['mood'] ?? $entry['summary_mood'] ?? legacy_to_signal_scale($entry['mood'] ?? 5));
$energy = normalize_signal_value($summary['energy'] ?? $entry['summary_energy'] ?? legacy_to_signal_scale($entry['energy'] ?? 5));
$stress = normalize_signal_value($summary['stress'] ?? $entry['summary_stress'] ?? legacy_to_signal_scale($entry['stress'] ?? 5));
return signal_combo_score($mood, $energy, $stress);
}
private function dashboardLineLevel(array $entry, bool $isPersisted = false): ?int
{
if (!$this->entryHasContent($entry, $isPersisted)) {
return null;
}
$percentage = max(0.0, min(100.0, (float) ($entry['evaluation']['percentage'] ?? 0)));
return (int) round($percentage / 5);
}
private function dashboardLineTone(array $entry, bool $isPersisted = false): string
{
return signal_value_class($this->dashboardVisualScore($entry, $isPersisted) ?? 0);
}
private function dashboardEventFromPost(array $input): array
{
$type = trim((string) ($input['event_type'] ?? 'event'));
if (!array_key_exists($type, day_event_type_options())) {
$type = 'event';
}
$time = trim((string) ($input['event_time'] ?? ''));
if (!$this->isValidTime($time)) {
$time = date('H:i');
}
$comment = trim((string) ($input['event_comment'] ?? ''));
$value = max(0, min(50000, (float) ($input['event_value'] ?? 0)));
if (in_array($type, ['walk', 'sport', 'sleep'], true) && $value <= 0) {
throw new RuntimeException('Für diese Aktivität braucht es einen Wert oder eine Dauer.');
}
$sportTypeID = trim((string) ($input['event_sport_type_id'] ?? ''));
if ($type === 'sport' && $sportTypeID === '') {
throw new RuntimeException('Bitte wähle eine Sportart.');
}
$unit = trim((string) ($input['event_unit'] ?? day_event_type_unit($type)));
if ($type === 'walk') {
$walkMode = trim((string) ($input['event_walk_mode'] ?? 'time'));
$unit = $walkMode === 'steps' ? 'steps' : 'min';
}
return [
'id' => 'evt-' . substr(sha1((string) microtime(true) . $type . $time . (string) ($input['event_comment'] ?? '')), 0, 12),
'type' => $type,
'time' => $time,
'comment' => $comment,
'value' => $value,
'unit' => $unit,
'sport_type_id' => $sportTypeID,
'consumed' => isset($input['event_consumed']) ? ((string) $input['event_consumed'] === '1') : true,
'mood' => normalize_signal_value($input['event_mood'] ?? 0),
'energy' => normalize_signal_value($input['event_energy'] ?? 0),
'stress' => normalize_signal_value($input['event_stress'] ?? 0),
];
}
private function dashboardMediaDirectory(string $username): string
{
return storage_path('users/' . normalize_username($username) . '/media');
}
private function withDashboardImageState(string $username, array $entry): array
{
$fileName = trim((string) ($entry['background_image'] ?? ''));
$date = (string) ($entry['date'] ?? '');
$entry['background_image_url'] = null;
if ($fileName === '' || !$this->isValidDate($date)) {
return $entry;
}
$path = $this->dashboardMediaDirectory($username) . '/' . basename($fileName);
if (is_file($path)) {
$entry['background_image_url'] = '/day-image?date=' . rawurlencode($date);
}
return $entry;
}
private function storeDashboardBackgroundImage(string $username, string $date, array $upload): string
{
$error = (int) ($upload['error'] ?? UPLOAD_ERR_NO_FILE);
if ($error !== UPLOAD_ERR_OK) {
throw new RuntimeException('Das Tagesbild konnte nicht hochgeladen werden.');
}
$tmpName = (string) ($upload['tmp_name'] ?? '');
if ($tmpName === '' || !is_uploaded_file($tmpName)) {
throw new RuntimeException('Die hochgeladene Bilddatei ist ungültig.');
}
$mime = mime_content_type($tmpName) ?: '';
$extension = match ($mime) {
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/webp' => 'webp',
default => '',
};
if ($extension === '') {
throw new RuntimeException('Bitte nutze JPG, PNG oder WebP als Tagesbild.');
}
$directory = $this->dashboardMediaDirectory($username);
if (!is_dir($directory)) {
mkdir($directory, 0775, true);
}
$fileName = $date . '-' . substr(sha1((string) microtime(true) . $username), 0, 10) . '.' . $extension;
$target = $directory . '/' . $fileName;
if (!move_uploaded_file($tmpName, $target)) {
throw new RuntimeException('Das Tagesbild konnte nicht gespeichert werden.');
}
return $fileName;
}
private function deleteDashboardBackgroundImage(string $username, string $date, string $fileName): void
{
$path = $this->dashboardMediaDirectory($username) . '/' . basename($fileName);
if (is_file($path)) {
@unlink($path);
}
}
private function serveDayImage(): void
{
$user = $this->requireUser();
$date = (string) ($_GET['date'] ?? '');
if (!$this->isValidDate($date)) {
http_response_code(404);
exit('Nicht gefunden');
}
$entry = $this->entries->find($user['username'], $date);
$fileName = trim((string) ($entry['background_image'] ?? ''));
if ($fileName === '') {
http_response_code(404);
exit('Nicht gefunden');
}
$path = $this->dashboardMediaDirectory($user['username']) . '/' . basename($fileName);
if (!is_file($path)) {
http_response_code(404);
exit('Nicht gefunden');
}
$mime = mime_content_type($path) ?: 'application/octet-stream';
header('Content-Type: ' . $mime);
header('Content-Length: ' . (string) filesize($path));
header('Cache-Control: private, max-age=3600');
readfile($path);
exit;
}
private function showTrack(): void
{
$user = $this->requireUser();
@@ -507,6 +1074,7 @@ final class App
{
$user = $this->requireUser();
$settings = $this->hydrateSettings($this->settings->forUser($user['username']));
$evaluatedEntries = $this->evaluateEntriesWithContext($this->entries->all($user['username']), $settings);
$sportTypePresets = array_values(array_filter(
Defaults::settings()['sport_types'],
static function (array $preset) use ($settings): bool {
@@ -534,6 +1102,7 @@ final class App
'pageTitle' => 'Optionen',
'page' => 'options',
'authUser' => $user,
'optionsOpenPanel' => trim((string) ($_GET['panel'] ?? '')),
'settings' => $settings,
'sportTypePresets' => $sportTypePresets,
'sportLocationOptions' => sport_location_options(),
@@ -545,6 +1114,8 @@ final class App
'aiConfig' => $user['is_admin'] ? $this->aiConfig->get() : null,
'aiStatus' => $user['is_admin'] ? $this->openAi->configuration() : null,
'users' => $user['is_admin'] ? $this->users->all() : [],
'statsSummary' => $this->buildDashboardSummary($evaluatedEntries),
'statsChartPayload' => encode_payload($this->buildDashboardCharts($evaluatedEntries)),
'maxScore' => $this->scoring->evaluate([
'mood' => 10,
'energy' => 10,