feat(dashboard): add immersive day range views
This commit is contained in:
+573
-2
@@ -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,
|
||||
|
||||
@@ -96,6 +96,10 @@ final class EntryRepository
|
||||
|
||||
private function parse(string $content, string $fallbackDate): ?array
|
||||
{
|
||||
if (str_contains($content, '<!-- mood-tracker:v3 -->')) {
|
||||
return $this->parseV3($content, $fallbackDate);
|
||||
}
|
||||
|
||||
$sportTypes = [];
|
||||
$sportTypesRaw = (string) ($this->extract('/^- Sportarten:\s*(.*)$/mu', $content) ?? '');
|
||||
if ($sportTypesRaw !== '') {
|
||||
@@ -134,6 +138,19 @@ final class EntryRepository
|
||||
'walk_steps' => $walkMode === 'steps' ? $walkValue : 0,
|
||||
'alcohol' => in_array($alcoholRaw, ['ja', 'yes', 'true', '1'], true),
|
||||
'note' => $this->extractNote($content),
|
||||
'summary' => [
|
||||
'comment' => $this->extractNote($content),
|
||||
'mood' => legacy_to_signal_scale((int) ($this->extract('/^- Stimmung:\s*(.+)$/m', $content) ?? 5)),
|
||||
'energy' => legacy_to_signal_scale((int) ($this->extract('/^- Energie:\s*(.+)$/m', $content) ?? 5)),
|
||||
'stress' => legacy_to_signal_scale((int) ($this->extract('/^- Stress:\s*(.+)$/m', $content) ?? 5)),
|
||||
'alcohol' => in_array($alcoholRaw, ['ja', 'yes', 'true', '1'], true),
|
||||
],
|
||||
'summary_comment' => $this->extractNote($content),
|
||||
'summary_mood' => legacy_to_signal_scale((int) ($this->extract('/^- Stimmung:\s*(.+)$/m', $content) ?? 5)),
|
||||
'summary_energy' => legacy_to_signal_scale((int) ($this->extract('/^- Energie:\s*(.+)$/m', $content) ?? 5)),
|
||||
'summary_stress' => legacy_to_signal_scale((int) ($this->extract('/^- Stress:\s*(.+)$/m', $content) ?? 5)),
|
||||
'background_image' => '',
|
||||
'events' => [],
|
||||
];
|
||||
|
||||
return $entry;
|
||||
@@ -163,18 +180,56 @@ final class EntryRepository
|
||||
|
||||
private function toMarkdown(string $username, string $date, array $entry, array $evaluation): string
|
||||
{
|
||||
$summary = is_array($entry['summary'] ?? null) ? $entry['summary'] : [
|
||||
'comment' => (string) ($entry['summary_comment'] ?? $entry['note'] ?? ''),
|
||||
'mood' => normalize_signal_value($entry['summary_mood'] ?? legacy_to_signal_scale($entry['mood'] ?? 5)),
|
||||
'energy' => normalize_signal_value($entry['summary_energy'] ?? legacy_to_signal_scale($entry['energy'] ?? 5)),
|
||||
'stress' => normalize_signal_value($entry['summary_stress'] ?? legacy_to_signal_scale($entry['stress'] ?? 5)),
|
||||
];
|
||||
$events = is_array($entry['events'] ?? null) ? $entry['events'] : [];
|
||||
$sportTypes = $evaluation['sport_types'] ?? [];
|
||||
$sportTypeValues = array_map(
|
||||
static fn (array $type): string => (string) $type['label'] . ' [' . (string) $type['id'] . ']',
|
||||
array_filter($sportTypes, 'is_array')
|
||||
);
|
||||
|
||||
$eventLines = [];
|
||||
foreach ($events as $event) {
|
||||
if (!is_array($event)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$eventLines[] = '### ' . ((string) ($event['id'] ?? 'evt'));
|
||||
$eventLines[] = '- Typ: ' . day_event_type_label((string) ($event['type'] ?? 'event')) . ' [' . (string) ($event['type'] ?? 'event') . ']';
|
||||
$eventLines[] = '- Uhrzeit: ' . (string) ($event['time'] ?? '');
|
||||
$eventLines[] = '- Wert: ' . (string) ($event['value'] ?? 0);
|
||||
$eventLines[] = '- Einheit: ' . (string) ($event['unit'] ?? '');
|
||||
$eventLines[] = '- Sportart: ' . (string) ($event['sport_type_id'] ?? '');
|
||||
$eventLines[] = '- Getrunken: ' . (!empty($event['consumed']) ? 'ja' : 'nein');
|
||||
$eventLines[] = '- Kommentar: ' . (string) ($event['comment'] ?? '');
|
||||
$eventLines[] = '- Stimmung: ' . (string) ($event['mood'] ?? 0);
|
||||
$eventLines[] = '- Energie: ' . (string) ($event['energy'] ?? 0);
|
||||
$eventLines[] = '- Stress: ' . (string) ($event['stress'] ?? 0);
|
||||
$eventLines[] = '';
|
||||
}
|
||||
|
||||
$lines = [
|
||||
'<!-- mood-tracker:v2 -->',
|
||||
'# Stimmungstracker',
|
||||
'<!-- mood-tracker:v3 -->',
|
||||
'# Stimmungstracker Tag',
|
||||
'Datum: ' . $date,
|
||||
'Benutzer: ' . normalize_username($username),
|
||||
'Hintergrundbild: ' . trim((string) ($entry['background_image'] ?? '')),
|
||||
'',
|
||||
'## Tagesbilanz',
|
||||
'- Kommentar: ' . trim((string) ($summary['comment'] ?? '')),
|
||||
'- Stimmung: ' . normalize_signal_value($summary['mood'] ?? 0),
|
||||
'- Energie: ' . normalize_signal_value($summary['energy'] ?? 0),
|
||||
'- Stress: ' . normalize_signal_value($summary['stress'] ?? 0),
|
||||
'- Alkohol: ' . (!empty($summary['alcohol']) ? 'ja' : 'nein'),
|
||||
'',
|
||||
'## Ereignisse',
|
||||
...($eventLines !== [] ? $eventLines : ['- Keine Ereignisse', '']),
|
||||
'## Tracking',
|
||||
'## Werte',
|
||||
'- Stimmung: ' . $entry['mood'],
|
||||
'- Energie: ' . $entry['energy'],
|
||||
@@ -202,14 +257,93 @@ final class EntryRepository
|
||||
'- Sport: ' . format_points((float) $evaluation['components']['sport_minutes']),
|
||||
'- Sportbonus: ' . format_points((float) ($evaluation['components']['sport_bonus'] ?? 0)),
|
||||
'- Spaziergang: ' . format_points((float) $evaluation['components']['walk_minutes']),
|
||||
'- Momente: ' . format_points((float) ($evaluation['components']['events'] ?? 0)),
|
||||
'- Alkohol: ' . format_points((float) ($evaluation['components']['alcohol'] ?? 0)),
|
||||
'- Notiz: ' . format_points((float) $evaluation['components']['note']),
|
||||
'',
|
||||
'## Notiz',
|
||||
trim((string) $entry['note']),
|
||||
trim((string) ($summary['comment'] ?? $entry['note'] ?? '')),
|
||||
'',
|
||||
];
|
||||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
private function parseV3(string $content, string $fallbackDate): ?array
|
||||
{
|
||||
$date = $this->extract('/^Datum:\s*(\d{4}-\d{2}-\d{2})$/m', $content) ?? $fallbackDate;
|
||||
$backgroundImage = trim((string) ($this->extract('/^Hintergrundbild:[ \t]*([^\r\n]*)$/mu', $content) ?? ''));
|
||||
if (preg_match('/^[A-Za-z0-9._-]+\.(?:jpe?g|png|webp)$/i', $backgroundImage) !== 1) {
|
||||
$backgroundImage = '';
|
||||
}
|
||||
$summarySection = $this->extractSection($content, '## Tagesbilanz', '## Ereignisse');
|
||||
$eventsSection = $this->extractSection($content, '## Ereignisse', '## Tracking');
|
||||
$legacySection = $this->extractSection($content, '## Werte', '## Bewertung');
|
||||
$legacyContent = $legacySection !== null ? "## Werte\n" . $legacySection : $content;
|
||||
|
||||
$base = $this->parse(str_replace('<!-- mood-tracker:v3 -->', '<!-- mood-tracker:v2 -->', $legacyContent), $fallbackDate);
|
||||
if ($base === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$summary = [
|
||||
'comment' => (string) ($this->extract('/^- Kommentar:\s*(.*)$/mu', $summarySection ?? '') ?? ''),
|
||||
'mood' => normalize_signal_value($this->extract('/^- Stimmung:\s*(-?\d+)$/m', $summarySection ?? '') ?? 0),
|
||||
'energy' => normalize_signal_value($this->extract('/^- Energie:\s*(-?\d+)$/m', $summarySection ?? '') ?? 0),
|
||||
'stress' => normalize_signal_value($this->extract('/^- Stress:\s*(-?\d+)$/m', $summarySection ?? '') ?? 0),
|
||||
'alcohol' => in_array(strtolower((string) ($this->extract('/^- Alkohol:\s*(.*)$/mu', $summarySection ?? '') ?? 'nein')), ['ja', 'yes', 'true', '1'], true),
|
||||
];
|
||||
|
||||
$events = [];
|
||||
foreach (preg_split('/^###\s+/m', trim((string) $eventsSection)) ?: [] as $chunk) {
|
||||
$chunk = trim($chunk);
|
||||
if ($chunk === '' || str_starts_with($chunk, '- Keine Ereignisse')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$lines = preg_split('/\R/', $chunk) ?: [];
|
||||
$id = trim((string) array_shift($lines));
|
||||
$block = implode("\n", $lines);
|
||||
$typeLine = (string) ($this->extract('/^- Typ:\s*.*\[([^\]]+)\]$/mu', $block) ?? 'event');
|
||||
|
||||
$events[] = [
|
||||
'id' => $id,
|
||||
'type' => $typeLine,
|
||||
'time' => (string) ($this->extract('/^- Uhrzeit:\s*(.*)$/mu', $block) ?? ''),
|
||||
'value' => (float) ($this->extract('/^- Wert:\s*(.*)$/mu', $block) ?? 0),
|
||||
'unit' => (string) ($this->extract('/^- Einheit:\s*(.*)$/mu', $block) ?? ''),
|
||||
'sport_type_id' => (string) ($this->extract('/^- Sportart:\s*(.*)$/mu', $block) ?? ''),
|
||||
'consumed' => in_array(strtolower((string) ($this->extract('/^- Getrunken:\s*(.*)$/mu', $block) ?? 'ja')), ['ja', 'yes', 'true', '1'], true),
|
||||
'comment' => (string) ($this->extract('/^- Kommentar:\s*(.*)$/mu', $block) ?? ''),
|
||||
'mood' => normalize_signal_value($this->extract('/^- Stimmung:\s*(-?\d+)$/m', $block) ?? 0),
|
||||
'energy' => normalize_signal_value($this->extract('/^- Energie:\s*(-?\d+)$/m', $block) ?? 0),
|
||||
'stress' => normalize_signal_value($this->extract('/^- Stress:\s*(-?\d+)$/m', $block) ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
$base['date'] = $date;
|
||||
$base['background_image'] = $backgroundImage;
|
||||
$base['summary'] = $summary;
|
||||
$base['summary_comment'] = $summary['comment'];
|
||||
$base['summary_mood'] = $summary['mood'];
|
||||
$base['summary_energy'] = $summary['energy'];
|
||||
$base['summary_stress'] = $summary['stress'];
|
||||
$base['summary_alcohol'] = !empty($summary['alcohol']);
|
||||
$base['events'] = $events;
|
||||
$base['alcohol'] = !empty($summary['alcohol']);
|
||||
$base['note'] = $summary['comment'];
|
||||
|
||||
return $base;
|
||||
}
|
||||
|
||||
private function extractSection(string $content, string $startHeading, string $endHeading): ?string
|
||||
{
|
||||
$pattern = '/' . preg_quote($startHeading, '/') . '\s*(.*?)\s*' . preg_quote($endHeading, '/') . '/su';
|
||||
|
||||
if (preg_match($pattern, $content, $matches) !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trim((string) ($matches[1] ?? ''));
|
||||
}
|
||||
}
|
||||
|
||||
+180
-12
@@ -6,25 +6,47 @@ final class ScoringService
|
||||
{
|
||||
public function normalize(array $input): array
|
||||
{
|
||||
$sportTypes = normalize_sport_type_selection($input['sport_types'] ?? ($input['sport_type'] ?? []));
|
||||
$hasSummaryInput = is_array($input['summary'] ?? null)
|
||||
|| array_key_exists('summary_mood', $input)
|
||||
|| array_key_exists('summary_energy', $input)
|
||||
|| array_key_exists('summary_stress', $input);
|
||||
$hasEventInput = is_array($input['events'] ?? null) && $input['events'] !== [];
|
||||
$summary = $this->normalizeSummary($input['summary'] ?? [
|
||||
'comment' => $input['summary_comment'] ?? ($input['note'] ?? ''),
|
||||
'mood' => $input['summary_mood'] ?? legacy_to_signal_scale($input['mood'] ?? 5),
|
||||
'energy' => $input['summary_energy'] ?? legacy_to_signal_scale($input['energy'] ?? 5),
|
||||
'stress' => $input['summary_stress'] ?? legacy_to_signal_scale($input['stress'] ?? 5),
|
||||
'alcohol' => !empty($input['summary_alcohol'] ?? $input['alcohol'] ?? false),
|
||||
]);
|
||||
$events = $this->normalizeEvents($input['events'] ?? []);
|
||||
$derived = $this->deriveLegacyFieldsFromEvents($events, $summary, $input);
|
||||
$sportTypes = normalize_sport_type_selection($hasEventInput ? ($derived['sport_types'] ?? []) : ($input['sport_types'] ?? ($derived['sport_types'] ?? ($input['sport_type'] ?? []))));
|
||||
|
||||
return [
|
||||
'date' => $input['date'] ?? today(),
|
||||
'mood' => max(1, min(10, (int) ($input['mood'] ?? 5))),
|
||||
'energy' => max(1, min(10, (int) ($input['energy'] ?? 5))),
|
||||
'stress' => max(1, min(10, (int) ($input['stress'] ?? 5))),
|
||||
'mood' => max(1, min(10, (int) ($hasSummaryInput ? $derived['mood'] : ($input['mood'] ?? $derived['mood'])))),
|
||||
'energy' => max(1, min(10, (int) ($hasSummaryInput ? $derived['energy'] : ($input['energy'] ?? $derived['energy'])))),
|
||||
'stress' => max(1, min(10, (int) ($hasSummaryInput ? $derived['stress'] : ($input['stress'] ?? $derived['stress'])))),
|
||||
'pain' => max(1, min(10, (int) ($input['pain'] ?? 1))),
|
||||
'pain_enabled' => $this->normalizeBoolean($input['pain_enabled'] ?? false),
|
||||
'sleep_hours' => max(0, min(24, (float) ($input['sleep_hours'] ?? 0))),
|
||||
'sleep_feeling' => max(1, min(5, (int) ($input['sleep_feeling'] ?? 3))),
|
||||
'sport_minutes' => max(0, min(1440, (int) ($input['sport_minutes'] ?? 0))),
|
||||
'sleep_hours' => max(0, min(24, (float) ($hasEventInput ? $derived['sleep_hours'] : ($input['sleep_hours'] ?? $derived['sleep_hours'])))),
|
||||
'sleep_feeling' => max(1, min(5, (int) ($hasSummaryInput ? $derived['sleep_feeling'] : ($input['sleep_feeling'] ?? $derived['sleep_feeling'])))),
|
||||
'sport_minutes' => max(0, min(1440, (int) ($hasEventInput ? $derived['sport_minutes'] : ($input['sport_minutes'] ?? $derived['sport_minutes'])))),
|
||||
'sport_type' => $sportTypes[0] ?? '',
|
||||
'sport_types' => $sportTypes,
|
||||
'walk_mode' => $this->normalizeWalkMode((string) ($input['walk_mode'] ?? ((isset($input['walk_steps']) && !isset($input['walk_minutes'])) ? 'steps' : 'time'))),
|
||||
'walk_minutes' => max(0, min(1440, (int) ($input['walk_minutes'] ?? 0))),
|
||||
'walk_steps' => max(0, min(50000, (int) ($input['walk_steps'] ?? 0))),
|
||||
'alcohol' => $this->normalizeBoolean($input['alcohol'] ?? false),
|
||||
'note' => trim((string) ($input['note'] ?? '')),
|
||||
'walk_mode' => $this->normalizeWalkMode((string) ($input['walk_mode'] ?? $derived['walk_mode'] ?? ((isset($input['walk_steps']) && !isset($input['walk_minutes'])) ? 'steps' : 'time'))),
|
||||
'walk_minutes' => max(0, min(1440, (int) ($hasEventInput ? $derived['walk_minutes'] : ($input['walk_minutes'] ?? $derived['walk_minutes'])))),
|
||||
'walk_steps' => max(0, min(50000, (int) ($hasEventInput ? $derived['walk_steps'] : ($input['walk_steps'] ?? $derived['walk_steps'])))),
|
||||
'alcohol' => $this->normalizeBoolean($hasSummaryInput ? $derived['alcohol'] : ($input['alcohol'] ?? $derived['alcohol'])),
|
||||
'note' => trim((string) ($input['note'] ?? $summary['comment'])),
|
||||
'summary' => $summary,
|
||||
'summary_comment' => $summary['comment'],
|
||||
'summary_mood' => $summary['mood'],
|
||||
'summary_energy' => $summary['energy'],
|
||||
'summary_stress' => $summary['stress'],
|
||||
'summary_alcohol' => !empty($summary['alcohol']),
|
||||
'background_image' => trim((string) ($input['background_image'] ?? '')),
|
||||
'events' => $events,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -36,6 +58,7 @@ final class ScoringService
|
||||
$ratings = $this->sortedRatings($settings['ratings'] ?? []);
|
||||
$sportTypes = find_sport_types($settings, $entry['sport_types']);
|
||||
$sportBonus = $this->sportBonusPoints($entry, $previousEntry, $settings, $sportTypes);
|
||||
$eventSignalPoints = $this->eventSignalPoints($entry['events']);
|
||||
$painEnabled = !empty($settings['tracking']['pain_enabled']);
|
||||
|
||||
$components = [
|
||||
@@ -47,6 +70,7 @@ final class ScoringService
|
||||
'sport_minutes' => $this->bandPoints((int) $entry['sport_minutes'], $scoring['sport_bands']),
|
||||
'sport_bonus' => $sportBonus,
|
||||
'walk_minutes' => $this->walkPoints($entry, $settings),
|
||||
'events' => $eventSignalPoints,
|
||||
'alcohol' => !empty($entry['alcohol']) ? ((float) abs((float) ($scoring['alcohol_penalty'] ?? 5)) * -1) : 0.0,
|
||||
'note' => $entry['note'] === '' ? 0 : (float) $scoring['journal_points'],
|
||||
];
|
||||
@@ -66,6 +90,7 @@ final class ScoringService
|
||||
$this->maxBandPoints($scoring['sport_bands']) +
|
||||
$this->maxSportBonusPoints($settings) +
|
||||
$this->maxWalkPoints($entry, $settings) +
|
||||
($eventSignalPoints !== 0.0 ? 8.0 : 0.0) +
|
||||
(float) $scoring['journal_points'],
|
||||
1
|
||||
);
|
||||
@@ -100,6 +125,28 @@ final class ScoringService
|
||||
];
|
||||
}
|
||||
|
||||
private function eventSignalPoints(array $events): float
|
||||
{
|
||||
if ($events === []) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$scores = [];
|
||||
foreach ($events as $event) {
|
||||
if (!is_array($event)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$scores[] = signal_combo_score($event['mood'] ?? 0, $event['energy'] ?? 0, $event['stress'] ?? 0);
|
||||
}
|
||||
|
||||
if ($scores === []) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return round(max(-8.0, min(8.0, (array_sum($scores) / count($scores)) * 4.0)), 1);
|
||||
}
|
||||
|
||||
private function sleepDurationPoints(float $hours, array $points): float
|
||||
{
|
||||
if ($hours < 4) {
|
||||
@@ -304,6 +351,127 @@ final class ScoringService
|
||||
return $total;
|
||||
}
|
||||
|
||||
private function normalizeSummary(mixed $summary): array
|
||||
{
|
||||
$summary = is_array($summary) ? $summary : [];
|
||||
|
||||
return [
|
||||
'comment' => trim((string) ($summary['comment'] ?? '')),
|
||||
'mood' => normalize_signal_value($summary['mood'] ?? 0),
|
||||
'energy' => normalize_signal_value($summary['energy'] ?? 0),
|
||||
'stress' => normalize_signal_value($summary['stress'] ?? 0),
|
||||
'alcohol' => $this->normalizeBoolean($summary['alcohol'] ?? false),
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeEvents(mixed $events): array
|
||||
{
|
||||
if (!is_array($events)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$normalized = [];
|
||||
|
||||
foreach ($events as $event) {
|
||||
if (!is_array($event)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$type = trim((string) ($event['type'] ?? 'event'));
|
||||
if (!array_key_exists($type, day_event_type_options())) {
|
||||
$type = 'event';
|
||||
}
|
||||
|
||||
$time = trim((string) ($event['time'] ?? ''));
|
||||
if (preg_match('/^(?:[01]\d|2[0-3]):[0-5]\d$/', $time) !== 1) {
|
||||
$time = '';
|
||||
}
|
||||
|
||||
$unit = trim((string) ($event['unit'] ?? day_event_type_unit($type)));
|
||||
$value = is_numeric($event['value'] ?? null) ? (float) $event['value'] : 0.0;
|
||||
|
||||
$normalized[] = [
|
||||
'id' => trim((string) ($event['id'] ?? '')) ?: ('evt-' . substr(sha1(json_encode($event) . microtime(true)), 0, 12)),
|
||||
'type' => $type,
|
||||
'time' => $time,
|
||||
'comment' => trim(preg_replace('/\s+/u', ' ', (string) ($event['comment'] ?? '')) ?? ''),
|
||||
'value' => max(0, min(50000, $value)),
|
||||
'unit' => $unit,
|
||||
'sport_type_id' => trim((string) ($event['sport_type_id'] ?? '')),
|
||||
'consumed' => $this->normalizeBoolean($event['consumed'] ?? true),
|
||||
'mood' => normalize_signal_value($event['mood'] ?? 0),
|
||||
'energy' => normalize_signal_value($event['energy'] ?? 0),
|
||||
'stress' => normalize_signal_value($event['stress'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
usort($normalized, static function (array $left, array $right): int {
|
||||
return strcmp((string) ($left['time'] ?? ''), (string) ($right['time'] ?? ''));
|
||||
});
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
private function deriveLegacyFieldsFromEvents(array $events, array $summary, array $input): array
|
||||
{
|
||||
$sportMinutes = 0;
|
||||
$walkMinutes = 0;
|
||||
$walkSteps = 0;
|
||||
$sleepHours = 0.0;
|
||||
$alcohol = false;
|
||||
$walkMode = $this->normalizeWalkMode((string) ($input['walk_mode'] ?? 'time'));
|
||||
$sportTypes = [];
|
||||
|
||||
foreach ($events as $event) {
|
||||
$type = (string) ($event['type'] ?? 'event');
|
||||
$unit = (string) ($event['unit'] ?? '');
|
||||
$value = (float) ($event['value'] ?? 0);
|
||||
|
||||
if ($type === 'sport') {
|
||||
$sportMinutes += (int) round($value);
|
||||
$sportTypeID = trim((string) ($event['sport_type_id'] ?? ''));
|
||||
if ($sportTypeID !== '') {
|
||||
$sportTypes[$sportTypeID] = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($type === 'walk') {
|
||||
if ($unit === 'steps') {
|
||||
$walkMode = 'steps';
|
||||
$walkSteps += (int) round($value);
|
||||
} else {
|
||||
$walkMinutes += (int) round($value);
|
||||
}
|
||||
}
|
||||
|
||||
if ($type === 'sleep') {
|
||||
$sleepHours += $unit === 'min' ? ($value / 60) : $value;
|
||||
}
|
||||
|
||||
if ($type === 'alcohol') {
|
||||
$alcohol = !empty($event['consumed']);
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($summary['alcohol'])) {
|
||||
$alcohol = true;
|
||||
}
|
||||
|
||||
return [
|
||||
'mood' => signal_to_legacy_scale($summary['mood']),
|
||||
'energy' => signal_to_legacy_scale($summary['energy']),
|
||||
'stress' => signal_to_legacy_scale($summary['stress']),
|
||||
'sleep_hours' => $sleepHours,
|
||||
'sleep_feeling' => max(1, min(5, 3 + $summary['energy'])),
|
||||
'sport_minutes' => $sportMinutes,
|
||||
'walk_mode' => $walkMode,
|
||||
'walk_minutes' => $walkMinutes,
|
||||
'walk_steps' => $walkSteps,
|
||||
'alcohol' => $alcohol,
|
||||
'sport_types' => array_keys($sportTypes),
|
||||
];
|
||||
}
|
||||
|
||||
private function sortedRatings(array $ratings): array
|
||||
{
|
||||
usort($ratings, static fn (array $a, array $b): int => ((int) $a['min']) <=> ((int) $b['min']));
|
||||
|
||||
+165
@@ -634,3 +634,168 @@ function find_sport_types(array $settings, array $ids): array
|
||||
|
||||
return $types;
|
||||
}
|
||||
|
||||
function signal_scale_options(): array
|
||||
{
|
||||
return [
|
||||
-2 => 'sehr niedrig',
|
||||
-1 => 'niedrig',
|
||||
0 => 'neutral',
|
||||
1 => 'hoch',
|
||||
2 => 'sehr hoch',
|
||||
];
|
||||
}
|
||||
|
||||
function signal_labels_for_metric(string $metric): array
|
||||
{
|
||||
return match ($metric) {
|
||||
'stress' => [
|
||||
-2 => 'sehr ruhig',
|
||||
-1 => 'ruhig',
|
||||
0 => 'neutral',
|
||||
1 => 'angespannt',
|
||||
2 => 'sehr angespannt',
|
||||
],
|
||||
'energy' => [
|
||||
-2 => 'leer',
|
||||
-1 => 'matt',
|
||||
0 => 'okay',
|
||||
1 => 'wach',
|
||||
2 => 'kraftvoll',
|
||||
],
|
||||
default => [
|
||||
-2 => 'sehr niedrig',
|
||||
-1 => 'niedrig',
|
||||
0 => 'neutral',
|
||||
1 => 'hoch',
|
||||
2 => 'sehr hoch',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function normalize_signal_value(mixed $value): int
|
||||
{
|
||||
return max(-2, min(2, (int) $value));
|
||||
}
|
||||
|
||||
function signal_to_legacy_scale(mixed $value): int
|
||||
{
|
||||
return match (normalize_signal_value($value)) {
|
||||
-2 => 1,
|
||||
-1 => 3,
|
||||
0 => 5,
|
||||
1 => 7,
|
||||
2 => 9,
|
||||
};
|
||||
}
|
||||
|
||||
function legacy_to_signal_scale(mixed $value): int
|
||||
{
|
||||
$legacy = max(1, min(10, (int) $value));
|
||||
|
||||
return match (true) {
|
||||
$legacy <= 2 => -2,
|
||||
$legacy <= 4 => -1,
|
||||
$legacy <= 6 => 0,
|
||||
$legacy <= 8 => 1,
|
||||
default => 2,
|
||||
};
|
||||
}
|
||||
|
||||
function day_event_type_options(): array
|
||||
{
|
||||
return [
|
||||
'event' => [
|
||||
'label' => 'Ereignis',
|
||||
'icon' => '/assets/icons/activity-event.svg',
|
||||
'unit' => '',
|
||||
],
|
||||
'walk' => [
|
||||
'label' => 'Spaziergang',
|
||||
'icon' => sport_icon_path('hike'),
|
||||
'unit' => 'min',
|
||||
],
|
||||
'sport' => [
|
||||
'label' => 'Sport',
|
||||
'icon' => sport_icon_path('run'),
|
||||
'unit' => 'min',
|
||||
],
|
||||
'sleep' => [
|
||||
'label' => 'Schlaf',
|
||||
'icon' => '/assets/icons/activity-sleep.svg',
|
||||
'unit' => 'h',
|
||||
],
|
||||
'alcohol' => [
|
||||
'label' => 'Alkohol',
|
||||
'icon' => icon_path('alcohol'),
|
||||
'unit' => '',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
function day_event_type_label(string $type): string
|
||||
{
|
||||
return day_event_type_options()[$type]['label'] ?? day_event_type_options()['event']['label'];
|
||||
}
|
||||
|
||||
function day_event_type_icon(string $type): string
|
||||
{
|
||||
return day_event_type_options()[$type]['icon'] ?? day_event_type_options()['event']['icon'];
|
||||
}
|
||||
|
||||
function day_event_type_unit(string $type): string
|
||||
{
|
||||
return day_event_type_options()[$type]['unit'] ?? '';
|
||||
}
|
||||
|
||||
function signal_badge_tone(int $value, string $metric): string
|
||||
{
|
||||
$value = normalize_signal_value($value);
|
||||
|
||||
if ($metric === 'stress') {
|
||||
return match (true) {
|
||||
$value <= -1 => 'good',
|
||||
$value === 0 => 'neutral',
|
||||
default => 'warn',
|
||||
};
|
||||
}
|
||||
|
||||
return match (true) {
|
||||
$value <= -1 => 'warn',
|
||||
$value === 0 => 'neutral',
|
||||
default => 'good',
|
||||
};
|
||||
}
|
||||
|
||||
function signal_combo_score(mixed $mood, mixed $energy, mixed $stress): int
|
||||
{
|
||||
return max(-2, min(2, (int) round((
|
||||
normalize_signal_value($mood) +
|
||||
normalize_signal_value($energy) -
|
||||
normalize_signal_value($stress)
|
||||
) / 3)));
|
||||
}
|
||||
|
||||
function day_entry_has_content(array $entry): bool
|
||||
{
|
||||
if (trim((string) ($entry['summary']['comment'] ?? '')) !== '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (trim((string) ($entry['background_image'] ?? '')) !== '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return count(array_filter(is_array($entry['events'] ?? null) ? $entry['events'] : [], 'is_array')) > 0;
|
||||
}
|
||||
|
||||
function signal_value_class(int $value): string
|
||||
{
|
||||
return match (normalize_signal_value($value)) {
|
||||
-2 => 'neg2',
|
||||
-1 => 'neg1',
|
||||
0 => 'zero',
|
||||
1 => 'pos1',
|
||||
2 => 'pos2',
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user