26 Commits

Author SHA1 Message Date
hnzio 3a467aca38 Fix sleep bar fill rendering 2026-05-21 13:10:22 +02:00
hnzio f5daff1a04 Refine swipe affordance and sleep bar 2026-05-21 13:07:03 +02:00
hnzio a087eb508b Improve day swipe and sleep handling 2026-05-21 13:00:10 +02:00
hnzio 2047cae61c Slide day header and prefetch adjacent days 2026-05-21 12:51:10 +02:00
hnzio 1dd5339a46 Fix light mode summary text 2026-05-21 12:49:05 +02:00
hnzio 0df5983f65 Make day strip draggable and fix sleep bars 2026-05-21 12:47:30 +02:00
hnzio 7c9f464686 Fix dashboard swipe and visual details 2026-05-21 12:36:53 +02:00
hnzio abcd35714f Refine balance scoring and dashboard views 2026-05-21 12:19:52 +02:00
hnzio 0fb8adbb14 Make sleep phase bars proportional 2026-05-19 16:43:33 +02:00
hnzio 3b2c36c849 Fix proportional sleep bar and image overlays 2026-05-19 16:39:25 +02:00
hnzio adaff22651 Polish mobile media and sleep bars 2026-05-19 16:34:25 +02:00
hnzio 36a15f3ed4 Fix media and sleep bar layout 2026-05-19 16:24:11 +02:00
hnzio 6a5852654b Fix media lightbox and sleep target 2026-05-19 16:07:35 +02:00
hnzio 3e497a8047 Refine Health import event presentation 2026-05-19 15:54:50 +02:00
hnzio 59c7d89e81 Recognize German Health walk workouts 2026-05-19 15:33:59 +02:00
hnzio 176b07f202 Add Health import failure diagnostics 2026-05-19 15:30:43 +02:00
hnzio d8636f6c41 Handle flexible Health Auto Export payloads 2026-05-19 15:26:32 +02:00
hnzio a555f552c2 Support Health Auto Export metric names 2026-05-19 15:21:58 +02:00
hnzio e00cd66fbe Clarify Health Auto Export config uploads 2026-05-19 15:19:24 +02:00
hnzio e36f27da4a add health import 2026-05-19 14:50:19 +02:00
hnzio bc6e850afb feat(dashboard): refine moment media experience 2026-05-18 23:49:15 +02:00
hnzio b8a96e96ef fix(overlays): improve ios safe area scrolling 2026-05-18 16:39:38 +02:00
hnzio 48df9831fd fix(dashboard): improve light mode styling 2026-05-18 16:37:00 +02:00
hnzio 83b4686b6f feat(dashboard): add immersive day range views 2026-05-18 16:32:22 +02:00
hnzio e953d0fd42 feat(track): replace alcohol checkbox with a selectable tile 2026-04-14 15:09:25 +02:00
hnzio ab1d8bc677 refactor(archive): redesign segmented archive experience 2026-04-14 15:09:25 +02:00
22 changed files with 8733 additions and 838 deletions
+4
View File
@@ -2,6 +2,10 @@ Options -Indexes
DirectoryIndex index.php
AddType application/manifest+json .webmanifest
<IfModule mod_setenvif.c>
SetEnvIf Authorization "(.+)" HTTP_AUTHORIZATION=$1
</IfModule>
<IfModule mod_rewrite.c>
RewriteEngine On
+96
View File
@@ -0,0 +1,96 @@
# AGENTS.md
## Projektueberblick
Mood ist ein dateibasierter Stimmungstracker fuer klassische PHP/LAMP-Deployments ohne Datenbank.
Die App rendert serverseitig PHP-Templates und speichert Nutzer-, Einstellungs- und Trackingdaten unter `storage/`.
## Einstiegspunkte
- `index.php`: Front-Controller, bootet die App.
- `src/bootstrap.php`: laedt Dateien, initialisiert Session und stellt `storage/` sicher.
- `src/App.php`: zentrales Routing und Grossteil der Anwendungslogik.
## Wichtige Struktur
- `src/Domain/`: dateibasierte Repositories und Fachlogik.
- `src/Support/`: Auth, View, Verschluesselung, OpenAI, Web Push.
- `templates/layout.php`: globales Layout.
- `templates/pages/`: serverseitige Seiten.
- `assets/css/app.css`: gesamtes Styling.
- `assets/js/app.js`: Frontend-Logik fuer Charts, Formulare, Archiv, Push und PWA.
- `storage/system/`: globale Systemdaten wie Nutzer, Throttle, Notifications, Key-Dateien.
- `storage/users/<user>/`: Nutzerdaten, Einstellungen, Tage, Zusammenfassungen und Push-Status.
## Routing
Die App nutzt keinen Router von aussen. Routen werden direkt in `App::run()` per `switch ($path)` behandelt.
Wichtige Routen:
- `/setup`
- `/login`
- `/logout`
- `/`
- `/track`
- `/archive`
- `/options`
- `/push/subscribe`
- `/push/unsubscribe`
- `/push/test`
- `/reminders/run`
## Datenmodell
- Nutzer stehen in `storage/system/users.json`.
- Einstellungen pro Nutzer in `storage/users/<user>/settings.json`.
- Tagesdaten in `storage/users/<user>/days/YYYY-MM-DD.txt`.
- Wochen- und Monatszusammenfassungen unter `storage/users/<user>/summaries/`.
- Push-Subscriptions und Reminder-State liegen ebenfalls pro Nutzer unter `storage/users/<user>/`.
Tagesdateien und Zusammenfassungen koennen serverseitig verschluesselt gespeichert werden. Die Logik liegt in `src/Support/EntryCrypto.php`.
## Sicherheitsrelevante Regeln
- Form-POSTs nutzen CSRF-Token via `csrf_field()` und `App::enforceCsrf()`.
- JSON-POSTs nutzen `App::enforceRequestCsrf()`.
- Auth-Logik liegt in `src/Support/Auth.php`.
- Security-Header werden zentral in `App::sendSecurityHeaders()` gesetzt.
- Aendere keine Auth-, Cookie-, CSRF- oder Reminder-Token-Logik leichtfertig.
## Arbeitsregeln fuer Aenderungen
- Bevorzuge kleine, lokale Aenderungen. Die App ist bewusst simpel und frameworkfrei.
- Ziehe bestehende Hilfsfunktionen in `src/helpers.php` vor, statt neue Utility-Dateien einzufuehren.
- Wenn moeglich dem bestehenden Muster folgen: Daten lesen/schreiben in Repositories, Seiten in `App`, Ausgabe in Templates.
- Fuehre keine grossen Architekturumbauten ohne konkreten Bedarf ein. `src/App.php` ist zentral und gewollt monolithisch.
- Beruehre `storage/` nur, wenn die Aufgabe das wirklich erfordert. Dort koennen echte Nutzerdaten liegen.
- Fuehre keine Massenformatierung oder kosmetische Grossumbauten ohne Anlass durch.
## Frontend-Hinweise
- Das UI ist servergerendert; JavaScript erweitert nur interaktive Teile.
- Neue UI-Logik moeglichst in `assets/js/app.js` integrieren, statt neue Build-Schritte einzufuehren.
- Externe CDNs oder Frontend-Frameworks nicht einfuehren.
## KI- und Push-Integrationen
- OpenAI-Zusammenfassungen laufen ueber `src/Support/OpenAiSummaryService.php`.
- Web Push und VAPID laufen ueber `src/Support/WebPushService.php` und `src/Domain/NotificationRepository.php`.
- Bei Aenderungen in diesen Bereichen besonders auf Datenschutz, Fehlerbehandlung und Rueckwaertskompatibilitaet der gespeicherten Daten achten.
## Lokale Checks
Es gibt aktuell keine sichtbare Composer- oder PHPUnit-Konfiguration im Projekt.
Sinnvolle manuelle Checks:
- PHP-Syntax fuer geaenderte Dateien pruefen: `php -l <datei>`
- Setup/Login/Tracken/Archiv/Optionen im Browser kurz durchklicken
- Falls Push oder Reminder betroffen sind: relevante Endpunkte gezielt testen
## Deployment-Annahmen
- Ziel ist klassisches Apache/LAMP bzw. Cloudron.
- `.htaccess` und Schreibrechte auf `storage/` sind wichtig.
- Die App erwartet keinen Datenbankserver und keinen JS-Buildprozess.
+3082
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="12" y="18" width="40" height="28" rx="10" stroke="#DFF7FF" stroke-width="4"/>
<path d="M20 28H44" stroke="#90E3FF" stroke-width="4" stroke-linecap="round"/>
<path d="M20 36H34" stroke="#DFF7FF" stroke-width="4" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 348 B

+5
View File
@@ -0,0 +1,5 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21 20C15.4772 24.1806 12 30.8108 12 38C12 50.1503 21.8497 60 34 60C43.9254 60 52.3144 53.422 55 44" stroke="#DFF7FF" stroke-width="4" stroke-linecap="round"/>
<path d="M37 14L31 24H39L33 34" stroke="#90E3FF" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="47" cy="19" r="3" fill="#8CFFD1"/>
</svg>

After

Width:  |  Height:  |  Size: 434 B

+8
View File
@@ -0,0 +1,8 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M23 11C23.4 9.8 24.5 9 25.8 9H38.2C39.5 9 40.6 9.8 41 11L44.4 22.7C44.8 24.2 45 25.8 45 27.3C45 33.9 39.8 39.3 33.3 39.8V51H39.5C40.9 51 42 52.1 42 53.5C42 54.9 40.9 56 39.5 56H24.5C23.1 56 22 54.9 22 53.5C22 52.1 23.1 51 24.5 51H30.7V39.8C24.2 39.3 19 33.9 19 27.3C19 25.8 19.2 24.2 19.6 22.7L23 11Z" fill="#EFF7FF"/>
<path d="M22.4 20H41.6L41.2 22C41 22.9 41 23.8 41 24.7C41 29.8 36.8 34 31.7 34H32.3C37.2 34 41.2 30 41.2 25.1C41.2 24 41.1 23 40.8 22L40.3 20H22.4Z" fill="#8BE4FF" opacity="0.95"/>
<path d="M22 21.5C22 20.7 22.7 20 23.5 20H40.5C41.3 20 42 20.7 42 21.5C42 27.3 37.3 32 31.5 32C25.7 32 21 27.3 21 21.5H22Z" fill="#7FF3BB" opacity="0.8"/>
<path d="M24 15H40" stroke="#8BE4FF" stroke-width="2.8" stroke-linecap="round"/>
<path d="M27.5 44H36.5" stroke="#8BE4FF" stroke-width="3" stroke-linecap="round"/>
<path d="M25 53H39" stroke="#7FF3BB" stroke-width="3.2" stroke-linecap="round" opacity="0.9"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M338.8-9.9c11.9 8.6 16.3 24.2 10.9 37.8L271.3 224H416c13.5 0 25.5 8.4 30.1 21.1s.7 26.9-9.6 35.5l-288 240c-11.3 9.4-27.4 9.9-39.3 1.3s-16.3-24.2-10.9-37.8L176.7 288H32c-13.5 0-25.5-8.4-30.1-21.1s-.7-26.9 9.6-35.5l288-240c11.3-9.4 27.4-9.9 39.3-1.3z"/></svg>

After

Width:  |  Height:  |  Size: 349 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M256 512a256 256 0 1 0 0-512 256 256 0 1 0 0 512zM165.4 321.9c20.4 28 53.4 46.1 90.6 46.1s70.2-18.1 90.6-46.1c7.8-10.7 22.8-13.1 33.5-5.3s13.1 22.8 5.3 33.5C356.3 390 309.2 416 256 416s-100.3-26-129.4-65.9c-7.8-10.7-5.4-25.7 5.3-33.5s25.7-5.4 33.5 5.3zM144 208a32 32 0 1 1 64 0 32 32 0 1 1-64 0zm192-32a32 32 0 1 1 0 64 32 32 0 1 1 0-64z"/></svg>

After

Width:  |  Height:  |  Size: 438 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M120 56c0-30.9 25.1-56 56-56h24c17.7 0 32 14.3 32 32v448c0 17.7-14.3 32-32 32h-32c-29.8 0-54.9-20.4-62-48-.7 0-1.3 0-2 0-44.2 0-80-35.8-80-80 0-18 6-34.6 16-48-19.4-14.6-32-37.8-32-64 0-30.9 17.6-57.8 43.2-71.1-7.1-12-11.2-26-11.2-40.9 0-44.2 35.8-80 80-80V56zm272 0v24c44.2 0 80 35.8 80 80 0 15-4.1 29-11.2 40.9 25.7 13.3 43.2 40.1 43.2 71.1 0 26.2-12.6 49.4-32 64 10 13.4 16 30 16 48 0 44.2-35.8 80-80 80-.7 0-1.3 0-2 0-7.1 27.6-32.2 48-62 48h-32c-17.7 0-32-14.3-32-32V32c0-17.7 14.3-32 32-32h24c30.9 0 56 25.1 56 56z"/></svg>

After

Width:  |  Height:  |  Size: 620 B

+938 -8
View File
File diff suppressed because it is too large Load Diff
+2197 -43
View File
File diff suppressed because it is too large Load Diff
+219 -3
View File
@@ -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,73 @@ 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'] : [];
$health = is_array($entry['health'] ?? null) ? $entry['health'] : [];
$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[] = '- Bild: ' . (string) ($event['image'] ?? '');
$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[] = '- Quelle: ' . (string) ($event['source'] ?? '');
$eventLines[] = '- Import-ID: ' . (string) ($event['import_id'] ?? '');
$eventLines[] = '- Dauer-Label: ' . (string) ($event['duration_label'] ?? '');
$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[] = '';
}
$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', '']),
'## Gesundheitsdaten',
'- Schritte: ' . (int) ($health['steps'] ?? 0),
'- Schritte importiert am: ' . (string) ($health['steps_imported_at'] ?? ''),
'',
'## Tracking',
'## Werte',
'- Stimmung: ' . $entry['mood'],
'- Energie: ' . $entry['energy'],
@@ -202,14 +274,158 @@ 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']),
'- Schrittebonus: ' . format_points((float) ($evaluation['components']['step_bonus'] ?? 0)),
'- 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', '## Gesundheitsdaten')
?? $this->extractSection($content, '## Ereignisse', '## Tracking');
$healthSection = $this->extractSection($content, '## Gesundheitsdaten', '## 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) ?? ''),
'image' => $this->normalizeImageFileName((string) ($this->extract('/^- Bild:\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),
'source' => (string) ($this->extract('/^- Quelle:\s*(.*)$/mu', $block) ?? ''),
'import_id' => (string) ($this->extract('/^- Import-ID:\s*(.*)$/mu', $block) ?? ''),
'duration_label' => (string) ($this->extract('/^- Dauer-Label:\s*(.*)$/mu', $block) ?? ''),
'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) ?? '')),
];
}
$health = [
'steps' => max(0, (int) ($this->extract('/^- Schritte:\s*(\d+)$/m', $healthSection ?? '') ?? 0)),
'steps_imported_at' => (string) ($this->extract('/^- Schritte importiert am:\s*(.*)$/mu', $healthSection ?? '') ?? ''),
];
$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['health'] = $health;
$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] ?? ''));
}
private function normalizeImageFileName(string $fileName): string
{
$fileName = trim($fileName);
return preg_match('/^[A-Za-z0-9._-]+\.(?:jpe?g|png|webp)$/i', $fileName) === 1 ? $fileName : '';
}
private function decodeRoute(string $encoded): array
{
$encoded = trim($encoded);
if ($encoded === '') {
return [];
}
$decoded = base64_decode($encoded, true);
if (!is_string($decoded)) {
return [];
}
$route = json_decode($decoded, true);
if (!is_array($route)) {
return [];
}
$points = [];
foreach ($route as $point) {
if (!is_array($point) || !is_numeric($point['lat'] ?? null) || !is_numeric($point['lon'] ?? null)) {
continue;
}
$lat = (float) $point['lat'];
$lon = (float) $point['lon'];
if ($lat < -90 || $lat > 90 || $lon < -180 || $lon > 180) {
continue;
}
$points[] = [
'lat' => round($lat, 6),
'lon' => round($lon, 6),
];
}
return $points;
}
}
+330 -12
View File
@@ -6,25 +6,49 @@ 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'] ?? []))));
$health = $this->normalizeHealth($input['health'] ?? []);
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'] ?? '')),
'health' => $health,
'events' => $events,
];
}
@@ -36,6 +60,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 +72,9 @@ final class ScoringService
'sport_minutes' => $this->bandPoints((int) $entry['sport_minutes'], $scoring['sport_bands']),
'sport_bonus' => $sportBonus,
'walk_minutes' => $this->walkPoints($entry, $settings),
'step_bonus' => $this->stepBonusPoints($entry, $scoring['step_bonus'] ?? []),
'daily_steps' => $this->stepTargetPoints((int) ($entry['health']['steps'] ?? 0), $scoring['walk_step_targets'] ?? []),
'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 +94,9 @@ final class ScoringService
$this->maxBandPoints($scoring['sport_bands']) +
$this->maxSportBonusPoints($settings) +
$this->maxWalkPoints($entry, $settings) +
$this->maxStepTargetPoints($scoring['walk_step_targets'] ?? []) +
max(0.0, (float) ($scoring['step_bonus']['points'] ?? 0)) +
($eventSignalPoints !== 0.0 ? 8.0 : 0.0) +
(float) $scoring['journal_points'],
1
);
@@ -95,11 +126,72 @@ final class ScoringService
'guardrail' => $guardrail,
'sentiment' => $this->sentimentForLabel($label, $ratings),
'percentage' => $maxTotal > 0 ? round(($total / $maxTotal) * 100, 1) : 0,
'balance' => $this->dayBalance($entry, $components, $settings),
'sport_type' => $sportTypes[0] ?? null,
'sport_types' => $sportTypes,
];
}
private function dayBalance(array $entry, array $components, array $settings): array
{
$config = is_array($settings['day_balance'] ?? null) ? $settings['day_balance'] : [];
$moodWeight = max(0.0, (float) ($config['mood_weight'] ?? 3));
$energyWeight = max(0.0, (float) ($config['energy_weight'] ?? 2));
$stressWeight = max(0.0, (float) ($config['stress_weight'] ?? 2));
$weightTotal = max(1.0, $moodWeight + $energyWeight + $stressWeight);
$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));
$base = (($mood * $moodWeight) + ($energy * $energyWeight) + ((-$stress) * $stressWeight)) / $weightTotal;
$adjustmentPoints = 0.0;
foreach ($components as $key => $value) {
if (in_array($key, ['mood', 'energy', 'stress', 'pain'], true)) {
continue;
}
$adjustmentPoints += (float) $value;
}
$pointsPerStep = max(1.0, (float) ($config['points_per_step'] ?? 12));
$cap = max(0.0, min(2.0, (float) ($config['adjustment_cap'] ?? 1.0)));
$adjustment = max(-$cap, min($cap, $adjustmentPoints / $pointsPerStep));
$raw = max(-2.0, min(2.0, $base + $adjustment));
$level = max(-2, min(2, (int) round($raw)));
return [
'base' => round($base, 2),
'adjustment' => round($adjustment, 2),
'raw' => round($raw, 2),
'level' => $level,
'percentage' => round((($raw + 2.0) / 4.0) * 100, 1),
'tone' => signal_value_class($level),
];
}
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) {
@@ -170,6 +262,19 @@ final class ScoringService
return $this->bandPoints((int) $entry['walk_minutes'], $scoring['walk_bands'] ?? []);
}
private function stepBonusPoints(array $entry, array $config): float
{
$steps = (int) ($entry['health']['steps'] ?? 0);
$min = max(0, (int) ($config['min'] ?? 10000));
$max = max($min, (int) ($config['max'] ?? 15000));
if ($steps > $min && $steps <= $max) {
return max(0.0, (float) ($config['points'] ?? 1));
}
return 0.0;
}
private function maxWalkPoints(array $entry, array $settings): float
{
$scoring = $settings['scoring'] ?? [];
@@ -186,6 +291,20 @@ final class ScoringService
return $this->maxBandPoints($scoring['walk_bands'] ?? []);
}
private function maxStepTargetPoints(array $targets): float
{
$max = 0.0;
foreach ($targets as $target) {
if (!is_array($target)) {
continue;
}
$max = max($max, (float) ($target['points'] ?? 0));
}
return $max;
}
private function stepTargetPoints(int $steps, array $targets): float
{
if ($targets === []) {
@@ -304,6 +423,205 @@ 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'] ?? '')),
'image' => trim((string) ($event['image'] ?? '')),
'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),
'source' => trim((string) ($event['source'] ?? '')),
'import_id' => trim((string) ($event['import_id'] ?? '')),
'duration_label' => trim((string) ($event['duration_label'] ?? '')),
'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'] ?? []),
];
}
usort($normalized, static function (array $left, array $right): int {
return strcmp((string) ($left['time'] ?? ''), (string) ($right['time'] ?? ''));
});
return $normalized;
}
private function normalizeHealth(mixed $health): array
{
if (!is_array($health)) {
return [
'steps' => 0,
'steps_imported_at' => '',
];
}
return [
'steps' => max(0, min(100000, (int) ($health['steps'] ?? 0))),
'steps_imported_at' => trim((string) ($health['steps_imported_at'] ?? '')),
];
}
private function normalizeRoute(mixed $route): array
{
if (!is_array($route)) {
return [];
}
$points = [];
foreach ($route as $point) {
if (!is_array($point)) {
continue;
}
$lat = $point['lat'] ?? $point['latitude'] ?? null;
$lon = $point['lon'] ?? $point['longitude'] ?? null;
if (!is_numeric($lat) || !is_numeric($lon)) {
continue;
}
$lat = (float) $lat;
$lon = (float) $lon;
if ($lat < -90 || $lat > 90 || $lon < -180 || $lon > 180) {
continue;
}
$points[] = [
'lat' => round($lat, 6),
'lon' => round($lon, 6),
];
}
if (count($points) <= 180) {
return $points;
}
$step = max(1, (int) floor(count($points) / 180));
$reduced = [];
foreach ($points as $index => $point) {
if ($index % $step === 0) {
$reduced[] = $point;
}
}
$last = $points[count($points) - 1];
if ($reduced === [] || $reduced[count($reduced) - 1] !== $last) {
$reduced[] = $last;
}
return $reduced;
}
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']));
+252 -1
View File
@@ -38,7 +38,7 @@ final class UserRepository
public function verify(string $username, string $password): ?array
{
$user = $this->find($username);
$user = $this->find($username) ?? [];
if ($user === null) {
return null;
@@ -51,6 +51,257 @@ final class UserRepository
return $user;
}
public function findByRememberToken(string $selector, string $validator): ?array
{
$validatorHash = hash('sha256', $validator);
$now = time();
foreach ($this->all() as $user) {
$token = $user['remember_token'] ?? null;
if (!is_array($token) || !hash_equals((string) ($token['selector'] ?? ''), $selector)) {
continue;
}
$expiresAt = strtotime((string) ($token['expires_at'] ?? '')) ?: 0;
if ($expiresAt < $now) {
return null;
}
if (!hash_equals((string) ($token['validator_hash'] ?? ''), $validatorHash)) {
return null;
}
return $user;
}
return null;
}
public function storeRememberToken(string $username, string $selector, string $validatorHash, int $expiresAt): void
{
$normalized = normalize_username($username);
$users = $this->all();
$updated = false;
foreach ($users as &$user) {
if (($user['username'] ?? '') !== $normalized) {
continue;
}
$user['remember_token'] = [
'selector' => $selector,
'validator_hash' => $validatorHash,
'expires_at' => date(DATE_ATOM, $expiresAt),
'created_at' => date(DATE_ATOM),
];
$user['updated_at'] = date(DATE_ATOM);
$updated = true;
break;
}
unset($user);
if (!$updated) {
throw new RuntimeException('Der Remember-Me-Token konnte keinem Benutzer zugeordnet werden.');
}
$this->write(['users' => $users]);
}
public function clearRememberToken(string $username): void
{
$normalized = normalize_username($username);
$users = $this->all();
$updated = false;
foreach ($users as &$user) {
if (($user['username'] ?? '') !== $normalized || !array_key_exists('remember_token', $user)) {
continue;
}
unset($user['remember_token']);
$user['updated_at'] = date(DATE_ATOM);
$updated = true;
break;
}
unset($user);
if ($updated) {
$this->write(['users' => $users]);
}
}
public function findByHealthImportToken(string $token): ?array
{
$tokenHash = hash('sha256', $token);
foreach ($this->all() as $user) {
$config = $user['health_import'] ?? null;
if (!is_array($config) || empty($config['enabled'])) {
continue;
}
if (hash_equals((string) ($config['token_hash'] ?? ''), $tokenHash)) {
return $user;
}
}
return null;
}
public function healthImportConfig(string $username): array
{
$user = $this->find($username);
$config = is_array($user['health_import'] ?? null) ? $user['health_import'] : [];
return [
'enabled' => !empty($config['enabled']),
'token_prefix' => (string) ($config['token_prefix'] ?? ''),
'created_at' => (string) ($config['created_at'] ?? ''),
'last_import_at' => (string) ($config['last_import_at'] ?? ''),
'last_status' => (string) ($config['last_status'] ?? ''),
'last_message' => (string) ($config['last_message'] ?? ''),
'progress_done' => max(0, (int) ($config['progress_done'] ?? 0)),
'progress_total' => max(0, (int) ($config['progress_total'] ?? 0)),
'started_at' => (string) ($config['started_at'] ?? ''),
'updated_at' => (string) ($config['updated_at'] ?? ''),
'finished_at' => (string) ($config['finished_at'] ?? ''),
];
}
public function issueHealthImportToken(string $username): string
{
$token = 'mood_health_' . bin2hex(random_bytes(24));
$normalized = normalize_username($username);
$users = $this->all();
$updated = false;
foreach ($users as &$user) {
if (($user['username'] ?? '') !== $normalized) {
continue;
}
$currentConfig = is_array($user['health_import'] ?? null) ? $user['health_import'] : [];
$user['health_import'] = [
'enabled' => true,
'token_hash' => hash('sha256', $token),
'token_prefix' => substr($token, 0, 18),
'created_at' => date(DATE_ATOM),
'last_import_at' => (string) ($currentConfig['last_import_at'] ?? ''),
'last_status' => (string) ($currentConfig['last_status'] ?? ''),
'last_message' => (string) ($currentConfig['last_message'] ?? ''),
'progress_done' => max(0, (int) ($currentConfig['progress_done'] ?? 0)),
'progress_total' => max(0, (int) ($currentConfig['progress_total'] ?? 0)),
'started_at' => (string) ($currentConfig['started_at'] ?? ''),
'updated_at' => (string) ($currentConfig['updated_at'] ?? ''),
'finished_at' => (string) ($currentConfig['finished_at'] ?? ''),
];
$user['updated_at'] = date(DATE_ATOM);
$updated = true;
break;
}
unset($user);
if (!$updated) {
throw new RuntimeException('Der Health-Import-Token konnte keinem Benutzer zugeordnet werden.');
}
$this->write(['users' => $users]);
return $token;
}
public function revokeHealthImportToken(string $username): void
{
$normalized = normalize_username($username);
$users = $this->all();
$updated = false;
foreach ($users as &$user) {
if (($user['username'] ?? '') !== $normalized || !array_key_exists('health_import', $user)) {
continue;
}
unset($user['health_import']);
$user['updated_at'] = date(DATE_ATOM);
$updated = true;
break;
}
unset($user);
if ($updated) {
$this->write(['users' => $users]);
}
}
public function recordHealthImport(string $username, string $status, string $message): void
{
$normalized = normalize_username($username);
$users = $this->all();
$updated = false;
foreach ($users as &$user) {
if (($user['username'] ?? '') !== $normalized) {
continue;
}
$config = is_array($user['health_import'] ?? null) ? $user['health_import'] : [];
$config['last_import_at'] = date(DATE_ATOM);
$config['last_status'] = $status;
$config['last_message'] = substr($message, 0, 240);
$config['updated_at'] = date(DATE_ATOM);
if ($status !== 'running') {
$config['finished_at'] = date(DATE_ATOM);
if ($status === 'ok') {
$total = max(0, (int) ($config['progress_total'] ?? 0));
$config['progress_done'] = $total > 0 ? $total : max(0, (int) ($config['progress_done'] ?? 0));
}
}
$user['health_import'] = $config;
$user['updated_at'] = date(DATE_ATOM);
$updated = true;
break;
}
unset($user);
if ($updated) {
$this->write(['users' => $users]);
}
}
public function recordHealthImportProgress(string $username, string $message, int $done, int $total, ?string $startedAt = null): void
{
$normalized = normalize_username($username);
$users = $this->all();
$updated = false;
foreach ($users as &$user) {
if (($user['username'] ?? '') !== $normalized) {
continue;
}
$config = is_array($user['health_import'] ?? null) ? $user['health_import'] : [];
$config['last_status'] = 'running';
$config['last_message'] = substr($message, 0, 240);
$config['progress_done'] = max(0, min($done, max($total, 0)));
$config['progress_total'] = max(0, $total);
$config['started_at'] = $startedAt ?? (string) ($config['started_at'] ?? date(DATE_ATOM));
$config['updated_at'] = date(DATE_ATOM);
$config['finished_at'] = '';
$user['health_import'] = $config;
$user['updated_at'] = date(DATE_ATOM);
$updated = true;
break;
}
unset($user);
if ($updated) {
$this->write(['users' => $users]);
}
}
public function create(string $username, string $password, bool $isAdmin = false): array
{
$normalized = normalize_username($username);
+60 -1
View File
@@ -11,7 +11,7 @@ final class Auth
public function check(): bool
{
if (!isset($_SESSION['user']) || !is_array($_SESSION['user'])) {
return false;
return $this->attemptRememberedLogin();
}
$username = $_SESSION['user']['username'] ?? null;
@@ -62,17 +62,76 @@ final class Auth
$_SESSION['remember_me'] = $remember;
if ($remember) {
$this->issueRememberCookie($user['username']);
setcookie(session_name(), session_id(), session_cookie_options_for(time() + remember_me_lifetime()));
} else {
$this->users->clearRememberToken($user['username']);
$this->clearRememberCookie();
setcookie(session_name(), session_id(), session_cookie_options_for());
}
}
public function logout(): void
{
$username = $_SESSION['user']['username'] ?? null;
if (is_string($username) && $username !== '') {
$this->users->clearRememberToken($username);
}
unset($_SESSION['user']);
unset($_SESSION['remember_me']);
$this->clearRememberCookie();
setcookie(session_name(), '', session_cookie_options_for(time() - 3600));
session_regenerate_id(true);
}
private function attemptRememberedLogin(): bool
{
$cookie = $_COOKIE[remember_cookie_name()] ?? '';
if (!is_string($cookie) || $cookie === '') {
return false;
}
$parts = explode(':', $cookie, 2);
if (count($parts) !== 2) {
$this->clearRememberCookie();
return false;
}
[$selector, $validator] = $parts;
if (!ctype_xdigit($selector) || strlen($selector) !== 32 || !ctype_xdigit($validator) || strlen($validator) !== 64) {
$this->clearRememberCookie();
return false;
}
$user = $this->users->findByRememberToken($selector, $validator);
if ($user === null) {
$this->clearRememberCookie();
return false;
}
$this->login($user, true);
return true;
}
private function issueRememberCookie(string $username): void
{
$selector = bin2hex(random_bytes(16));
$validator = bin2hex(random_bytes(32));
$expiresAt = time() + remember_me_lifetime();
$this->users->storeRememberToken($username, $selector, hash('sha256', $validator), $expiresAt);
setcookie(remember_cookie_name(), $selector . ':' . $validator, session_cookie_options_for($expiresAt));
}
private function clearRememberCookie(): void
{
setcookie(remember_cookie_name(), '', session_cookie_options_for(time() - 3600));
}
}
+19 -1
View File
@@ -19,6 +19,19 @@ final class Defaults
'walk' => [
'mode' => 'time',
],
'sleep' => [
'optimal_hours' => 7.0,
],
'display' => [
'score_mode' => 'scale',
],
'day_balance' => [
'mood_weight' => 3,
'energy_weight' => 2,
'stress_weight' => 2,
'adjustment_cap' => 1.0,
'points_per_step' => 12,
],
'tracking' => [
'pain_enabled' => false,
],
@@ -88,7 +101,7 @@ final class Defaults
],
[
'id' => 'rowing',
'label' => 'Rudern',
'label' => 'Rudergerät',
'icon' => 'row',
'location' => '',
'recovery_group' => 'rudern',
@@ -152,6 +165,11 @@ final class Defaults
['steps' => 15000, 'points' => 4],
['steps' => 20000, 'points' => 0],
],
'step_bonus' => [
'min' => 10000,
'max' => 15000,
'points' => 1,
],
'journal_points' => 2,
'alcohol_penalty' => 5,
],
+213
View File
@@ -115,6 +115,23 @@ function format_points(float $value): string
return number_format($rounded, 1, ',', '.');
}
function format_duration_hours(float $hours): string
{
$minutes = max(0, (int) round($hours * 60));
$wholeHours = intdiv($minutes, 60);
$remainingMinutes = $minutes % 60;
if ($wholeHours <= 0) {
return $remainingMinutes . ' min';
}
if ($remainingMinutes === 0) {
return $wholeHours . ' h';
}
return $wholeHours . ' h ' . $remainingMinutes . ' min';
}
function normalize_username(string $username): string
{
return strtolower(trim($username));
@@ -197,6 +214,17 @@ function format_display_date(string $date, bool $withWeekday = true): string
return $weekdays[(int) $current->format('w')] . ', ' . $label;
}
function format_compact_date(string $date): string
{
$current = DateTimeImmutable::createFromFormat('Y-m-d', $date);
if ($current === false) {
return $date;
}
return $current->format('d.m.Y');
}
function format_display_datetime(string $value): string
{
try {
@@ -223,6 +251,17 @@ function format_display_datetime(string $value): string
return $current->format('j.') . ' ' . $months[(int) $current->format('n')] . ' ' . $current->format('Y') . ' um ' . $current->format('H:i');
}
function format_compact_datetime(string $value): string
{
try {
$current = new DateTimeImmutable($value);
} catch (Throwable) {
return $value;
}
return $current->format('d.m.Y · H:i');
}
function iso_week_key(string $date): string
{
$current = DateTimeImmutable::createFromFormat('Y-m-d', $date);
@@ -313,6 +352,11 @@ function remember_me_lifetime(): int
return 60 * 60 * 24 * 30;
}
function remember_cookie_name(): string
{
return 'mood_remember';
}
function session_cookie_params_for(int $lifetime = 0): array
{
return [
@@ -612,3 +656,172 @@ 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' => 'Moment',
'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;
}
if ((int) ($entry['health']['steps'] ?? 0) > 0) {
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',
};
}
+26 -40
View File
@@ -3,20 +3,23 @@
declare(strict_types=1);
$brandSubtitle = match ($page) {
'dashboard' => 'Statistiken und Verlauf',
'dashboard' => '',
'track' => 'Tag erfassen und bewerten',
'archive' => 'Rückblick auf vergangene Tage',
'archive' => '',
'options' => 'Logik, Erinnerungen, Sicherheit und Accounts',
'login' => 'Geschützter Zugang',
'setup' => 'Erstkonfiguration',
default => 'Stimmungstracker',
};
$immersiveDashboard = in_array($page, ['dashboard', 'options'], true);
$cssVersion = is_file(base_path('assets/css/app.css')) ? (string) filemtime(base_path('assets/css/app.css')) : '1';
$jsVersion = is_file(base_path('assets/js/app.js')) ? (string) filemtime(base_path('assets/js/app.js')) : '1';
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0b1e2e">
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet">
<meta name="apple-mobile-web-app-capable" content="yes">
@@ -33,15 +36,19 @@ $brandSubtitle = match ($page) {
<link rel="shortcut icon" href="/favicon.ico?v=20260412">
<link rel="apple-touch-icon" sizes="180x180" href="/assets/branding/apple-touch-icon.png?v=20260412">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="stylesheet" href="/assets/css/app.css">
<script defer src="/assets/js/app.js"></script>
<?php if (($page ?? '') === 'dashboard' && ($dashboardView ?? '') === 'day'): ?>
<link rel="prefetch" href="/?view=day&amp;date=<?= e(rawurlencode((string) ($dashboardPrevDate ?? shift_date(today(), -1)))) ?>">
<link rel="prefetch" href="/?view=day&amp;date=<?= e(rawurlencode((string) ($dashboardNextDate ?? shift_date(today(), 1)))) ?>">
<?php endif; ?>
<link rel="stylesheet" href="/assets/css/app.css?v=<?= e($cssVersion) ?>">
<script defer src="/assets/js/app.js?v=<?= e($jsVersion) ?>"></script>
</head>
<body class="app-body page-<?= e($page) ?><?= $authUser !== null ? ' is-authenticated' : '' ?>" data-authenticated="<?= $authUser !== null ? '1' : '0' ?>"<?= isset($trackMood) ? ' data-track-mood="' . e($trackMood) . '"' : '' ?>>
<body class="app-body page-<?= e($page) ?><?= $authUser !== null ? ' is-authenticated' : '' ?><?= !empty($pageBodyClass) ? ' ' . e((string) $pageBodyClass) : '' ?>" data-authenticated="<?= $authUser !== null ? '1' : '0' ?>"<?= isset($trackMood) ? ' data-track-mood="' . e($trackMood) . '"' : '' ?><?= isset($dashboardWalkMode) ? ' data-walk-mode="' . e((string) $dashboardWalkMode) . '"' : '' ?>>
<div class="aurora aurora-one"></div>
<div class="aurora aurora-two"></div>
<div class="pull-refresh-indicator glass-panel" data-pull-refresh-indicator aria-hidden="true">Zum Aktualisieren ziehen</div>
<div class="shell">
<?php if ($authUser !== null): ?>
<div class="shell<?= $immersiveDashboard ? ' shell--dashboard' : '' ?>">
<?php if ($authUser !== null && !$immersiveDashboard): ?>
<aside class="sidebar glass-panel">
<div class="brand-block">
<div class="brand-mark">
@@ -56,11 +63,7 @@ $brandSubtitle = match ($page) {
<nav class="main-nav" aria-label="Hauptnavigation">
<a class="<?= is_active_path('/') ? 'active' : '' ?>" href="/">
<img class="nav-icon" src="<?= e(icon_path('dashboard')) ?>" alt="">
<span>Dashboard</span>
</a>
<a class="<?= is_active_path('/track') ? 'active' : '' ?>" href="/track">
<img class="nav-icon" src="<?= e(icon_path('track')) ?>" alt="">
<span>Tracken</span>
<span>Start</span>
</a>
<a class="<?= is_active_path('/archive') ? 'active' : '' ?>" href="/archive">
<img class="nav-icon" src="<?= e(icon_path('archive')) ?>" alt="">
@@ -86,10 +89,12 @@ $brandSubtitle = match ($page) {
<?php endif; ?>
<main class="content">
<?php if ($authUser !== null): ?>
<?php if ($authUser !== null && !$immersiveDashboard): ?>
<header class="topbar glass-panel">
<div>
<p class="eyebrow"><?= e($brandSubtitle) ?></p>
<?php if ($brandSubtitle !== ''): ?>
<p class="eyebrow"><?= e($brandSubtitle) ?></p>
<?php endif; ?>
<h2><?= e($pageTitle) ?></h2>
</div>
<div class="topbar__meta">
@@ -113,32 +118,13 @@ $brandSubtitle = match ($page) {
<?= $content ?>
<footer class="site-footer glass-panel">
<a class="site-footer__link" href="https://git.hnz.io/hnzio/mood-tracking/releases" target="_blank" rel="noreferrer">Version 1.2.1</a>
<a class="site-footer__link" href="https://hnz.io" target="_blank" rel="noreferrer">(c) 2026 @hnz.io</a>
</footer>
<?php if (!$immersiveDashboard): ?>
<footer class="site-footer glass-panel">
<a class="site-footer__link" href="https://git.hnz.io/hnzio/mood-tracking/releases" target="_blank" rel="noreferrer">Version 1.7.0</a>
<a class="site-footer__link" href="https://hnz.io" target="_blank" rel="noreferrer">(c) 2026 @hnz.io</a>
</footer>
<?php endif; ?>
</main>
<?php if ($authUser !== null): ?>
<nav class="mobile-nav glass-panel" aria-label="Mobile Hauptnavigation">
<a class="<?= is_active_path('/') ? 'active' : '' ?>" href="/">
<img class="nav-icon" src="<?= e(icon_path('dashboard')) ?>" alt="">
<span>Dashboard</span>
</a>
<a class="<?= is_active_path('/track') ? 'active' : '' ?>" href="/track">
<img class="nav-icon" src="<?= e(icon_path('track')) ?>" alt="">
<span>Tracken</span>
</a>
<a class="<?= is_active_path('/archive') ? 'active' : '' ?>" href="/archive">
<img class="nav-icon" src="<?= e(icon_path('archive')) ?>" alt="">
<span>Archiv</span>
</a>
<a class="<?= is_active_path('/options') ? 'active' : '' ?>" href="/options">
<img class="nav-icon" src="<?= e(icon_path('options')) ?>" alt="">
<span>Optionen</span>
</a>
</nav>
<?php endif; ?>
</div>
</body>
</html>
+273 -207
View File
@@ -1,232 +1,298 @@
<section class="page-grid">
<article class="glass-panel archive-list">
<div class="section-head">
<div>
<p class="eyebrow">Archiv</p>
<h3>KI-Rückblicke und gespeicherte Tage</h3>
</div>
<span class="chart-chip"><?= e((string) count($entries)) ?> Einträge</span>
<?php
$baseParams = ['view' => $archiveView];
if ($archiveFilterMonth !== '') {
$baseParams['filter_month'] = $archiveFilterMonth;
}
$archiveUrl = static function (array $params = []) use ($baseParams): string {
$query = array_filter(array_merge($baseParams, $params), static fn (mixed $value): bool => $value !== null && $value !== '');
return $query === [] ? '/archive' : '/archive?' . http_build_query($query);
};
$detailType = $selectedEntry !== null
? 'day'
: ($selectedWeek !== null
? 'week'
: ($selectedMonth !== null ? 'month' : null));
$detailOpen = $detailType !== null;
?>
<section class="archive-page">
<article class="glass-panel archive-shell">
<div class="archive-toolbar archive-toolbar--compact">
<nav class="archive-switcher" aria-label="Archivansicht">
<a class="archive-switcher__item <?= $archiveView === 'days' ? 'active' : '' ?>" href="<?= e('/archive?view=days' . ($archiveFilterMonth !== '' ? '&filter_month=' . rawurlencode($archiveFilterMonth) : '')) ?>">Tage</a>
<a class="archive-switcher__item <?= $archiveView === 'weeks' ? 'active' : '' ?>" href="<?= e('/archive?view=weeks' . ($archiveFilterMonth !== '' ? '&filter_month=' . rawurlencode($archiveFilterMonth) : '')) ?>">Wochen</a>
<a class="archive-switcher__item <?= $archiveView === 'months' ? 'active' : '' ?>" href="<?= e('/archive?view=months' . ($archiveFilterMonth !== '' ? '&filter_month=' . rawurlencode($archiveFilterMonth) : '')) ?>">Monate</a>
</nav>
<form method="get" action="/archive" class="archive-filter">
<input type="hidden" name="view" value="<?= e($archiveView) ?>">
<label>
<span>Zeitraum</span>
<select name="filter_month" onchange="this.form.submit()">
<option value="">Alle Monate</option>
<?php foreach ($archiveMonthOptions as $monthOption): ?>
<option value="<?= e($monthOption) ?>" <?= $archiveFilterMonth === $monthOption ? 'selected' : '' ?>><?= e(month_label($monthOption)) ?></option>
<?php endforeach; ?>
</select>
</label>
</form>
</div>
<section class="archive-summary-section">
<div class="section-head section-head--compact">
<div>
<p class="eyebrow">KI</p>
<h4>Monatszusammenfassungen</h4>
</div>
<?php if (empty($aiAvailable)): ?>
<span class="chart-chip chart-chip--muted">API nicht bereit</span>
<div class="archive-workspace">
<section class="archive-main">
<?php if ($archiveView === 'days'): ?>
<div class="archive-list-header">
<div>
<p class="eyebrow">Tage</p>
<h4>Gespeicherte Tage</h4>
</div>
<span class="chart-chip"><?= e((string) count($entries)) ?> Tage</span>
</div>
<?php if ($entries === []): ?>
<p class="empty-state">Für diesen Zeitraum gibt es noch keine getrackten Tage.</p>
<?php else: ?>
<div class="archive-rows">
<?php foreach ($entries as $entry): ?>
<a class="archive-row archive-row--day <?= $selectedEntry !== null && $selectedEntry['date'] === $entry['date'] ? 'active' : '' ?>" href="<?= e($archiveUrl(['date' => $entry['date'], 'week' => null, 'month_key' => null])) ?>">
<div class="archive-row__main">
<strong><?= e(format_compact_date($entry['date'])) ?></strong>
<span><?= e($entry['evaluation']['label']) ?></span>
</div>
<div class="archive-row__meta">
<span><?= e(format_points((float) $entry['evaluation']['total'])) ?> Punkte</span>
<span>Stimmung <?= e((string) $entry['mood']) ?>/10</span>
</div>
<span class="archive-row__hint">Ansehen</span>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php elseif ($archiveView === 'weeks'): ?>
<div class="archive-list-header">
<div>
<p class="eyebrow">Wochen</p>
<h4>Wöchentliche KI-Rückblicke</h4>
</div>
<span class="chart-chip"><?= e((string) count($weeklyArchive)) ?> Wochen</span>
</div>
<?php if ($weeklyArchive === []): ?>
<p class="empty-state">Für diesen Zeitraum sind noch keine Wochen im Archiv vorhanden.</p>
<?php else: ?>
<div class="archive-rows archive-rows--summary">
<?php foreach ($weeklyArchive as $week): ?>
<a class="archive-row archive-row--summary archive-row--week <?= $selectedWeek !== null && $selectedWeek['summary_key'] === $week['summary_key'] ? 'active' : '' ?>" href="<?= e($archiveUrl(['week' => $week['summary_key'], 'date' => null, 'month_key' => null])) ?>">
<div class="archive-row__main archive-row__main--week">
<div class="archive-row__title-group">
<strong><?= e($week['label']) ?></strong>
<span><?= e(format_compact_date((string) $week['date_from'])) ?> bis <?= e(format_compact_date((string) $week['date_to'])) ?></span>
</div>
<span class="status-badge status-badge--<?= e($week['status_tone']) ?>"><?= e($week['status_label']) ?></span>
</div>
<div class="archive-row__meta archive-row__meta--stack">
<span><?= e((string) $week['note_entries_count']) ?> Texteinträge</span>
<span><?= e((string) $week['tracked_days']) ?> getrackte Tage</span>
<span><?= e($week['trend_label']) ?></span>
</div>
<span class="archive-row__hint"><?= !empty($week['has_summary']) ? 'Öffnen' : 'Details' ?></span>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php else: ?>
<div class="archive-list-header">
<div>
<p class="eyebrow">Monate</p>
<h4>Monatliche KI-Rückblicke</h4>
</div>
<span class="chart-chip"><?= e((string) count($monthlyArchive)) ?> Monate</span>
</div>
<?php if ($monthlyArchive === []): ?>
<p class="empty-state">Für diesen Zeitraum sind noch keine Monatsobjekte im Archiv vorhanden.</p>
<?php else: ?>
<div class="archive-rows archive-rows--summary">
<?php foreach ($monthlyArchive as $month): ?>
<a class="archive-row archive-row--summary archive-row--month <?= $selectedMonth !== null && $selectedMonth['summary_key'] === $month['summary_key'] ? 'active' : '' ?>" href="<?= e($archiveUrl(['month_key' => $month['summary_key'], 'date' => null, 'week' => null])) ?>">
<div class="archive-row__main archive-row__main--month">
<div class="archive-row__title-group">
<strong><?= e($month['label']) ?></strong>
<span><?= e(format_compact_date((string) $month['date_from'])) ?> bis <?= e(format_compact_date((string) $month['date_to'])) ?></span>
</div>
<span class="status-badge status-badge--<?= e($month['status_tone']) ?>"><?= e($month['status_label']) ?></span>
</div>
<div class="archive-row__meta archive-row__meta--stack">
<span><?= e($month['weekly_progress_label']) ?></span>
<span><?= e((string) $month['tracked_days']) ?> getrackte Tage</span>
</div>
<span class="archive-row__hint"><?= !empty($month['has_summary']) ? 'Öffnen' : 'Details' ?></span>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php endif; ?>
</div>
</section>
<?php if ($monthlyArchive === []): ?>
<p class="empty-state">Sobald genügend Wochenzusammenfassungen vorliegen, erscheinen hier die Monatsrückblicke.</p>
<?php else: ?>
<div class="archive-summary-grid">
<?php foreach ($monthlyArchive as $month): ?>
<article class="archive-summary-card">
<div class="archive-summary-card__head">
<div>
<span class="summary-badge">KI</span>
<strong><?= e($month['label']) ?></strong>
</div>
<?php if (!empty($month['has_summary'])): ?>
<span class="chart-chip">vorhanden</span>
<?php else: ?>
<span class="chart-chip chart-chip--muted">offen</span>
<?php endif; ?>
</div>
<p class="helper-text"><?= e($month['date_from']) ?> bis <?= e($month['date_to']) ?></p>
<p class="helper-text"><?= e((string) $month['weekly_summary_count']) ?> KI-Wochenzusammenfassungen im Monat verfügbar</p>
<?php if (!empty($month['summary'])): ?>
<p class="helper-text">Erstellt am <?= e(format_display_datetime((string) $month['summary']['created_at'])) ?></p>
<aside class="archive-detail <?= $detailOpen ? 'is-open' : '' ?>" id="archive-detail-panel" data-detail-open="<?= $detailOpen ? '1' : '0' ?>">
<div class="glass-panel archive-detail__panel">
<div class="archive-detail__top">
<div>
<p class="eyebrow">Details</p>
<?php if ($detailType === 'day'): ?>
<h3><?= e(format_compact_date($selectedEntry['date'])) ?></h3>
<?php elseif ($detailType === 'week'): ?>
<h3><?= e($selectedWeek['label']) ?></h3>
<?php elseif ($detailType === 'month'): ?>
<h3><?= e($selectedMonth['label']) ?></h3>
<?php else: ?>
<p class="helper-text">Mindestens 2 KI-Wochenzusammenfassungen nötig.</p>
<h3>Archivansicht</h3>
<?php endif; ?>
</div>
<div class="archive-item__actions archive-item__actions--stack">
<?php if (!empty($month['summary'])): ?>
<a class="ghost-link archive-action" href="/archive?summary_kind=monthly&amp;summary_key=<?= e(rawurlencode((string) $month['summary_key'])) ?>">Öffnen</a>
<?php endif; ?>
<?php if ($detailOpen): ?>
<a class="ghost-link archive-detail__close" href="<?= e($archiveUrl(['date' => null, 'week' => null, 'month_key' => null])) ?>">Schließen</a>
<?php endif; ?>
</div>
<form method="post" action="/archive">
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="generate_monthly_summary">
<input type="hidden" name="month_key" value="<?= e((string) $month['summary_key']) ?>">
<button class="ghost-button ghost-button--small" type="submit" <?= !$month['can_generate'] || empty($aiAvailable) ? 'disabled' : '' ?>>
<?= !empty($month['has_summary']) ? 'Neu generieren' : 'KI-Monatszusammenfassung erzeugen' ?>
</button>
</form>
</div>
</article>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
<?php if ($detailType === 'day'): ?>
<p class="hero-label"><?= e($selectedEntry['evaluation']['label']) ?> · <?= e(format_points((float) $selectedEntry['evaluation']['total'])) ?> Punkte</p>
<a class="primary-button button-link" href="/track?date=<?= e(rawurlencode($selectedEntry['date'])) ?>">Diesen Tag bearbeiten</a>
<section class="archive-summary-section">
<div class="section-head section-head--compact">
<div>
<p class="eyebrow">KI</p>
<h4>Wochenzusammenfassungen</h4>
</div>
</div>
<?php if ($weeklyArchive === []): ?>
<p class="empty-state">Noch keine Wochen verfügbar. Sobald Einträge vorliegen, kannst du hier Wochenrückblicke erzeugen.</p>
<?php else: ?>
<div class="archive-summary-grid">
<?php foreach ($weeklyArchive as $week): ?>
<article class="archive-summary-card">
<div class="archive-summary-card__head">
<div>
<span class="summary-badge">KI</span>
<strong><?= e($week['label']) ?></strong>
</div>
<?php if (!empty($week['has_summary'])): ?>
<span class="chart-chip">vorhanden</span>
<?php else: ?>
<span class="chart-chip chart-chip--muted">offen</span>
<?php endif; ?>
</div>
<p class="helper-text"><?= e($week['date_from']) ?> bis <?= e($week['date_to']) ?></p>
<p class="helper-text"><?= e((string) $week['note_entries_count']) ?> Texteinträge · <?= e((string) $week['tracked_days']) ?> getrackte Tage</p>
<?php if (!empty($week['summary'])): ?>
<p class="helper-text">Erstellt am <?= e(format_display_datetime((string) $week['summary']['created_at'])) ?></p>
<?php else: ?>
<p class="helper-text">Mindestens 3 Texteinträge nötig.</p>
<dl class="detail-grid detail-grid--archive-day">
<div><dt>Stimmung</dt><dd><?= e((string) $selectedEntry['mood']) ?>/10</dd></div>
<div><dt>Energie</dt><dd><?= e((string) $selectedEntry['energy']) ?>/10</dd></div>
<div><dt>Stress</dt><dd><?= e((string) $selectedEntry['stress']) ?>/10</dd></div>
<?php if (!empty($settings['tracking']['pain_enabled'])): ?>
<div><dt>Schmerzen</dt><dd><?= e((string) $selectedEntry['pain']) ?>/10</dd></div>
<?php endif; ?>
<div><dt>Schlaf</dt><dd><?= e((string) $selectedEntry['sleep_hours']) ?> h</dd></div>
<div><dt>Schlafgefühl</dt><dd><?= e((string) $selectedEntry['sleep_feeling']) ?>/5</dd></div>
<div><dt>Sport</dt><dd><?= e((string) $selectedEntry['sport_minutes']) ?> min</dd></div>
<div><dt>Spaziergang</dt><dd><?= e(format_walk_value($selectedEntry)) ?></dd></div>
<div><dt>Alkohol</dt><dd><?= !empty($selectedEntry['alcohol']) ? 'ja' : 'nein' ?></dd></div>
</dl>
<div class="archive-item__actions archive-item__actions--stack">
<?php if (!empty($week['summary'])): ?>
<a class="ghost-link archive-action" href="/archive?summary_kind=weekly&amp;summary_key=<?= e(rawurlencode((string) $week['summary_key'])) ?>">Öffnen</a>
<?php endif; ?>
<div class="note-box">
<h4>Notiz</h4>
<p><?= nl2br(e($selectedEntry['note'] !== '' ? $selectedEntry['note'] : 'Keine Notiz hinterlegt.')) ?></p>
</div>
<?php elseif ($detailType === 'week'): ?>
<p class="hero-label"><?= e(format_compact_date((string) $selectedWeek['date_from'])) ?> bis <?= e(format_compact_date((string) $selectedWeek['date_to'])) ?></p>
<div class="archive-detail__status-row">
<span class="status-badge status-badge--<?= e($selectedWeek['status_tone']) ?>"><?= e($selectedWeek['status_label']) ?></span>
<span class="chart-chip"><?= e($selectedWeek['trend_label']) ?></span>
</div>
<dl class="detail-grid detail-grid--archive">
<div><dt>Texteinträge</dt><dd><?= e((string) $selectedWeek['note_entries_count']) ?></dd></div>
<div><dt>Getrackte Tage</dt><dd><?= e((string) $selectedWeek['tracked_days']) ?></dd></div>
<?php if (!empty($selectedWeek['summary'])): ?>
<div><dt>Erstellt am</dt><dd><?= e(format_compact_datetime((string) $selectedWeek['summary']['created_at'])) ?></dd></div>
<?php endif; ?>
</dl>
<div class="note-box archive-detail__status-note">
<h4>KI-Status</h4>
<p><?= e($selectedWeek['status_hint']) ?></p>
</div>
<?php if (!empty($selectedWeek['summary'])): ?>
<div class="archive-detail__actions">
<a class="ghost-link archive-action" href="<?= e($archiveUrl(['week' => $selectedWeek['summary_key'], 'date' => null, 'month_key' => null])) ?>">Öffnen</a>
<form method="post" action="/archive">
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="generate_weekly_summary">
<input type="hidden" name="week_key" value="<?= e((string) $week['summary_key']) ?>">
<button class="ghost-button ghost-button--small" type="submit" <?= !$week['can_generate'] || empty($aiAvailable) ? 'disabled' : '' ?>>
<?= !empty($week['has_summary']) ? 'Neu generieren' : 'KI-Wochenzusammenfassung erzeugen' ?>
</button>
<input type="hidden" name="view" value="weeks">
<input type="hidden" name="filter_month" value="<?= e($archiveFilterMonth) ?>">
<input type="hidden" name="week_key" value="<?= e((string) $selectedWeek['summary_key']) ?>">
<button class="ghost-button" type="submit" <?= empty($aiAvailable) ? 'disabled' : '' ?>>Neu generieren</button>
</form>
</div>
</article>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
<section class="archive-summary-section">
<div class="section-head section-head--compact">
<div>
<p class="eyebrow">Tage</p>
<h4>Alle gespeicherten Tage</h4>
</div>
</div>
<?php if ($entries === []): ?>
<p class="empty-state">Noch keine Einträge vorhanden. Auf der Tracking-Seite kannst du den ersten Tag anlegen.</p>
<?php else: ?>
<div class="archive-items">
<?php foreach ($entries as $entry): ?>
<article class="archive-item <?= $selectedEntry !== null && $selectedEntry['date'] === $entry['date'] ? 'active' : '' ?>">
<div>
<strong><?= e(format_display_date($entry['date'], false)) ?></strong>
<span><?= e($entry['evaluation']['label']) ?></span>
<?php if ((int) $entry['sport_minutes'] > 0 && !empty($entry['sport_type_meta'])): ?>
<span class="sport-pill-group">
<?php foreach ($entry['sport_type_meta'] as $sportType): ?>
<span class="sport-pill">
<img src="<?= e(sport_icon_path($sportType['icon'])) ?>" alt="">
<span><?= e($sportType['label']) ?><?= !empty($sportType['location']) ? ' · ' . e(sport_location_label((string) $sportType['location'])) : '' ?></span>
</span>
<?php endforeach; ?>
</span>
<?php endif; ?>
<div class="note-box note-box--summary">
<h4>KI-Wochenzusammenfassung</h4>
<p><?= e((string) ($selectedWeek['summary']['text'] ?? '')) ?></p>
</div>
<div class="archive-item__meta">
<span><?= e(format_points((float) $entry['evaluation']['total'])) ?></span>
<span>Stimmung <?= e((string) $entry['mood']) ?>/10</span>
</div>
<div class="archive-item__actions">
<a class="ghost-link archive-action" href="/archive?date=<?= e(rawurlencode($entry['date'])) ?>">Ansehen</a>
<a class="ghost-link archive-action" href="/track?date=<?= e(rawurlencode($entry['date'])) ?>">Bearbeiten</a>
</div>
</article>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
</article>
<?php else: ?>
<form method="post" action="/archive" class="archive-detail__single-action">
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="generate_weekly_summary">
<input type="hidden" name="view" value="weeks">
<input type="hidden" name="filter_month" value="<?= e($archiveFilterMonth) ?>">
<input type="hidden" name="week_key" value="<?= e((string) $selectedWeek['summary_key']) ?>">
<button class="primary-button" type="submit" <?= !$selectedWeek['can_generate'] || empty($aiAvailable) ? 'disabled' : '' ?>>KI-Wochenzusammenfassung erzeugen</button>
</form>
<?php endif; ?>
<?php elseif ($detailType === 'month'): ?>
<p class="hero-label"><?= e(format_compact_date((string) $selectedMonth['date_from'])) ?> bis <?= e(format_compact_date((string) $selectedMonth['date_to'])) ?></p>
<aside class="stack-column">
<?php if ($selectedSummary !== null): ?>
<article class="glass-panel detail-card">
<p class="eyebrow">KI-Zusammenfassung</p>
<h3><?= e($selectedSummary['title']) ?></h3>
<p class="hero-label"><?= e($selectedSummary['date_from']) ?> bis <?= e($selectedSummary['date_to']) ?></p>
<p class="helper-text">Erstellt am <?= e(format_display_datetime((string) $selectedSummary['created_at'])) ?></p>
<div class="archive-detail__status-row">
<span class="status-badge status-badge--<?= e($selectedMonth['status_tone']) ?>"><?= e($selectedMonth['status_label']) ?></span>
<span class="chart-chip"><?= e($selectedMonth['weekly_progress_label']) ?></span>
</div>
<div class="note-box note-box--summary">
<h4>Text</h4>
<p><?= e($selectedSummary['text']) ?></p>
</div>
</article>
<?php elseif ($selectedEntry !== null): ?>
<article class="glass-panel detail-card">
<p class="eyebrow">Ausgewählt</p>
<h3><?= e(format_display_date($selectedEntry['date'])) ?></h3>
<p class="hero-label"><?= e($selectedEntry['evaluation']['label']) ?> · <?= e(format_points((float) $selectedEntry['evaluation']['total'])) ?> Punkte</p>
<a class="primary-button button-link" href="/track?date=<?= e(rawurlencode($selectedEntry['date'])) ?>">Diesen Tag bearbeiten</a>
<dl class="detail-grid">
<div><dt>Stimmung</dt><dd><?= e((string) $selectedEntry['mood']) ?>/10</dd></div>
<div><dt>Energie</dt><dd><?= e((string) $selectedEntry['energy']) ?>/10</dd></div>
<div><dt>Stress</dt><dd><?= e((string) $selectedEntry['stress']) ?>/10</dd></div>
<?php if (!empty($settings['tracking']['pain_enabled'])): ?>
<div><dt>Schmerzen</dt><dd><?= e((string) $selectedEntry['pain']) ?>/10</dd></div>
<?php endif; ?>
<div><dt>Schlaf</dt><dd><?= e((string) $selectedEntry['sleep_hours']) ?> h</dd></div>
<div><dt>Schlafgefühl</dt><dd><?= e((string) $selectedEntry['sleep_feeling']) ?>/5</dd></div>
<div><dt>Sport</dt><dd><?= e((string) $selectedEntry['sport_minutes']) ?> min</dd></div>
<div>
<dt>Sportarten</dt>
<dd>
<?php if ((int) $selectedEntry['sport_minutes'] > 0 && !empty($selectedEntry['sport_type_meta'])): ?>
<span class="sport-pill-group sport-pill-group--inline">
<?php foreach ($selectedEntry['sport_type_meta'] as $sportType): ?>
<span class="sport-pill sport-pill--inline">
<img src="<?= e(sport_icon_path($sportType['icon'])) ?>" alt="">
<span><?= e($sportType['label']) ?><?= !empty($sportType['location']) ? ' · ' . e(sport_location_label((string) $sportType['location'])) : '' ?></span>
</span>
<?php endforeach; ?>
</span>
<?php else: ?>
keine
<dl class="detail-grid detail-grid--archive">
<div><dt>KI-Wochen vorhanden</dt><dd><?= e((string) $selectedMonth['weekly_summary_count']) ?> / <?= e((string) ((int) $selectedMonth['weekly_total_count'])) ?></dd></div>
<?php if (!empty($selectedMonth['summary'])): ?>
<div><dt>Erstellt am</dt><dd><?= e(format_compact_datetime((string) $selectedMonth['summary']['created_at'])) ?></dd></div>
<?php endif; ?>
</dd>
</div>
<div><dt>Sportbonus</dt><dd><?= e(format_points((float) ($selectedEntry['evaluation']['components']['sport_bonus'] ?? 0))) ?></dd></div>
<div><dt>Spaziergang</dt><dd><?= e(format_walk_value($selectedEntry)) ?></dd></div>
<div><dt>Alkohol</dt><dd><?= !empty($selectedEntry['alcohol']) ? 'ja' : 'nein' ?></dd></div>
</dl>
</dl>
<div class="note-box">
<h4>Notiz</h4>
<p><?= nl2br(e($selectedEntry['note'] !== '' ? $selectedEntry['note'] : 'Keine Notiz hinterlegt.')) ?></p>
<div class="note-box archive-detail__status-note">
<h4>Monatsstatus</h4>
<p><?= e($selectedMonth['status_hint']) ?></p>
</div>
<div class="note-box archive-detail__week-status">
<h4>Wochen in diesem Monat</h4>
<div class="archive-mini-list">
<?php foreach ($selectedMonth['weeks'] as $week): ?>
<div class="archive-mini-list__row">
<span><?= e($week['label']) ?></span>
<span class="status-badge status-badge--<?= !empty($week['has_summary']) ? 'ready' : 'blocked' ?>"><?= !empty($week['has_summary']) ? 'vorhanden' : 'fehlt' ?></span>
</div>
<?php endforeach; ?>
</div>
</div>
<?php if (!empty($selectedMonth['summary'])): ?>
<div class="archive-detail__actions">
<a class="ghost-link archive-action" href="<?= e($archiveUrl(['month_key' => $selectedMonth['summary_key'], 'date' => null, 'week' => null])) ?>">Öffnen</a>
<form method="post" action="/archive">
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="generate_monthly_summary">
<input type="hidden" name="view" value="months">
<input type="hidden" name="filter_month" value="<?= e($archiveFilterMonth) ?>">
<input type="hidden" name="month_key" value="<?= e((string) $selectedMonth['summary_key']) ?>">
<button class="ghost-button" type="submit" <?= empty($aiAvailable) ? 'disabled' : '' ?>>Neu generieren</button>
</form>
</div>
<div class="note-box note-box--summary">
<h4>KI-Monatszusammenfassung</h4>
<p><?= e((string) ($selectedMonth['summary']['text'] ?? '')) ?></p>
</div>
<?php else: ?>
<form method="post" action="/archive" class="archive-detail__single-action">
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="generate_monthly_summary">
<input type="hidden" name="view" value="months">
<input type="hidden" name="filter_month" value="<?= e($archiveFilterMonth) ?>">
<input type="hidden" name="month_key" value="<?= e((string) $selectedMonth['summary_key']) ?>">
<button class="primary-button" type="submit" <?= !$selectedMonth['can_generate'] || empty($aiAvailable) ? 'disabled' : '' ?>>KI-Monatszusammenfassung erzeugen</button>
</form>
<?php endif; ?>
<?php else: ?>
<p class="helper-text">Wähle links einen Tag, eine Woche oder einen Monat aus.</p>
<?php endif; ?>
</div>
</article>
<?php else: ?>
<article class="glass-panel detail-card">
<p class="eyebrow">Details</p>
<h3>Archivansicht</h3>
<p>Wähle links einen Tag oder eine KI-Zusammenfassung aus. Wochenrückblicke benötigen mindestens 3 Texteinträge, Monatsrückblicke mindestens 2 vorhandene KI-Wochenzusammenfassungen.</p>
</article>
<?php endif; ?>
</aside>
</aside>
</div>
</article>
</section>
+705 -90
View File
@@ -1,99 +1,714 @@
<section class="hero-grid">
<article class="hero-card hero-card--wide glass-panel">
<p class="eyebrow">Stimmung im Blick</p>
<h3>Dein Dashboard verbindet Verlauf, Score und Bewegungsdaten in einer schnellen Übersicht.</h3>
<p class="hero-copy">Die Bewertung basiert auf deiner konfigurierbaren Logik. Sobald du die Regeln in den Optionen änderst, spiegeln Archiv und Statistiken die neue Gewichtung wider.</p>
</article>
<?php
$dayDateLabel = format_display_date((string) $dayEntry['date']);
$dayWeekday = strtok($dayDateLabel, ',');
$dayBackground = is_string($dayEntry['background_image_url'] ?? null) ? (string) $dayEntry['background_image_url'] : null;
$summaryComment = trim((string) ($dayEntry['summary']['comment'] ?? $dayEntry['summary_comment'] ?? ''));
if (preg_match('/^\s*-?\s*(?:Stimmung|Energie|Stress)\s*:\s*0\s*$/iu', $summaryComment) === 1) {
$summaryComment = '';
}
$summaryMood = normalize_signal_value($dayEntry['summary']['mood'] ?? $dayEntry['summary_mood'] ?? 0);
$summaryEnergy = normalize_signal_value($dayEntry['summary']['energy'] ?? $dayEntry['summary_energy'] ?? 0);
$summaryStress = normalize_signal_value($dayEntry['summary']['stress'] ?? $dayEntry['summary_stress'] ?? 0);
$summaryAlcohol = !empty($dayEntry['summary']['alcohol'] ?? $dayEntry['summary_alcohol'] ?? false);
$dayHealth = is_array($dayEntry['health'] ?? null) ? $dayEntry['health'] : [];
$daySteps = (int) ($dayHealth['steps'] ?? 0);
$dayStepBonus = (float) ($dayEntry['evaluation']['components']['step_bonus'] ?? 0);
$optimalSleepHours = max(1.0, min(16.0, (float) ($settings['sleep']['optimal_hours'] ?? 7.0)));
$formatBalanceValue = static function (?array $entry) use ($settings): string {
if ($entry === null) {
return '';
}
<article class="hero-card glass-panel">
<p class="eyebrow">Heute</p>
<?php if ($summary['today'] !== null): ?>
<div class="hero-score"><?= e(format_points((float) $summary['today']['evaluation']['total'])) ?></div>
<p class="hero-label"><?= e($summary['today']['evaluation']['label']) ?></p>
<?php else: ?>
<div class="hero-score">-</div>
<p class="hero-label">Noch kein Eintrag für heute</p>
<?php endif; ?>
</article>
</section>
$mode = (string) ($settings['display']['score_mode'] ?? 'scale');
$balance = is_array($entry['evaluation']['balance'] ?? null) ? $entry['evaluation']['balance'] : [];
if ($mode === 'points') {
return format_points((float) ($entry['evaluation']['total'] ?? 0)) . ' Punkte';
}
<section class="stats-grid">
<article class="metric-card glass-panel">
<span>Getrackte Tage</span>
<strong><?= e((string) $summary['tracked_days']) ?></strong>
</article>
<article class="metric-card glass-panel">
<span>Ø Score</span>
<strong><?= e(format_points((float) $summary['average_score'])) ?></strong>
</article>
<article class="metric-card glass-panel">
<span>Ø Stimmung</span>
<strong><?= e(format_points((float) $summary['average_mood'])) ?>/10</strong>
</article>
<article class="metric-card glass-panel">
<span>Ø Stress</span>
<strong><?= e(format_points((float) $summary['average_stress'])) ?>/10</strong>
</article>
<article class="metric-card glass-panel">
<span>Serie</span>
<strong><?= e((string) $summary['streak']) ?> Tage</strong>
</article>
</section>
if ($mode === 'percent') {
return format_points((float) ($balance['percentage'] ?? $entry['evaluation']['percentage'] ?? 0)) . ' %';
}
<section class="dashboard-grid">
<article class="glass-panel chart-card chart-card--calendar">
<div class="section-head">
<div>
<p class="eyebrow">Kalender</p>
<h3>Gesamtstimmung pro Tag</h3>
</div>
$level = max(-2, min(2, (int) ($balance['level'] ?? 0)));
return ($level > 0 ? '+' : '') . (string) $level;
};
?>
<section class="dashboard-shell<?= $dayBackground !== null ? ' dashboard-shell--with-image' : '' ?>" data-dashboard-root>
<?php if ($dayBackground !== null): ?>
<div class="dashboard-shell__background" aria-hidden="true">
<img src="<?= e($dayBackground) ?>" alt="">
</div>
<div id="calendar-heatmap" class="calendar-heatmap" data-payload="<?= e($chartPayload) ?>"></div>
</article>
<article class="glass-panel chart-card">
<div class="section-head">
<div>
<p class="eyebrow">Trend</p>
<h3>Tagesstimmung</h3>
</div>
<span class="chart-chip">letzte 30 Einträge</span>
</div>
<div class="line-chart" data-chart-type="line" data-series="mood" data-payload="<?= e($chartPayload) ?>"></div>
</article>
<article class="glass-panel chart-card">
<div class="section-head">
<div>
<p class="eyebrow">Belastung</p>
<h3>Stressverlauf</h3>
</div>
<span class="chart-chip chart-chip--warm">weniger ist besser</span>
</div>
<div class="line-chart" data-chart-type="line" data-series="stress" data-payload="<?= e($chartPayload) ?>"></div>
</article>
<?php if (!empty($settings['tracking']['pain_enabled'])): ?>
<article class="glass-panel chart-card">
<div class="section-head">
<div>
<p class="eyebrow">Körper</p>
<h3>Schmerzverlauf</h3>
</div>
<span class="chart-chip chart-chip--warm">weniger ist besser</span>
</div>
<div class="line-chart" data-chart-type="line" data-series="pain" data-payload="<?= e($chartPayload) ?>"></div>
</article>
<?php endif; ?>
<article class="glass-panel chart-card chart-card--wide">
<div class="section-head">
<div>
<p class="eyebrow">Aktivität</p>
<h3>Sport und Spaziergang</h3>
<header class="dashboard-topbar">
<nav class="dashboard-switcher glass-panel" aria-label="Ansicht wechseln">
<a class="<?= $dashboardView === 'day' && $dashboardDate === today() ? 'active' : '' ?>" href="/?view=day&amp;date=<?= e(rawurlencode(today())) ?>">Heute</a>
<a class="<?= $dashboardView === 'week' ? 'active' : '' ?>" href="/?view=week&amp;date=<?= e(rawurlencode(today())) ?>">Woche</a>
<a class="<?= $dashboardView === 'month' ? 'active' : '' ?>" href="/?view=month&amp;date=<?= e(rawurlencode(today())) ?>">Monat</a>
</nav>
<button class="dashboard-settings glass-panel" type="button" data-settings-menu-open aria-label="Optionen öffnen">
<img src="<?= e(icon_path('options')) ?>" alt="">
</button>
</header>
<?php if ($dashboardView === 'day'): ?>
<div class="dashboard-day" data-day-swipe data-prev-date="<?= e($dashboardPrevDate) ?>" data-next-date="<?= e($dashboardNextDate) ?>">
<div class="dashboard-day-slider" data-day-slider-shell>
<span class="day-slide-hint day-slide-hint--prev" data-day-slide-prev-hint><span class="day-slide-hint__arrow" aria-hidden="true"></span>Vorherigen Tag laden</span>
<span class="day-slide-hint day-slide-hint--next" data-day-slide-next-hint>Nächster Tag laden<span class="day-slide-hint__arrow" aria-hidden="true"></span></span>
<div class="dashboard-day__hero" data-day-slider>
<p class="dashboard-day__eyebrow"><?= e((string) $dayWeekday) ?></p>
<h1><?= e(format_display_date((string) $dayEntry['date'], false)) ?></h1>
<nav class="dashboard-compare-strip" aria-label="Tagesvergleich" data-day-strip>
<span class="score-scale score-scale--day" aria-hidden="true"><span>+2</span><span>+1</span><span>0</span><span>-1</span><span>-2</span></span>
<?php foreach ($dashboardCompareDays as $compareDay): ?>
<a class="compare-day offset-<?= e((string) ($compareDay['offset'] ?? 0)) ?><?= !empty($compareDay['is_current']) ? ' is-current' : '' ?><?= empty($compareDay['has_content']) ? ' is-empty' : '' ?>" href="/?view=day&amp;date=<?= e(rawurlencode((string) $compareDay['date'])) ?>">
<span class="compare-day__line<?= empty($compareDay['has_content']) ? ' is-empty' : '' ?><?= !empty($compareDay['is_current']) ? ' is-primary' : '' ?> score-<?= e((string) ($compareDay['score_level'] ?? 'empty')) ?> compare-tone-<?= e((string) ($compareDay['line_tone'] ?? 'empty')) ?>">
<span class="compare-day__marker"></span>
</span>
</a>
<?php endforeach; ?>
</nav>
</div>
</div>
<button class="day-summary-card glass-panel<?= $dashboardHasContent ? ' is-filled' : '' ?>" type="button" data-summary-overlay-open>
<span class="day-summary-card__label">Tagesbilanz</span>
<strong class="day-summary-card__title"><?= $summaryComment !== '' ? e($summaryComment) : 'Tagesbilanz' ?></strong>
<?php if ($daySteps > 0): ?>
<span class="day-summary-card__chips">
<span class="day-chip day-chip--score">Bilanz <?= e($formatBalanceValue($dayEntry)) ?></span>
<span class="day-chip"><?= e(number_format($daySteps, 0, ',', '.')) ?> Schritte</span>
<?php if ($dayStepBonus > 0): ?>
<span class="day-chip day-chip--bonus">+<?= e(format_points($dayStepBonus)) ?> Punkt</span>
<?php endif; ?>
</span>
<?php else: ?>
<span class="day-summary-card__chips"><span class="day-chip day-chip--score">Bilanz <?= e($formatBalanceValue($dayEntry)) ?></span></span>
<?php endif; ?>
</button>
<section class="dashboard-moments-block">
<div class="section-head section-head--compact section-head--dashboard">
<div>
<p class="eyebrow">Deine Momente</p>
<h2>Momente des Tages</h2>
</div>
</div>
<div class="timeline-list">
<?php if ($dashboardTimeline === []): ?>
<article class="timeline-card timeline-card--empty glass-panel">
<div class="timeline-card__body">
<h3>Noch keine Momente</h3>
<p>Du kannst auch vergangene Tage jederzeit nachtragen.</p>
</div>
</article>
<?php endif; ?>
<?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 ? ($eventType === 'sleep' ? format_duration_hours((float) $item['value']) : rtrim(rtrim(number_format((float) $item['value'], 2, ',', '.'), '0'), ',') . ' ' . (string) $item['unit']) : ''; ?>
<?php $eventTitle = match ($eventType) {
'sport' => $isImportedWalkSport ? 'Spaziergang' : (string) ($sportType['label'] ?? 'Sport'),
'walk' => 'Spaziergang',
'sleep' => 'Schlaf',
default => (string) ($item['comment'] !== '' ? $item['comment'] : day_event_type_label($eventType)),
}; ?>
<?php $eventDetail = match ($eventType) {
'sport' => trim($eventValueText),
'walk', 'sleep' => trim($eventValueText),
default => trim($eventValueText . ($sportType !== null ? ' · ' . (string) ($sportType['label'] ?? '') : '')),
}; ?>
<?php $showEventComment = in_array($eventType, ['sport', 'walk', 'sleep'], true) && $eventComment !== ''; ?>
<?php
$sleepPhases = ['deep' => (float) ($item['sleep_deep'] ?? 0), 'rem' => (float) ($item['sleep_rem'] ?? 0), 'core' => (float) ($item['sleep_core'] ?? 0)];
$sleepPhaseSource = trim($eventComment . ' ' . (string) ($item['duration_label'] ?? '') . ' ' . (string) ($item['distance_label'] ?? '') . ' ' . (string) ($item['energy_label'] ?? '') . ' ' . (string) ($item['heart_rate_label'] ?? ''));
if ($eventType === 'sleep' && array_sum($sleepPhases) <= 0 && $sleepPhaseSource !== '') {
if (preg_match('/(?:Tief|Tiefschlaf)\s*:?\s*([0-9]+(?:[,.][0-9]+)?)/u', $sleepPhaseSource, $match) === 1) {
$sleepPhases['deep'] = (float) str_replace(',', '.', $match[1]);
}
if (preg_match('/REM(?:-Schlaf)?\s*:?\s*([0-9]+(?:[,.][0-9]+)?)/u', $sleepPhaseSource, $match) === 1) {
$sleepPhases['rem'] = (float) str_replace(',', '.', $match[1]);
}
if (preg_match('/(?:Kern|Kernschlaf)\s*:?\s*([0-9]+(?:[,.][0-9]+)?)/u', $sleepPhaseSource, $match) === 1) {
$sleepPhases['core'] = (float) str_replace(',', '.', $match[1]);
}
}
$sleepPhaseTotal = max(0.0, array_sum($sleepPhases));
$sleepActualTotal = $eventType === 'sleep' ? max((float) ($item['value'] ?? 0), $sleepPhaseTotal) : 0.0;
$sleepBarTotal = $eventType === 'sleep' ? max($sleepActualTotal, $optimalSleepHours / 0.75) : 0.0;
$sleepUnclassified = max(0.0, $sleepActualTotal - $sleepPhaseTotal);
$sleepPhaseRemainder = max(0.0, $sleepBarTotal - $sleepActualTotal);
$sleepOptimalPercent = $sleepBarTotal > 0 ? max(0, min(100, ($optimalSleepHours / $sleepBarTotal) * 100)) : 0;
$sleepActualPercent = $sleepBarTotal > 0 ? max(0, min(100, ($sleepActualTotal / $sleepBarTotal) * 100)) : 0;
$sleepPhaseLeft = 0.0;
?>
<?php $eventStats = array_values(array_filter([
$eventType !== 'sleep' ? (string) ($item['duration_label'] ?? '') : '',
(string) ($item['distance_label'] ?? ''),
'',
(string) ($item['heart_rate_label'] ?? ''),
], static function (string $value): bool {
$value = trim($value);
return $value !== '' && !preg_match('/^-\s*(Distanz|Energie|Puls|Route|Tief|Tiefschlaf|REM|REM-Schlaf|Kern|Kernschlaf)(?:-?Label)?:?(?:\s*[0-9]+(?:[,.][0-9]+)?)?$/u', $value);
})); ?>
<?php $eventPayload = encode_payload([
'id' => (string) ($item['id'] ?? ''),
'type' => (string) ($item['type'] ?? 'event'),
'time' => (string) ($item['time'] ?? ''),
'comment' => (string) ($item['comment'] ?? ''),
'value' => (float) ($item['value'] ?? 0),
'unit' => (string) ($item['unit'] ?? ''),
'sport_type_id' => (string) ($item['sport_type_id'] ?? ''),
'image' => (string) ($item['image'] ?? ''),
'consumed' => !empty($item['consumed']),
'mood' => normalize_signal_value($item['mood'] ?? 0),
'energy' => normalize_signal_value($item['energy'] ?? 0),
'stress' => normalize_signal_value($item['stress'] ?? 0),
'source' => (string) ($item['source'] ?? ''),
'import_id' => (string) ($item['import_id'] ?? ''),
'duration_label' => (string) ($item['duration_label'] ?? ''),
'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): ?>
<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">
<div class="timeline-card__icon-wrap" title="<?= e(day_event_type_label((string) $item['type'])) ?>">
<img class="timeline-card__icon" src="<?= e(day_event_type_icon((string) $item['type'])) ?>" alt="">
</div>
<div>
<strong class="timeline-card__time"><?= e($item['time'] !== '' ? $item['time'] : '--:--') ?></strong>
</div>
</div>
<div class="timeline-card__body">
<h3><?= e($eventTitle) ?></h3>
<?php if ($showEventComment): ?>
<p class="timeline-card__comment"><?= e($eventComment) ?></p>
<?php endif; ?>
<?php if ((string) ($item['type'] ?? '') === 'alcohol'): ?>
<p class="timeline-card__value">
<?= !empty($item['consumed']) ? 'Heute getrunken' : 'Heute nicht getrunken' ?>
</p>
<?php elseif ($eventDetail !== ''): ?>
<p class="timeline-card__value"><?= e($eventDetail) ?></p>
<?php endif; ?>
<?php if ((string) ($item['comment'] ?? '') !== '' && (string) ($item['type'] ?? '') === 'alcohol'): ?>
<p class="timeline-card__value"><?= e((string) $item['comment']) ?></p>
<?php endif; ?>
<?php if ($eventType === 'sleep' && $sleepActualTotal > 0): ?>
<div class="sleep-phase-bar" aria-label="Schlafphasen" style="--sleep-optimal-left: <?= e((string) $sleepOptimalPercent) ?>%; --sleep-actual-width: <?= e((string) $sleepActualPercent) ?>%">
<div class="sleep-phase-bar__fill" style="width: <?= e((string) $sleepActualPercent) ?>%">
<?php if ($sleepPhaseTotal > 0): ?>
<?php foreach (['deep' => ['Tief', 'deep'], 'rem' => ['REM', 'rem'], 'core' => ['Kern', 'core']] as $phase => [$label, $class]): ?>
<?php $phaseHours = max(0.0, (float) ($sleepPhases[$phase] ?? 0)); ?>
<?php if ($phaseHours <= 0) { continue; } ?>
<?php $phasePercent = $sleepActualTotal > 0 ? max(0.5, min(100, ($phaseHours / $sleepActualTotal) * 100)) : 0; ?>
<span class="sleep-phase-bar__segment sleep-phase-bar__segment--<?= e($class) ?><?= $phasePercent < 13 ? ' is-compact' : '' ?>" style="width: <?= e((string) $phasePercent) ?>%; flex-basis: <?= e((string) $phasePercent) ?>%" title="<?= e($label) ?>: <?= e(format_duration_hours($phaseHours)) ?>" data-tooltip="<?= e($label) ?>: <?= e(format_duration_hours($phaseHours)) ?>">
<strong><?= e($label) ?></strong> <?= e(format_duration_hours($phaseHours)) ?>
</span>
<?php $sleepPhaseLeft += $phaseHours; ?>
<?php endforeach; ?>
<?php endif; ?>
<?php if ($sleepUnclassified > 0): ?>
<?php $unclassifiedPercent = $sleepActualTotal > 0 ? max(0.5, min(100, ($sleepUnclassified / $sleepActualTotal) * 100)) : 0; ?>
<span class="sleep-phase-bar__segment sleep-phase-bar__segment--total<?= $sleepPhaseTotal > 0 ? ' is-after-phase' : '' ?><?= $unclassifiedPercent < 13 ? ' is-compact' : '' ?>" style="width: <?= e((string) $unclassifiedPercent) ?>%; flex-basis: <?= e((string) $unclassifiedPercent) ?>%" title="Schlaf: <?= e(format_duration_hours($sleepUnclassified)) ?>" data-tooltip="Schlaf: <?= e(format_duration_hours($sleepUnclassified)) ?>">
<strong>Schlaf</strong> <?= e(format_duration_hours($sleepUnclassified)) ?>
</span>
<?php endif; ?>
</div>
<span class="sleep-phase-bar__target"><span><?= e(format_duration_hours($optimalSleepHours)) ?></span></span>
<?php if ($sleepPhaseRemainder > 0): ?>
<span class="sleep-phase-bar__rest-label">noch <?= e(format_duration_hours($sleepPhaseRemainder)) ?> bis Skalenende</span>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if ($eventStats !== []): ?>
<div class="timeline-card__stats" aria-label="Importdetails">
<?php foreach ($eventStats as $stat): ?>
<span><?= e($stat) ?></span>
<?php endforeach; ?>
</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); ?>
<?php $valueTone = signal_value_class($metric === 'stress' ? -$value : $value); ?>
<?php if ($value === 0) { continue; } ?>
<span class="signal-pill signal-pill--<?= e(signal_badge_tone($value, $metric)) ?> signal-pill--<?= e($valueTone) ?>">
<strong><?= e($label) ?></strong>
<img class="signal-pill__icon" src="<?= e(icon_path($metric === 'mood' ? 'signal-mood' : ($metric === 'energy' ? 'signal-energy' : 'signal-stress'))) ?>" alt="<?= e($label) ?>">
<span><?= $value >= 0 ? '+' : '' ?><?= e((string) $value) ?></span>
</span>
<?php endforeach; ?>
</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">
<input type="hidden" name="date" value="<?= e((string) $dayEntry['date']) ?>">
<input type="hidden" name="event_id" value="<?= e((string) $item['id']) ?>">
<button class="ghost-button ghost-button--small" type="submit" data-confirm-delete aria-label="Moment löschen">×</button>
</form>
</article>
<?php endforeach; ?>
</div>
</section>
<button class="dashboard-fab" type="button" data-moment-overlay-open aria-label="Neuen Moment hinzufügen">+</button>
<div class="dashboard-fab-menu glass-panel" data-fab-menu hidden>
<?php foreach ($dashboardEventTypes as $type => $meta): ?>
<?php if ($type === 'alcohol') { continue; } ?>
<button type="button" data-fab-moment-choice="<?= e($type) ?>">
<img src="<?= e((string) $meta['icon']) ?>" alt="">
<span><?= e((string) $meta['label']) ?></span>
</button>
<?php endforeach; ?>
</div>
<span class="chart-chip chart-chip--cool">Aktivität pro Tag</span>
</div>
<div class="bar-chart" data-chart-type="bars" data-series="sport" data-payload="<?= e($chartPayload) ?>"></div>
</article>
<div class="dashboard-overlay" data-summary-overlay hidden>
<div class="dashboard-overlay__backdrop" data-summary-overlay-close></div>
<section class="dashboard-modal glass-panel dashboard-modal--summary" role="dialog" aria-modal="true">
<div class="dashboard-modal__controls">
<button class="dashboard-modal__round" type="button" data-summary-overlay-close>×</button>
<button class="dashboard-modal__round dashboard-modal__round--confirm" type="submit" form="day-summary-form">✓</button>
</div>
<form method="post" action="/" enctype="multipart/form-data" class="dashboard-modal__form" id="day-summary-form">
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="save_day_summary">
<input type="hidden" name="date" value="<?= e((string) $dayEntry['date']) ?>">
<h2 class="dashboard-modal__title"><?= e(format_display_date((string) $dayEntry['date'], false)) ?></h2>
<p class="dashboard-modal__subtitle">Deine Tagesbilanz</p>
<label class="dashboard-modal__textarea">
<textarea name="summary_comment" rows="5" placeholder="Fasse deinen Tag zusammen"><?= e($summaryComment) ?></textarea>
</label>
<div class="overlay-signal-grid overlay-signal-grid--summary-row">
<?php foreach (['summary_mood' => ['Stimmung', $summaryMood], 'summary_energy' => ['Energie', $summaryEnergy], 'summary_stress' => ['Stress', $summaryStress]] as $field => [$label, $value]): ?>
<?php $metric = str_replace('summary_', '', $field); ?>
<div class="overlay-signal-card overlay-signal-card--inline" data-stepper data-stepper-metric="<?= e($metric) ?>">
<div>
<h3><?= e($label) ?></h3>
<p data-stepper-label>
<?= e(signal_labels_for_metric($metric)[$value]) ?>
</p>
</div>
<div class="overlay-signal-card__control">
<div class="overlay-signal-card__ring tone-<?= e(signal_value_class($value)) ?>">
<span data-stepper-value><?= $value >= 0 ? '+' : '' ?><?= e((string) $value) ?></span>
</div>
<div class="overlay-signal-card__buttons">
<button type="button" data-stepper-minus>-</button>
<button type="button" data-stepper-plus>+</button>
</div>
<div class="signal-scale" aria-hidden="true"><span>-2</span><span>-1</span><span>0</span><span>+1</span><span>+2</span></div>
<input type="hidden" name="<?= e($field) ?>" value="<?= e((string) $value) ?>" data-stepper-input>
</div>
</div>
<?php endforeach; ?>
</div>
<fieldset class="moment-alcohol-field moment-alcohol-field--summary">
<legend>Alkohol</legend>
<div class="moment-choice-row">
<label class="moment-choice-pill">
<input type="radio" name="summary_alcohol" value="1" <?= $summaryAlcohol ? 'checked' : '' ?>>
<span>Ja</span>
</label>
<label class="moment-choice-pill">
<input type="radio" name="summary_alcohol" value="0" <?= !$summaryAlcohol ? 'checked' : '' ?>>
<span>Nein</span>
</label>
</div>
</fieldset>
<label>
<span>Tagesbild</span>
<input type="file" name="background_image" accept="image/jpeg,image/png,image/webp">
</label>
</form>
<?php if ($dayBackground !== null): ?>
<form method="post" action="/" class="dashboard-modal__secondary-action">
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="remove_background">
<input type="hidden" name="date" value="<?= e((string) $dayEntry['date']) ?>">
<button class="ghost-button" type="submit">Bild entfernen</button>
</form>
<?php endif; ?>
</section>
</div>
<div class="dashboard-overlay" data-moment-overlay hidden>
<div class="dashboard-overlay__backdrop" data-moment-overlay-close></div>
<section class="dashboard-modal glass-panel dashboard-modal--moment" role="dialog" aria-modal="true" data-moment-modal>
<div class="dashboard-modal__controls">
<button class="dashboard-modal__round" type="button" data-moment-overlay-close>×</button>
<button class="dashboard-modal__round dashboard-modal__round--confirm" type="submit" form="moment-form" data-moment-submit disabled>✓</button>
</div>
<div data-moment-step="choose">
<h2 class="dashboard-modal__title">Neuer Moment</h2>
<div class="moment-type-grid">
<?php foreach ($dashboardEventTypes as $type => $meta): ?>
<?php if ($type === 'alcohol') { continue; } ?>
<button class="moment-type-card" type="button" data-moment-type-choice="<?= e($type) ?>">
<img src="<?= e((string) $meta['icon']) ?>" alt="">
<span><?= e((string) $meta['label']) ?></span>
</button>
<?php endforeach; ?>
</div>
</div>
<form method="post" action="/" enctype="multipart/form-data" class="dashboard-modal__form" id="moment-form" data-moment-step="form" hidden>
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="add_event" data-moment-form-name>
<input type="hidden" name="date" value="<?= e((string) $dayEntry['date']) ?>">
<input type="hidden" name="event_id" value="" data-moment-event-id>
<input type="hidden" name="event_type" value="event" data-moment-type-input>
<input type="hidden" name="event_unit" value="" data-event-unit>
<input type="hidden" name="event_walk_mode" value="time" data-walk-mode-input>
<div class="dashboard-modal__heading-row">
<div>
<p class="dashboard-modal__subtitle" data-moment-type-label>Neuer Moment</p>
<h2 class="dashboard-modal__title">Was ist passiert?</h2>
</div>
<button class="ghost-button ghost-button--small" type="button" data-moment-back>Typ ändern</button>
</div>
<label class="dashboard-modal__textarea">
<textarea name="event_comment" rows="4" placeholder="Was hast du erlebt?" data-moment-comment></textarea>
</label>
<label>
<span>Momentbild</span>
<input type="file" name="event_image" accept="image/jpeg,image/png,image/webp">
</label>
<div class="field-grid field-grid--two">
<label>
<span>Erfasst um</span>
<input type="time" name="event_time" value="<?= e(date('H:i')) ?>" required>
</label>
<label data-moment-value-field>
<span data-moment-value-label>Wert</span>
<input type="number" name="event_value" min="0" max="50000" step="0.01" placeholder="optional" data-moment-value-input>
</label>
</div>
<fieldset data-moment-sport-field hidden>
<legend>Sportart</legend>
<input type="hidden" name="event_sport_type_id" value="">
<div class="moment-type-grid moment-type-grid--sport">
<?php foreach ($dashboardSportTypes as $sportType): ?>
<button class="moment-type-card moment-type-card--sport" type="button" data-sport-choice="<?= e((string) ($sportType['id'] ?? '')) ?>">
<img src="<?= e(sport_icon_path((string) ($sportType['icon'] ?? 'run'))) ?>" alt="">
<span><?= e((string) ($sportType['label'] ?? '')) ?></span>
</button>
<?php endforeach; ?>
</div>
</fieldset>
<fieldset class="moment-alcohol-field" data-moment-walk-field hidden>
<legend>Spaziergang als</legend>
<div class="moment-choice-row">
<label class="moment-choice-pill"><input type="radio" name="event_walk_mode" value="time" checked><span>Dauer</span></label>
<label class="moment-choice-pill"><input type="radio" name="event_walk_mode" value="steps"><span>Schritte</span></label>
</div>
</fieldset>
<fieldset class="moment-alcohol-field" data-moment-alcohol-field hidden>
<legend>Heute Alkohol getrunken?</legend>
<div class="moment-choice-row">
<label class="moment-choice-pill">
<input type="radio" name="event_consumed" value="1" checked>
<span>Ja</span>
</label>
<label class="moment-choice-pill">
<input type="radio" name="event_consumed" value="0">
<span>Nein</span>
</label>
</div>
</fieldset>
<div class="overlay-signal-grid overlay-signal-grid--summary-row overlay-signal-grid--moment">
<?php foreach (['event_mood' => ['Stimmung', 0], 'event_energy' => ['Energie', 0], 'event_stress' => ['Stress', 0]] as $field => [$label, $value]): ?>
<?php $metric = str_replace('event_', '', $field); ?>
<div class="overlay-signal-card overlay-signal-card--inline overlay-signal-card--moment" data-stepper data-stepper-metric="<?= e($metric) ?>">
<div>
<h3><?= e($label) ?></h3>
</div>
<div class="overlay-signal-card__control">
<div class="overlay-signal-card__ring tone-zero">
<span data-stepper-value><?= $value >= 0 ? '+' : '' ?><?= e((string) $value) ?></span>
</div>
<div class="overlay-signal-card__buttons">
<button type="button" data-stepper-minus>-</button>
<button type="button" data-stepper-plus>+</button>
</div>
<div class="signal-scale" aria-hidden="true"><span>-2</span><span>-1</span><span>0</span><span>+1</span><span>+2</span></div>
<input type="hidden" name="<?= e($field) ?>" value="<?= e((string) $value) ?>" data-stepper-input>
</div>
</div>
<?php endforeach; ?>
</div>
</form>
<form method="post" action="/" class="dashboard-modal__secondary-action" data-moment-delete-form hidden>
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="delete_event">
<input type="hidden" name="date" value="<?= e((string) $dayEntry['date']) ?>">
<input type="hidden" name="event_id" value="" data-moment-delete-id>
<button class="ghost-button" type="submit">Moment löschen</button>
</form>
</section>
</div>
<?php elseif ($dashboardView === 'week'): ?>
<section class="dashboard-range-view dashboard-range-view--week">
<header class="dashboard-range-view__hero">
<p class="eyebrow">Wochenansicht</p>
<h1><?= e($dashboardWeek['title']) ?></h1>
<h2><?= e($dashboardWeek['range']) ?></h2>
</header>
<?php $weekInsights = is_array($dashboardWeek['insights'] ?? null) ? $dashboardWeek['insights'] : []; ?>
<?php if ((int) ($weekInsights['average_steps'] ?? 0) > 0 || (int) ($weekInsights['daily_sport_minutes'] ?? 0) > 0): ?>
<section class="week-insight-card glass-panel">
<?php if ((int) ($weekInsights['average_steps'] ?? 0) > 0): ?>
<p>
Du bist in dieser Woche durchschnittlich <strong><?= e(number_format((int) $weekInsights['average_steps'], 0, ',', '.')) ?> Schritte</strong> gegangen.
<?php if (!empty($weekInsights['has_step_comparison'])): ?>
Das sind <strong><?= e(number_format(abs((int) $weekInsights['step_difference']), 0, ',', '.')) ?> Schritte <?= e((string) $weekInsights['step_direction']) ?></strong> als im vergangenen Monat.
<?php endif; ?>
</p>
<?php endif; ?>
<?php if ((int) ($weekInsights['daily_sport_minutes'] ?? 0) > 0): ?>
<p>Täglich hast du im Schnitt <strong><?= e((string) $weekInsights['daily_sport_minutes']) ?> Minuten Sport</strong> gemacht.</p>
<?php endif; ?>
</section>
<?php endif; ?>
<div class="range-period-rail range-period-rail--week">
<?php foreach (($dashboardWeek['periods'] ?? [$dashboardWeek]) as $week): ?>
<article class="range-period-panel<?= !empty($week['is_selected']) ? ' is-selected' : '' ?>">
<header class="range-period-panel__head">
<a href="/?view=week&amp;date=<?= e(rawurlencode((string) ($week['key'] ?? $dashboardDate))) ?>">
<h3><?= e((string) $week['title']) ?></h3>
<p><?= e((string) $week['range']) ?></p>
</a>
</header>
<nav class="range-score-strip range-score-strip--week glass-panel" aria-label="Tage dieser Woche">
<span class="score-scale score-scale--range" aria-hidden="true"><span>+2</span><span>+1</span><span>0</span><span>-1</span><span>-2</span></span>
<?php foreach ($week['days'] as $day): ?>
<a class="range-score-day<?= !empty($day['is_current']) ? ' is-current' : '' ?><?= empty($day['has_content']) ? ' is-empty' : '' ?>" href="/?view=week&amp;date=<?= e(rawurlencode((string) $day['date'])) ?>" title="<?= e((string) $day['weekday']) ?>">
<span class="compare-day__line<?= !empty($day['is_current']) ? ' is-primary' : '' ?> score-<?= e((string) ($day['score_level'] ?? 'empty')) ?> compare-tone-<?= e((string) ($day['line_tone'] ?? 'empty')) ?>">
<span class="compare-day__marker"></span>
</span>
<span class="range-score-day__label"><?= e((string) $day['day']) ?></span>
</a>
<?php endforeach; ?>
</nav>
</article>
<?php endforeach; ?>
</div>
<?php $weekDetailDays = array_values(array_reverse(array_filter($dashboardWeek['days'], static fn (array $day): bool => !empty($day['has_content'])))); ?>
<?php if ($weekDetailDays !== []): ?>
<div class="range-day-list">
<?php foreach ($weekDetailDays as $day): ?>
<?php
$entry = is_array($day['entry'] ?? null) ? $day['entry'] : null;
$events = $entry !== null && is_array($entry['events'] ?? null) ? $entry['events'] : [];
$summaryText = $entry !== null ? trim((string) ($entry['summary']['comment'] ?? $entry['summary_comment'] ?? $entry['note'] ?? '')) : '';
$dayTone = (string) ($day['line_tone'] ?? 'empty');
$dayImage = $entry !== null && is_string($entry['background_image_url'] ?? null) ? (string) $entry['background_image_url'] : null;
?>
<a class="range-day-card range-day-card--<?= e($dayTone) ?><?= empty($day['has_content']) ? ' is-empty' : '' ?> glass-panel" href="/?view=day&amp;date=<?= e(rawurlencode((string) $day['date'])) ?>">
<?php if ($dayImage !== null): ?>
<img class="range-day-card__image" src="<?= e($dayImage) ?>" alt="">
<?php endif; ?>
<div class="range-day-card__body">
<p class="eyebrow"><?= e((string) $day['weekday']) ?></p>
<p class="range-day-card__score">Bilanz <?= e($formatBalanceValue($entry)) ?></p>
<p class="range-day-card__summary"><?= $summaryText !== '' ? e($summaryText) : 'Noch keine Tagesbilanz.' ?></p>
<?php if ($events !== []): ?>
<ul class="range-moment-list">
<?php foreach ($events as $event): ?>
<?php if (!is_array($event)) { continue; } ?>
<?php
$eventType = (string) ($event['type'] ?? 'event');
$eventScore = signal_combo_score($event['mood'] ?? 0, $event['energy'] ?? 0, $event['stress'] ?? 0);
$eventTone = signal_value_class($eventScore);
$sportType = $eventType === 'sport' ? find_sport_type($settings, (string) ($event['sport_type_id'] ?? '')) : null;
$eventValue = (float) ($event['value'] ?? 0);
$eventValueText = $eventValue > 0 ? ($eventType === 'sleep' ? format_duration_hours($eventValue) : rtrim(rtrim(number_format($eventValue, 2, ',', '.'), '0'), ',') . ' ' . (string) ($event['unit'] ?? '')) : '';
$eventTitle = trim((string) ($event['comment'] ?? '')) !== '' ? trim((string) $event['comment']) : day_event_type_label($eventType);
$eventDetail = $eventValueText;
if ($eventType === 'sport') {
$eventTitle = (string) ($sportType['label'] ?? 'Sport');
}
if ($eventType === 'sleep' && trim((string) ($event['comment'] ?? '')) === '') {
$eventTitle = 'Schlaf';
}
?>
<li class="range-moment-list__item range-moment-list__item--<?= e($eventTone) ?>">
<span class="range-moment-list__bullet" aria-hidden="true"></span>
<span>
<strong><?= e($eventTitle) ?></strong>
<?php if ($eventDetail !== ''): ?>
<span><?= e($eventDetail) ?></span>
<?php endif; ?>
</span>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
<?php else: ?>
<section class="dashboard-range-view dashboard-range-view--month">
<header class="dashboard-range-view__hero">
<p class="eyebrow">Monatsansicht</p>
<h1><?= e($dashboardMonth['title']) ?></h1>
</header>
<div class="range-period-rail range-period-rail--month">
<?php foreach (($dashboardMonth['periods'] ?? [$dashboardMonth]) as $month): ?>
<article class="range-period-panel<?= !empty($month['is_selected']) ? ' is-selected' : '' ?>">
<header class="range-period-panel__head">
<a href="/?view=month&amp;date=<?= e(rawurlencode((string) ($month['key'] ?? $dashboardDate))) ?>">
<h3><?= e((string) $month['title']) ?></h3>
</a>
</header>
<nav class="range-score-strip range-score-strip--month glass-panel" aria-label="Tage dieses Monats">
<span class="score-scale score-scale--range score-scale--month" aria-hidden="true"><span>+2</span><span>+1</span><span>0</span><span>-1</span><span>-2</span></span>
<?php foreach ($month['days'] as $day): ?>
<a class="range-score-day<?= empty($day['has_content']) ? ' is-empty' : '' ?><?= !empty($day['is_future']) ? ' is-future' : '' ?>" href="/?view=month&amp;date=<?= e(rawurlencode((string) $day['date'])) ?>" title="<?= e((string) $day['weekday']) ?>">
<span class="compare-day__line score-<?= e((string) ($day['score_level'] ?? 'empty')) ?> compare-tone-<?= e((string) ($day['line_tone'] ?? 'empty')) ?>">
<span class="compare-day__marker"></span>
</span>
</a>
<?php endforeach; ?>
</nav>
</article>
<?php endforeach; ?>
</div>
<?php $monthDetailDays = array_values(array_filter($dashboardMonth['days'], static function (array $day): bool {
$entry = is_array($day['entry'] ?? null) ? $day['entry'] : null;
$summaryText = $entry !== null ? trim((string) ($entry['summary']['comment'] ?? $entry['summary_comment'] ?? $entry['note'] ?? '')) : '';
return !empty($day['has_content']) || $summaryText !== '';
})); ?>
<?php $monthDetailDays = array_reverse($monthDetailDays); ?>
<?php if ($monthDetailDays !== []): ?>
<div class="range-day-list range-day-list--month">
<?php foreach ($monthDetailDays as $day): ?>
<?php
$entry = is_array($day['entry'] ?? null) ? $day['entry'] : null;
$summaryText = $entry !== null ? trim((string) ($entry['summary']['comment'] ?? $entry['summary_comment'] ?? $entry['note'] ?? '')) : '';
$dayTone = (string) ($day['line_tone'] ?? 'empty');
?>
<a class="range-day-card range-day-card--summary-only range-day-card--<?= e($dayTone) ?> glass-panel" href="/?view=day&amp;date=<?= e(rawurlencode((string) $day['date'])) ?>">
<div class="range-day-card__body">
<p class="eyebrow"><?= e((string) $day['weekday']) ?></p>
<p class="range-day-card__score">Bilanz <?= e($formatBalanceValue($entry)) ?></p>
<p class="range-day-card__summary"><?= $summaryText !== '' ? e($summaryText) : 'Tagesbilanz' ?></p>
</div>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
<?php endif; ?>
<div class="dashboard-overlay" data-settings-menu-overlay hidden>
<div class="dashboard-overlay__backdrop" data-settings-menu-close></div>
<section class="dashboard-modal dashboard-modal--settings glass-panel" role="dialog" aria-modal="true">
<div class="dashboard-modal__controls">
<button class="dashboard-modal__round" type="button" data-settings-menu-close>×</button>
</div>
<h2 class="dashboard-modal__title">Einstellungen und Bereiche</h2>
<div class="settings-menu-grid">
<a class="options-menu-card" href="/options?panel=sports"><strong>Sportarten anpassen</strong><span>Eigene Sportarten und Bonuspunkte</span></a>
<a class="options-menu-card" href="/options?panel=walk"><strong>Spaziergang anpassen</strong><span>Zeit oder Schritte auswerten</span></a>
<a class="options-menu-card" href="/options?panel=sleep"><strong>Schlaf anpassen</strong><span>Optimale Schlafmenge</span></a>
<a class="options-menu-card" href="/options?panel=health"><strong>Health Import</strong><span>Apple Health automatisch übernehmen</span></a>
<a class="options-menu-card" href="/options?panel=reminders"><strong>Erinnerungen setzen</strong><span>Push und tägliche Erinnerung</span></a>
<a class="options-menu-card" href="/options?panel=ratings"><strong>Bewertungsskala ändern</strong><span>Labels und Schutzregeln</span></a>
<a class="options-menu-card" href="/options?panel=stats"><strong>Statistik</strong><span>Verlauf und Aktivität</span></a>
<?php if (!empty($authUser['is_admin'])): ?>
<a class="options-menu-card" href="/options?panel=users"><strong>Neue Nutzer anlegen</strong><span>Accounts und Adminrechte</span></a>
<?php endif; ?>
<form method="post" action="/logout" class="options-logout-form">
<?= csrf_field() ?>
<button class="options-menu-card options-menu-card--danger" type="submit"><strong>Abmelden</strong><span>Sitzung sicher beenden</span></button>
</form>
</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>
+275 -417
View File
@@ -1,449 +1,307 @@
<section class="page-grid">
<article class="glass-panel form-panel form-panel--wide">
<div class="section-head">
<div>
<p class="eyebrow">Dein Account</p>
<h3>Score und Sportarten persönlich anpassen</h3>
<p class="helper-text">Alle Einstellungen auf dieser Seite gelten nur für deinen eigenen Account und beeinflussen keine anderen Nutzer.</p>
</div>
<span class="chart-chip">Maximal <?= e(format_points((float) $maxScore)) ?> Punkte</span>
</div>
<form method="post" action="/options" class="stack-form stack-form--spacious">
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="settings">
<div class="settings-section">
<h4>Multiplikatoren</h4>
<div class="field-grid field-grid--four">
<label><span>Stimmung</span><input type="number" name="settings[scoring][mood_multiplier]" value="<?= e((string) $settings['scoring']['mood_multiplier']) ?>" min="0" max="10"></label>
<label><span>Energie</span><input type="number" name="settings[scoring][energy_multiplier]" value="<?= e((string) $settings['scoring']['energy_multiplier']) ?>" min="0" max="10"></label>
<label><span>Stress</span><input type="number" name="settings[scoring][stress_multiplier]" value="<?= e((string) $settings['scoring']['stress_multiplier']) ?>" min="0" max="10"></label>
<label><span>Schlafgefühl</span><input type="number" name="settings[scoring][sleep_feeling_multiplier]" value="<?= e((string) $settings['scoring']['sleep_feeling_multiplier']) ?>" min="0" max="10"></label>
</div>
<section class="options-shell">
<div class="options-overlay" data-options-overlay data-options-standalone="1" data-open-panel="<?= e((string) ($optionsOpenPanel ?? '')) ?>">
<div class="options-overlay__backdrop" data-options-close></div>
<section class="options-modal glass-panel" role="dialog" aria-modal="true">
<div class="options-modal__controls">
<button class="dashboard-modal__round" type="button" data-options-back></button>
<button class="dashboard-modal__round" type="button" data-options-close>×</button>
</div>
<div class="settings-section">
<div class="section-head section-head--compact">
<div class="options-menu-panel" data-options-menu>
<div class="section-head">
<div>
<h4>Tracking-Felder</h4>
<p class="helper-text">Hier steuerst du, welche Zusatzfelder auf deiner Tracking-Seite sichtbar und in die Bewertung einbezogen werden.</p>
<p class="eyebrow">Optionen</p>
<h3>Einstellungen und Bereiche</h3>
</div>
</div>
<div class="field-grid field-grid--two">
<label class="checkbox-row checkbox-row--panel">
<input type="checkbox" name="settings[tracking][pain_enabled]" value="1" <?= !empty($settings['tracking']['pain_enabled']) ? 'checked' : '' ?>>
<span>
<strong>Schmerzen aktivieren</strong>
<small>1 bedeutet keine Schmerzen, 10 starke Schmerzen. Der Wert wird wie bei Stress positiv gewichtet, wenn die Schmerzen niedrig sind.</small>
</span>
</label>
<label>
<span>Schmerzfaktor</span>
<input type="number" name="settings[scoring][pain_multiplier]" value="<?= e((string) $settings['scoring']['pain_multiplier']) ?>" min="0" max="10">
</label>
<div class="options-menu-grid">
<button class="options-menu-card" type="button" data-options-open="sports"><strong>Sportarten anpassen</strong><span>Eigene Sportarten und Bonuspunkte</span></button>
<button class="options-menu-card" type="button" data-options-open="walk"><strong>Spaziergang anpassen</strong><span>Zeit oder Schritte auswerten</span></button>
<button class="options-menu-card" type="button" data-options-open="sleep"><strong>Schlaf anpassen</strong><span>Optimale Schlafmenge markieren</span></button>
<button class="options-menu-card" type="button" data-options-open="reminders"><strong>Erinnerungen setzen</strong><span>Push und tägliche Erinnerung</span></button>
<button class="options-menu-card" type="button" data-options-open="health"><strong>Health Import</strong><span>Apple Health automatisch übernehmen</span></button>
<button class="options-menu-card" type="button" data-options-open="ratings"><strong>Bewertungsskala ändern</strong><span>Labels und Schutzregeln</span></button>
<button class="options-menu-card" type="button" data-options-open="stats"><strong>Statistik</strong><span>Verlauf und Aktivität</span></button>
<?php if (!empty($authUser['is_admin'])): ?>
<button class="options-menu-card" type="button" data-options-open="users"><strong>Neue Nutzer anlegen</strong><span>Accounts und Adminrechte</span></button>
<?php endif; ?>
<button class="options-menu-card" type="button" data-options-open="security"><strong>Sicherheit</strong><span>Passwort und Backup</span></button>
<?php if (!empty($authUser['is_admin'])): ?>
<button class="options-menu-card" type="button" data-options-open="ai"><strong>KI</strong><span>OpenAI und Zusammenfassungen</span></button>
<?php endif; ?>
<form method="post" action="/logout" class="options-logout-form">
<?= csrf_field() ?>
<button class="options-menu-card options-menu-card--danger" type="submit"><strong>Abmelden</strong><span>Sitzung sicher beenden</span></button>
</form>
</div>
</div>
<div class="settings-section">
<h4>Schlafdauerpunkte</h4>
<div class="field-grid field-grid--four">
<?php foreach ($settings['scoring']['sleep_duration_points'] as $key => $value): ?>
<label>
<span><?= e($key) ?></span>
<input type="number" name="settings[scoring][sleep_duration_points][<?= e($key) ?>]" value="<?= e((string) $value) ?>" min="0" max="20">
</label>
<?php endforeach; ?>
</div>
</div>
<div class="settings-section">
<h4>Sport-Bänder</h4>
<div class="band-grid">
<?php foreach ($settings['scoring']['sport_bands'] as $index => $band): ?>
<div class="band-card">
<label><span>Min</span><input type="number" name="settings[scoring][sport_bands][<?= e((string) $index) ?>][min]" value="<?= e((string) $band['min']) ?>"></label>
<label><span>Max</span><input type="number" name="settings[scoring][sport_bands][<?= e((string) $index) ?>][max]" value="<?= e((string) $band['max']) ?>"></label>
<label><span>Punkte</span><input type="number" name="settings[scoring][sport_bands][<?= e((string) $index) ?>][points]" value="<?= e((string) $band['points']) ?>"></label>
<div class="options-panel" data-options-panel="sports" hidden>
<h2>Sportarten anpassen</h2>
<form method="post" action="/options" class="stack-form stack-form--spacious">
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="settings">
<div class="settings-section">
<div class="section-head section-head--compact">
<div>
<h4>Sportarten und Bonuspunkte</h4>
<p class="helper-text">Diese Sportarten stehen in deinen Momenten zur Auswahl.</p>
</div>
<button class="ghost-button" type="button" data-add-sport-type>Sportart hinzufügen</button>
</div>
<?php endforeach; ?>
</div>
</div>
<div class="settings-section">
<div class="section-head section-head--compact">
<div>
<h4>Spaziergang</h4>
<p class="helper-text">Du kannst Spaziergänge pro Account entweder über Zeit oder über Schritte bewerten lassen.</p>
</div>
</div>
<label>
<span>Spaziergang auswerten nach</span>
<select name="settings[walk][mode]">
<?php foreach ($walkModeOptions as $modeValue => $modeLabel): ?>
<option value="<?= e($modeValue) ?>" <?= ($settings['walk']['mode'] ?? 'time') === $modeValue ? 'selected' : '' ?>><?= e($modeLabel) ?></option>
<?php endforeach; ?>
</select>
</label>
<?php if (($settings['walk']['mode'] ?? 'time') === 'steps'): ?>
<div class="band-card">
<h5>Schritte mit Bestwert bei 10.000</h5>
<p class="helper-text">Bei Schritten liegt der beste Wert bei 10.000. Darunter steigt die Punktzahl schrittweise an, darüber fällt sie wieder sanft ab.</p>
<p class="helper-text">Aktueller Verlauf: 0 / 3.000 / 5.000 / 7.500 / 10.000 / 12.500 / 15.000 / 20.000 Schritte</p>
</div>
<?php else: ?>
<div class="band-grid">
<?php foreach ($settings['scoring']['walk_bands'] as $index => $band): ?>
<div class="band-card">
<label><span>Min</span><input type="number" name="settings[scoring][walk_bands][<?= e((string) $index) ?>][min]" value="<?= e((string) $band['min']) ?>"></label>
<label><span>Max</span><input type="number" name="settings[scoring][walk_bands][<?= e((string) $index) ?>][max]" value="<?= e((string) $band['max']) ?>"></label>
<label><span>Punkte</span><input type="number" name="settings[scoring][walk_bands][<?= e((string) $index) ?>][points]" value="<?= e((string) $band['points']) ?>"></label>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<div class="settings-section">
<div class="section-head section-head--compact">
<div>
<h4>Sportarten und Bonuspunkte</h4>
<p class="helper-text">Lege fest, welche Sportarten nur in deinem eigenen Tracking auswählbar sind. Entfernte Sportarten kannst du darunter jederzeit wieder für deinen Account hinzufügen.</p>
</div>
<button class="ghost-button" type="button" data-add-sport-type>Sportart hinzufügen</button>
</div>
<input type="hidden" name="settings[sport_types_present]" value="1">
<?php if (!empty($sportTypePresets)): ?>
<div class="preset-list">
<?php foreach ($sportTypePresets as $preset): ?>
<button
class="preset-pill"
type="button"
data-sport-preset
data-id="<?= e($preset['id']) ?>"
data-label="<?= e($preset['label']) ?>"
data-icon="<?= e($preset['icon']) ?>"
data-location="<?= e($preset['location'] ?? '') ?>"
data-recovery-group="<?= e($preset['recovery_group']) ?>"
data-bonus-points="<?= e((string) $preset['bonus_points']) ?>"
data-allow-consecutive="<?= !empty($preset['allow_consecutive']) ? '1' : '0' ?>"
>
<img src="<?= e(sport_icon_path($preset['icon'])) ?>" alt="">
<span><?= e($preset['label']) ?></span>
</button>
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="sport-type-list" data-sport-type-list>
<?php foreach ($settings['sport_types'] as $index => $sportType): ?>
<div class="sport-type-card band-card" data-sport-type-row>
<input type="hidden" name="settings[sport_types][<?= e((string) $index) ?>][id]" value="<?= e($sportType['id']) ?>" data-name-template="settings[sport_types][__INDEX__][id]">
<div class="field-grid field-grid--four">
<label>
<span>Bezeichnung</span>
<input type="text" name="settings[sport_types][<?= e((string) $index) ?>][label]" value="<?= e($sportType['label']) ?>" data-name-template="settings[sport_types][__INDEX__][label]">
</label>
<label>
<span>Icon</span>
<select name="settings[sport_types][<?= e((string) $index) ?>][icon]" data-name-template="settings[sport_types][__INDEX__][icon]">
<?php foreach (sport_icon_options() as $iconValue => $iconLabel): ?>
<option value="<?= e($iconValue) ?>" <?= $sportType['icon'] === $iconValue ? 'selected' : '' ?>><?= e($iconLabel) ?></option>
<?php endforeach; ?>
</select>
</label>
<label>
<span>Ort</span>
<select name="settings[sport_types][<?= e((string) $index) ?>][location]" data-name-template="settings[sport_types][__INDEX__][location]">
<?php foreach ($sportLocationOptions as $locationValue => $locationLabel): ?>
<option value="<?= e($locationValue) ?>" <?= ($sportType['location'] ?? '') === $locationValue ? 'selected' : '' ?>><?= e($locationLabel) ?></option>
<?php endforeach; ?>
</select>
</label>
<label>
<span>Erholungsgruppe optional</span>
<input type="text" name="settings[sport_types][<?= e((string) $index) ?>][recovery_group]" value="<?= e($sportType['recovery_group']) ?>" data-name-template="settings[sport_types][__INDEX__][recovery_group]">
</label>
</div>
<div class="field-grid field-grid--four">
<label>
<span>Bonuspunkte</span>
<input type="number" name="settings[sport_types][<?= e((string) $index) ?>][bonus_points]" value="<?= e((string) $sportType['bonus_points']) ?>" min="0" max="20" data-name-template="settings[sport_types][__INDEX__][bonus_points]">
</label>
</div>
<label class="checkbox-row">
<input type="checkbox" name="settings[sport_types][<?= e((string) $index) ?>][allow_consecutive]" value="1" <?= !empty($sportType['allow_consecutive']) ? 'checked' : '' ?> data-name-template="settings[sport_types][__INDEX__][allow_consecutive]">
<span>Darf an aufeinanderfolgenden Tagen Bonus geben</span>
</label>
<p class="helper-text">Die Erholungsgruppe brauchst du nur, wenn mehrere Varianten dieselbe Belastung teilen sollen, zum Beispiel Krafttraining Zuhause und Krafttraining Auswärts. Wenn du nichts Besonderes einträgst, reicht die Sportart selbst.</p>
<div class="sport-type-card__actions">
<span class="sport-pill sport-pill--soft">
<img src="<?= e(sport_icon_path($sportType['icon'])) ?>" alt="">
<span><?= e($sportType['label']) ?><?= !empty($sportType['location']) ? ' · ' . e(sport_location_label((string) $sportType['location'])) : '' ?></span>
</span>
<button class="ghost-button ghost-button--small" type="button" data-remove-sport-type>Entfernen</button>
<input type="hidden" name="settings[sport_types_present]" value="1">
<?php if (!empty($sportTypePresets)): ?>
<div class="preset-list">
<?php foreach ($sportTypePresets as $preset): ?>
<button class="preset-pill" type="button" data-sport-preset data-id="<?= e($preset['id']) ?>" data-label="<?= e($preset['label']) ?>" data-icon="<?= e($preset['icon']) ?>" data-location="<?= e($preset['location'] ?? '') ?>" data-recovery-group="<?= e($preset['recovery_group']) ?>" data-bonus-points="<?= e((string) $preset['bonus_points']) ?>" data-allow-consecutive="<?= !empty($preset['allow_consecutive']) ? '1' : '0' ?>">
<img src="<?= e(sport_icon_path($preset['icon'])) ?>" alt=""><span><?= e($preset['label']) ?></span>
</button>
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="sport-type-list" data-sport-type-list>
<?php foreach ($settings['sport_types'] as $index => $sportType): ?>
<div class="sport-type-card band-card" data-sport-type-row>
<input type="hidden" name="settings[sport_types][<?= e((string) $index) ?>][id]" value="<?= e($sportType['id']) ?>" data-name-template="settings[sport_types][__INDEX__][id]">
<div class="field-grid field-grid--four">
<label><span>Bezeichnung</span><input type="text" name="settings[sport_types][<?= e((string) $index) ?>][label]" value="<?= e($sportType['label']) ?>" data-name-template="settings[sport_types][__INDEX__][label]"></label>
<label><span>Icon</span><select name="settings[sport_types][<?= e((string) $index) ?>][icon]" data-name-template="settings[sport_types][__INDEX__][icon]"><?php foreach (sport_icon_options() as $iconValue => $iconLabel): ?><option value="<?= e($iconValue) ?>" <?= $sportType['icon'] === $iconValue ? 'selected' : '' ?>><?= e($iconLabel) ?></option><?php endforeach; ?></select></label>
<label><span>Ort</span><select name="settings[sport_types][<?= e((string) $index) ?>][location]" data-name-template="settings[sport_types][__INDEX__][location]"><?php foreach ($sportLocationOptions as $locationValue => $locationLabel): ?><option value="<?= e($locationValue) ?>" <?= ($sportType['location'] ?? '') === $locationValue ? 'selected' : '' ?>><?= e($locationLabel) ?></option><?php endforeach; ?></select></label>
<label><span>Erholungsgruppe</span><input type="text" name="settings[sport_types][<?= e((string) $index) ?>][recovery_group]" value="<?= e($sportType['recovery_group']) ?>" data-name-template="settings[sport_types][__INDEX__][recovery_group]"></label>
</div>
<div class="field-grid field-grid--four"><label><span>Bonuspunkte</span><input type="number" name="settings[sport_types][<?= e((string) $index) ?>][bonus_points]" value="<?= e((string) $sportType['bonus_points']) ?>" min="0" max="20" data-name-template="settings[sport_types][__INDEX__][bonus_points]"></label></div>
<label class="checkbox-row"><input type="checkbox" name="settings[sport_types][<?= e((string) $index) ?>][allow_consecutive]" value="1" <?= !empty($sportType['allow_consecutive']) ? 'checked' : '' ?> data-name-template="settings[sport_types][__INDEX__][allow_consecutive]"><span>Darf an Folgetagen Bonus geben</span></label>
<div class="sport-type-card__actions"><button class="ghost-button ghost-button--small" type="button" data-remove-sport-type>Entfernen</button></div>
</div>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
</div>
<div class="section-actions">
<template id="sport-type-row-template">
<div class="sport-type-card band-card" data-sport-type-row>
<input type="hidden" value="" data-name-template="settings[sport_types][__INDEX__][id]">
<div class="field-grid field-grid--four">
<label><span>Bezeichnung</span><input type="text" value="" data-name-template="settings[sport_types][__INDEX__][label]"></label>
<label><span>Icon</span><select data-name-template="settings[sport_types][__INDEX__][icon]"><?php foreach (sport_icon_options() as $iconValue => $iconLabel): ?><option value="<?= e($iconValue) ?>"><?= e($iconLabel) ?></option><?php endforeach; ?></select></label>
<label><span>Ort</span><select data-name-template="settings[sport_types][__INDEX__][location]"><?php foreach ($sportLocationOptions as $locationValue => $locationLabel): ?><option value="<?= e($locationValue) ?>"><?= e($locationLabel) ?></option><?php endforeach; ?></select></label>
<label><span>Erholungsgruppe</span><input type="text" value="" data-name-template="settings[sport_types][__INDEX__][recovery_group]"></label>
</div>
<div class="field-grid field-grid--four"><label><span>Bonuspunkte</span><input type="number" value="2" min="0" max="20" data-name-template="settings[sport_types][__INDEX__][bonus_points]"></label></div>
<label class="checkbox-row"><input type="checkbox" value="1" data-name-template="settings[sport_types][__INDEX__][allow_consecutive]"><span>Darf an Folgetagen Bonus geben</span></label>
<div class="sport-type-card__actions"><button class="ghost-button ghost-button--small" type="button" data-remove-sport-type>Entfernen</button></div>
</div>
</template>
</div>
<button class="primary-button" type="submit">Sportarten speichern</button>
</div>
<template id="sport-type-row-template">
<div class="sport-type-card band-card" data-sport-type-row>
<input type="hidden" value="" data-name-template="settings[sport_types][__INDEX__][id]">
<div class="field-grid field-grid--four">
<label>
<span>Bezeichnung</span>
<input type="text" value="" placeholder="z. B. Krafttraining Zuhause" data-name-template="settings[sport_types][__INDEX__][label]">
</label>
<label>
<span>Icon</span>
<select data-name-template="settings[sport_types][__INDEX__][icon]">
<?php foreach (sport_icon_options() as $iconValue => $iconLabel): ?>
<option value="<?= e($iconValue) ?>"><?= e($iconLabel) ?></option>
<?php endforeach; ?>
</select>
</label>
<label>
<span>Ort</span>
<select data-name-template="settings[sport_types][__INDEX__][location]">
<?php foreach ($sportLocationOptions as $locationValue => $locationLabel): ?>
<option value="<?= e($locationValue) ?>"><?= e($locationLabel) ?></option>
<?php endforeach; ?>
</select>
</label>
<label>
<span>Erholungsgruppe optional</span>
<input type="text" value="" placeholder="z. B. krafttraining" data-name-template="settings[sport_types][__INDEX__][recovery_group]">
</label>
</div>
<div class="field-grid field-grid--four">
<label>
<span>Bonuspunkte</span>
<input type="number" value="2" min="0" max="20" data-name-template="settings[sport_types][__INDEX__][bonus_points]">
</label>
</div>
<label class="checkbox-row">
<input type="checkbox" value="1" data-name-template="settings[sport_types][__INDEX__][allow_consecutive]">
<span>Darf an aufeinanderfolgenden Tagen Bonus geben</span>
</label>
<p class="helper-text">Die Erholungsgruppe ist nur dann nützlich, wenn mehrere Varianten sportlich gleich belastend sind und denselben Bonusrhythmus teilen sollen.</p>
<div class="sport-type-card__actions">
<span class="sport-pill sport-pill--soft">
<img src="<?= e(sport_icon_path('run')) ?>" alt="">
<span>Neue Sportart</span>
</span>
<button class="ghost-button ghost-button--small" type="button" data-remove-sport-type>Entfernen</button>
</div>
</div>
</template>
</form>
</div>
<div class="settings-section">
<div class="section-head section-head--compact">
<div>
<h4>Erinnerungen</h4>
<p class="helper-text">Diese Erinnerungen gelten nur für deinen Account. Auf dem iPhone funktioniert Push nach dem Hinzufügen zum Home-Bildschirm.</p>
<div class="options-panel" data-options-panel="walk" hidden>
<h2>Spaziergang und Schritte</h2>
<form method="post" action="/options" class="stack-form">
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="settings">
<p class="helper-text">Spaziergänge werden als Momente angezeigt. Punkte kommen nicht mehr aus einzelnen Spaziergängen, sondern aus der täglichen Gesamtschrittzahl.</p>
<label><span>Spaziergang anzeigen nach</span><select name="settings[walk][mode]"><?php foreach ($walkModeOptions as $modeValue => $modeLabel): ?><option value="<?= e($modeValue) ?>" <?= ($settings['walk']['mode'] ?? 'time') === $modeValue ? 'selected' : '' ?>><?= e($modeLabel) ?></option><?php endforeach; ?></select></label>
<div class="settings-section">
<h4>Schritte-Bonus</h4>
<div class="field-grid field-grid--three">
<label><span>Mehr als</span><input type="number" name="settings[scoring][step_bonus][min]" value="<?= e((string) ($settings['scoring']['step_bonus']['min'] ?? 10000)) ?>" min="0" max="100000"></label>
<label><span>Bis einschließlich</span><input type="number" name="settings[scoring][step_bonus][max]" value="<?= e((string) ($settings['scoring']['step_bonus']['max'] ?? 15000)) ?>" min="0" max="100000"></label>
<label><span>Bonuspunkte</span><input type="number" name="settings[scoring][step_bonus][points]" value="<?= e((string) ($settings['scoring']['step_bonus']['points'] ?? 1)) ?>" min="0" max="20"></label>
</div>
</div>
<span class="chart-chip"><?= e((string) $pushSubscriptionCount) ?> Gerät<?= $pushSubscriptionCount === 1 ? '' : 'e' ?></span>
</div>
<div class="settings-section">
<h4>Schritte-Zielkurve</h4>
<p class="helper-text">Diese Punkte fließen als kleiner Bonus oder Malus in die Tagesbilanz ein. Positive Werte motivieren, negative Werte markieren zu wenig oder zu viel Bewegung.</p>
<div class="band-grid">
<?php foreach (($settings['scoring']['walk_step_targets'] ?? []) as $index => $target): ?>
<div class="band-card">
<label><span>Schritte</span><input type="number" name="settings[scoring][walk_step_targets][<?= e((string) $index) ?>][steps]" value="<?= e((string) ($target['steps'] ?? 0)) ?>" min="0" max="100000"></label>
<label><span>Punkte</span><input type="number" name="settings[scoring][walk_step_targets][<?= e((string) $index) ?>][points]" value="<?= e((string) ($target['points'] ?? 0)) ?>" min="-20" max="20"></label>
</div>
<?php endforeach; ?>
</div>
</div>
<button class="primary-button" type="submit">Schritte speichern</button>
</form>
</div>
<div class="field-grid field-grid--two">
<label class="checkbox-row checkbox-row--panel">
<input type="checkbox" name="settings[notifications][enabled]" value="1" <?= !empty($settings['notifications']['enabled']) ? 'checked' : '' ?>>
<span>Tägliche Push-Erinnerung aktivieren</span>
</label>
<div class="options-panel" data-options-panel="sleep" hidden>
<h2>Schlaf anpassen</h2>
<form method="post" action="/options" class="stack-form">
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="settings">
<p class="helper-text">Diese Zielmenge wird im importierten Schlafbalken als horizontale Markierung angezeigt und fließt in die automatische Stimmung/Energie/Stress-Einschätzung ein.</p>
<label><span>Optimale Schlafdauer</span><input type="number" name="settings[sleep][optimal_hours]" value="<?= e((string) ($settings['sleep']['optimal_hours'] ?? 7.0)) ?>" min="1" max="16" step="0.1"></label>
<button class="primary-button" type="submit">Schlaf speichern</button>
</form>
</div>
<label>
<span>Uhrzeit der Erinnerung</span>
<input type="time" name="settings[notifications][time]" value="<?= e((string) ($settings['notifications']['time'] ?? '20:30')) ?>">
</label>
</div>
<div class="options-panel" data-options-panel="reminders" hidden>
<h2>Erinnerungen setzen</h2>
<form method="post" action="/options" class="stack-form">
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="settings">
<div class="field-grid field-grid--two">
<label class="checkbox-row checkbox-row--panel"><input type="checkbox" name="settings[notifications][enabled]" value="1" <?= !empty($settings['notifications']['enabled']) ? 'checked' : '' ?>><span>Tägliche Push-Erinnerung aktivieren</span></label>
<label><span>Uhrzeit der Erinnerung</span><input type="time" name="settings[notifications][time]" value="<?= e((string) ($settings['notifications']['time'] ?? '20:30')) ?>"></label>
</div>
<div class="push-panel band-card" data-push-panel data-push-ready="<?= !empty($pushAvailable) && !empty($pushPublicKey) ? '1' : '0' ?>">
<div><h5>Push auf diesem Gerät</h5><p class="helper-text" data-push-status><?php if (!empty($pushAvailable) && !empty($pushPublicKey)): ?>Installiere die App auf Wunsch als PWA und aktiviere dann Push direkt auf diesem Gerät.<?php else: ?>Push ist auf diesem Server gerade noch nicht verfügbar.<?php endif; ?></p></div>
<div class="push-actions"><button class="ghost-button" type="button" data-push-enable <?= empty($pushAvailable) || empty($pushPublicKey) ? 'disabled' : '' ?>>Push aktivieren</button><button class="ghost-button" type="button" data-push-disable <?= empty($pushAvailable) || empty($pushPublicKey) ? 'disabled' : '' ?>>Auf diesem Gerät entfernen</button><button class="ghost-button" type="button" data-push-test <?= empty($pushAvailable) || empty($pushPublicKey) ? 'disabled' : '' ?>>Test senden</button></div>
</div>
<button class="primary-button" type="submit">Erinnerungen speichern</button>
</form>
</div>
<div
class="push-panel band-card"
data-push-panel
data-push-ready="<?= !empty($pushAvailable) && !empty($pushPublicKey) ? '1' : '0' ?>"
>
<div>
<h5>Push auf diesem Gerät</h5>
<p class="helper-text" data-push-status>
<?php if (!empty($pushAvailable) && !empty($pushPublicKey)): ?>
Installiere die App auf Wunsch als PWA und aktiviere dann Push direkt auf diesem Gerät.
<div class="options-panel" data-options-panel="health" hidden>
<h2>Health Import</h2>
<article class="detail-card detail-card--overlay">
<p class="eyebrow">REST-Endpunkt</p>
<div class="stack-form">
<label><span>URL in Health Auto Export</span><input type="text" value="<?= e((string) $healthImportUrl) ?>" readonly></label>
<label><span>HTTP-Header</span><input type="text" value="Authorization: Bearer <?= !empty($healthImportConfig['token_prefix']) ? e((string) $healthImportConfig['token_prefix']) . '…' : 'TOKEN' ?>" readonly></label>
</div>
<p class="helper-text">Lege in Health Auto Export zwei REST-API-Automationen an: Gesundheitsmetriken mit <code>step_count</code> und <code>sleep_analysis</code>, sowie Workouts mit JSON Version 2 und Routendaten.</p>
</article>
<?php if (!empty($healthImportToken)): ?>
<article class="detail-card detail-card--overlay health-token-card">
<p class="eyebrow">Neuer Token</p>
<label><span>Nur jetzt sichtbar</span><input type="text" value="<?= e((string) $healthImportToken) ?>" readonly></label>
<p class="helper-text">Kopiere diesen Token als Bearer-Token in Health Auto Export. Danach wird nur noch der Anfang angezeigt.</p>
</article>
<?php endif; ?>
<article class="detail-card detail-card--overlay" data-health-import-status>
<p class="eyebrow">Status</p>
<div class="health-import-progress" data-health-progress-wrap data-progress-done="<?= e((string) ($healthImportConfig['progress_done'] ?? 0)) ?>" data-progress-total="<?= e((string) ($healthImportConfig['progress_total'] ?? 0)) ?>">
<progress class="health-import-progress__bar" data-health-progress-bar max="100" value="0">0%</progress>
<p class="helper-text" data-health-progress-text>
<?php if (($healthImportConfig['last_status'] ?? '') === 'running'): ?>
Import läuft: <?= e((string) ($healthImportConfig['progress_done'] ?? 0)) ?> von <?= e((string) ($healthImportConfig['progress_total'] ?? 0)) ?> verarbeitet.
<?php elseif (!empty($healthImportConfig['last_message'])): ?>
<?= e((string) $healthImportConfig['last_message']) ?>
<?php else: ?>
Push ist auf diesem Server gerade noch nicht verfügbar.
Noch kein Import gelaufen.
<?php endif; ?>
</p>
</div>
<div class="push-actions">
<button class="ghost-button" type="button" data-push-enable <?= empty($pushAvailable) || empty($pushPublicKey) ? 'disabled' : '' ?>>Push aktivieren</button>
<button class="ghost-button" type="button" data-push-disable <?= empty($pushAvailable) || empty($pushPublicKey) ? 'disabled' : '' ?>>Auf diesem Gerät entfernen</button>
<button class="ghost-button" type="button" data-push-test <?= empty($pushAvailable) || empty($pushPublicKey) ? 'disabled' : '' ?>>Test senden</button>
</div>
</div>
<div class="section-actions">
<button class="primary-button" type="submit">Erinnerungen speichern</button>
</div>
</div>
<div class="settings-section">
<h4>Bewertungsskala</h4>
<p class="helper-text">Diese Score-Grenzen und Schutzregeln gelten ebenfalls nur für deinen eigenen Account.</p>
<div class="band-grid">
<?php foreach ($settings['ratings'] as $index => $rating): ?>
<div class="band-card">
<label><span>Label</span><input type="text" name="settings[ratings][<?= e((string) $index) ?>][label]" value="<?= e($rating['label']) ?>"></label>
<label><span>Min</span><input type="number" name="settings[ratings][<?= e((string) $index) ?>][min]" value="<?= e((string) $rating['min']) ?>"></label>
<label><span>Max</span><input type="number" name="settings[ratings][<?= e((string) $index) ?>][max]" value="<?= e((string) $rating['max']) ?>"></label>
</div>
<?php endforeach; ?>
</div>
</div>
<div class="settings-section">
<h4>Schutzregeln</h4>
<div class="band-grid">
<?php foreach ($settings['guardrails'] as $index => $guardrail): ?>
<div class="band-card">
<label><span>Stimmung max</span><input type="number" name="settings[guardrails][<?= e((string) $index) ?>][mood_max]" value="<?= e((string) $guardrail['mood_max']) ?>" min="1" max="10"></label>
<label><span>Energie max</span><input type="number" name="settings[guardrails][<?= e((string) $index) ?>][energy_max]" value="<?= e((string) ($guardrail['energy_max'] ?? '')) ?>" min="1" max="10"></label>
<label><span>Maximales Label</span><input type="text" name="settings[guardrails][<?= e((string) $index) ?>][cap_label]" value="<?= e($guardrail['cap_label']) ?>"></label>
</div>
<?php endforeach; ?>
</div>
</div>
<label>
<span>Tagebuchpunkte bei nicht-leerer Notiz</span>
<input type="number" name="settings[scoring][journal_points]" value="<?= e((string) $settings['scoring']['journal_points']) ?>" min="0" max="20">
</label>
<button class="primary-button" type="submit">Bewertung speichern</button>
</form>
</article>
<aside class="stack-column">
<article class="glass-panel detail-card">
<p class="eyebrow">Backup</p>
<h3>Eigene Einträge sichern</h3>
<p class="helper-text">Deine Tagesdateien liegen auf dem Server verschlüsselt. Beim Download bekommst du automatisch ein lesbares Backup mit allen Tagen als `.txt`-Dateien in einer ZIP-Datei. Beim Import werden diese Dateien wieder still im Hintergrund geschützt gespeichert.</p>
<form method="post" action="/options" class="stack-form">
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="export_backup">
<button class="primary-button" type="submit" <?= empty($backupAvailable) ? 'disabled' : '' ?>>Backup herunterladen</button>
<?php if (empty($backupAvailable)): ?>
<p class="helper-text">Auf diesem Server fehlt gerade die ZIP-Erweiterung für den Download.</p>
<?php endif; ?>
</form>
<form method="post" action="/options" enctype="multipart/form-data" class="stack-form">
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="import_backup">
<label>
<span>Backup importieren</span>
<input type="file" name="backup_files[]" accept=".zip,.txt" multiple>
</label>
<p class="helper-text">Du kannst ein komplettes ZIP-Backup oder einzelne Tagesdateien wie `2026-04-13.txt` importieren. Vorhandene Tage mit demselben Datum werden dabei aktualisiert.</p>
<button class="ghost-button" type="submit">Backup importieren</button>
</form>
</article>
<article class="glass-panel detail-card">
<p class="eyebrow">Sicherheit</p>
<h3>Passwort ändern</h3>
<form method="post" action="/options" class="stack-form">
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="password">
<label><span>Aktuelles Passwort</span><input type="password" name="current_password" required></label>
<label><span>Neues Passwort</span><input type="password" name="new_password" minlength="10" required></label>
<label><span>Neues Passwort wiederholen</span><input type="password" name="new_password_confirm" minlength="10" required></label>
<button class="primary-button" type="submit">Passwort aktualisieren</button>
</form>
</article>
<?php if (!empty($authUser['is_admin'])): ?>
<article class="glass-panel detail-card">
<p class="eyebrow">KI</p>
<h3>OpenAI für Zusammenfassungen</h3>
<p class="helper-text">Diese Einstellung gilt zentral für alle Nutzer. Der API-Key bleibt in der Umgebung des Servers und wird hier bewusst nicht gespeichert.</p>
<?php if (!empty($aiStatus)): ?>
<div class="user-list">
<div class="user-row">
<strong>API-Key</strong>
<span><?= !empty($aiStatus['has_api_key']) ? 'vorhanden' : 'fehlt' ?></span>
<div class="user-row"><strong>Token</strong><span data-health-token-state><?= !empty($healthImportConfig['enabled']) ? 'aktiv' : 'nicht eingerichtet' ?></span></div>
<?php if (!empty($healthImportConfig['last_import_at'])): ?>
<div class="user-row"><strong>Letzter Import</strong><span data-health-last-import><?= e(format_display_datetime((string) $healthImportConfig['last_import_at'])) ?></span></div>
<?php else: ?>
<div class="user-row"><strong>Letzter Import</strong><span data-health-last-import>-</span></div>
<?php endif; ?>
<div class="user-row"><strong>Statusmeldung</strong><span data-health-last-message><?= !empty($healthImportConfig['last_message']) ? e((string) $healthImportConfig['last_message']) : '-' ?></span></div>
</div>
<form method="post" action="/options" class="stack-form">
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="health_import_token">
<button class="primary-button" type="submit"><?= !empty($healthImportConfig['enabled']) ? 'Token neu erstellen' : 'Token erstellen' ?></button>
</form>
<?php if (!empty($healthImportConfig['enabled'])): ?>
<form method="post" action="/options" class="stack-form">
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="health_import_revoke">
<button class="ghost-button" type="submit">Token deaktivieren</button>
</form>
<?php endif; ?>
</article>
</div>
<div class="options-panel" data-options-panel="ratings" hidden>
<h2>Bewertungsskala ändern</h2>
<form method="post" action="/options" class="stack-form stack-form--spacious">
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="settings">
<div class="settings-section">
<h4>Tagesbilanz als Hauptmetrik</h4>
<p class="helper-text">Stimmung, Energie und Stress bilden die Basis. Schlaf, Schritte, Sport, Spaziergang und Notizen verschieben den Tag nur gedeckelt in eine positivere oder negativere Richtung.</p>
<div class="field-grid field-grid--three">
<label><span>Gewicht Stimmung</span><input type="number" name="settings[day_balance][mood_weight]" value="<?= e((string) ($settings['day_balance']['mood_weight'] ?? 3)) ?>" min="0" max="10"></label>
<label><span>Gewicht Energie</span><input type="number" name="settings[day_balance][energy_weight]" value="<?= e((string) ($settings['day_balance']['energy_weight'] ?? 2)) ?>" min="0" max="10"></label>
<label><span>Gewicht Stress</span><input type="number" name="settings[day_balance][stress_weight]" value="<?= e((string) ($settings['day_balance']['stress_weight'] ?? 2)) ?>" min="0" max="10"></label>
</div>
<div class="user-row">
<strong>Aktuelles Modell</strong>
<span><?= e((string) ($aiStatus['model'] ?? '')) ?></span>
</div>
<div class="user-row">
<strong>Timeout</strong>
<span><?= e((string) ($aiStatus['timeout'] ?? '')) ?> s</span>
<div class="field-grid field-grid--two">
<label><span>Max. Bonus/Malus in Stufen</span><input type="number" name="settings[day_balance][adjustment_cap]" value="<?= e((string) ($settings['day_balance']['adjustment_cap'] ?? 1.0)) ?>" min="0" max="2" step="0.1"></label>
<label><span>Punkte pro Stufenverschiebung</span><input type="number" name="settings[day_balance][points_per_step]" value="<?= e((string) ($settings['day_balance']['points_per_step'] ?? 12)) ?>" min="1" max="50"></label>
</div>
</div>
<?php endif; ?>
<form method="post" action="/options" class="stack-form">
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="ai_config">
<label><span>OpenAI-Modell</span><input type="text" name="ai[model]" value="<?= e((string) ($aiConfig['model'] ?? 'gpt-4o-mini')) ?>" required></label>
<label><span>Timeout in Sekunden</span><input type="number" name="ai[timeout]" value="<?= e((string) ($aiConfig['timeout'] ?? 25)) ?>" min="5" max="120" required></label>
<button class="primary-button" type="submit">KI-Konfiguration speichern</button>
<div class="settings-section"><h4>Bewertungsskala</h4><div class="band-grid"><?php foreach ($settings['ratings'] as $index => $rating): ?><div class="band-card"><label><span>Label</span><input type="text" name="settings[ratings][<?= e((string) $index) ?>][label]" value="<?= e($rating['label']) ?>"></label><label><span>Min</span><input type="number" name="settings[ratings][<?= e((string) $index) ?>][min]" value="<?= e((string) $rating['min']) ?>"></label><label><span>Max</span><input type="number" name="settings[ratings][<?= e((string) $index) ?>][max]" value="<?= e((string) $rating['max']) ?>"></label></div><?php endforeach; ?></div></div>
<div class="settings-section"><h4>Schutzregeln</h4><div class="band-grid"><?php foreach ($settings['guardrails'] as $index => $guardrail): ?><div class="band-card"><label><span>Stimmung max</span><input type="number" name="settings[guardrails][<?= e((string) $index) ?>][mood_max]" value="<?= e((string) $guardrail['mood_max']) ?>" min="1" max="10"></label><label><span>Energie max</span><input type="number" name="settings[guardrails][<?= e((string) $index) ?>][energy_max]" value="<?= e((string) ($guardrail['energy_max'] ?? '')) ?>" min="1" max="10"></label><label><span>Maximales Label</span><input type="text" name="settings[guardrails][<?= e((string) $index) ?>][cap_label]" value="<?= e($guardrail['cap_label']) ?>"></label></div><?php endforeach; ?></div></div>
<label><span>Tagebuchpunkte bei nicht-leerer Notiz</span><input type="number" name="settings[scoring][journal_points]" value="<?= e((string) $settings['scoring']['journal_points']) ?>" min="0" max="20"></label>
<button class="primary-button" type="submit">Bewertung speichern</button>
</form>
</article>
</div>
<article class="glass-panel detail-card">
<p class="eyebrow">Mehrere Accounts</p>
<h3>Neuen Nutzer anlegen</h3>
<form method="post" action="/options" class="stack-form">
<div class="options-panel" data-options-panel="stats" hidden>
<h2>Statistik</h2>
<form method="post" action="/options" class="stack-form stack-form--spacious stats-settings-form">
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="create_user">
<label><span>Benutzername</span><input type="text" name="username" required></label>
<label><span>Startpasswort</span><input type="password" name="password" minlength="10" required></label>
<label class="checkbox-row"><input type="checkbox" name="is_admin" value="1"><span>Als Admin anlegen</span></label>
<button class="primary-button" type="submit">Account erstellen</button>
</form>
<?php if ($users !== []): ?>
<div class="user-list">
<?php foreach ($users as $account): ?>
<div class="user-row">
<strong><?= e($account['username']) ?></strong>
<span><?= !empty($account['is_admin']) ? 'Admin' : 'Nutzer' ?></span>
</div>
<?php endforeach; ?>
<input type="hidden" name="form_name" value="settings">
<div class="settings-section">
<h4>Statistik-Darstellung</h4>
<label><span>Hauptwert anzeigen als</span><select name="settings[display][score_mode]">
<?php foreach (['scale' => '5-Stufen-Bilanz', 'percent' => 'Prozentwert', 'points' => 'Punkte'] as $mode => $label): ?>
<option value="<?= e($mode) ?>" <?= ($settings['display']['score_mode'] ?? 'scale') === $mode ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select></label>
</div>
<?php endif; ?>
</article>
<?php endif; ?>
</aside>
<button class="primary-button" type="submit">Statistik speichern</button>
</form>
<section class="stats-grid">
<article class="metric-card glass-panel"><span>Getrackte Tage</span><strong><?= e((string) $statsSummary['tracked_days']) ?></strong></article>
<article class="metric-card glass-panel"><span>Ø Score</span><strong><?= e(format_points((float) $statsSummary['average_score'])) ?></strong></article>
<article class="metric-card glass-panel"><span>Ø Bilanz</span><strong><?= ((float) $statsSummary['average_balance']) > 0 ? '+' : '' ?><?= e(format_points((float) $statsSummary['average_balance'])) ?></strong></article>
<article class="metric-card glass-panel"><span>Ø Stimmung</span><strong><?= e(format_points((float) $statsSummary['average_mood'])) ?>/10</strong></article>
<article class="metric-card glass-panel"><span>Ø Stress</span><strong><?= e(format_points((float) $statsSummary['average_stress'])) ?>/10</strong></article>
<article class="metric-card glass-panel"><span>Serie</span><strong><?= e((string) $statsSummary['streak']) ?> Tage</strong></article>
</section>
<section class="dashboard-grid dashboard-grid--embedded-stats">
<article class="glass-panel chart-card chart-card--calendar"><div class="section-head"><div><p class="eyebrow">Kalender</p><h3>Gesamtstimmung pro Tag</h3></div></div><div id="calendar-heatmap" class="calendar-heatmap" data-payload="<?= e($statsChartPayload) ?>"></div></article>
<article class="glass-panel chart-card"><div class="section-head"><div><p class="eyebrow">Trend</p><h3>Errechnete Tagesbilanz</h3></div></div><div class="line-chart" data-chart-type="line" data-series="balance" data-payload="<?= e($statsChartPayload) ?>"></div></article>
<article class="glass-panel chart-card"><div class="section-head"><div><p class="eyebrow">Belastung</p><h3>Stressverlauf</h3></div></div><div class="line-chart" data-chart-type="line" data-series="stress" data-payload="<?= e($statsChartPayload) ?>"></div></article>
<article class="glass-panel chart-card chart-card--wide"><div class="section-head"><div><p class="eyebrow">Aktivität</p><h3>Sport und Spaziergang</h3></div></div><div class="bar-chart" data-chart-type="bars" data-series="sport" data-payload="<?= e($statsChartPayload) ?>"></div></article>
</section>
</div>
<?php if (!empty($authUser['is_admin'])): ?>
<div class="options-panel" data-options-panel="users" hidden>
<h2>Neue Nutzer anlegen</h2>
<form method="post" action="/options" class="stack-form">
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="create_user">
<label><span>Benutzername</span><input type="text" name="username" required></label>
<label><span>Startpasswort</span><input type="password" name="password" minlength="10" required></label>
<label class="checkbox-row"><input type="checkbox" name="is_admin" value="1"><span>Als Admin anlegen</span></label>
<button class="primary-button" type="submit">Account erstellen</button>
</form>
<?php if ($users !== []): ?><div class="user-list"><?php foreach ($users as $account): ?><div class="user-row"><strong><?= e($account['username']) ?></strong><span><?= !empty($account['is_admin']) ? 'Admin' : 'Nutzer' ?></span></div><?php endforeach; ?></div><?php endif; ?>
</div>
<?php endif; ?>
<div class="options-panel" data-options-panel="security" hidden>
<h2>Sicherheit</h2>
<article class="detail-card detail-card--overlay">
<p class="eyebrow">Backup</p>
<form method="post" action="/options" class="stack-form"><?= csrf_field() ?><input type="hidden" name="form_name" value="export_backup"><button class="primary-button" type="submit" <?= empty($backupAvailable) ? 'disabled' : '' ?>>Backup herunterladen</button></form>
<form method="post" action="/options" enctype="multipart/form-data" class="stack-form"><?= csrf_field() ?><input type="hidden" name="form_name" value="import_backup"><label><span>Backup importieren</span><input type="file" name="backup_files[]" accept=".zip,.txt" multiple></label><button class="ghost-button" type="submit">Backup importieren</button></form>
</article>
<article class="detail-card detail-card--overlay">
<p class="eyebrow">Passwort</p>
<form method="post" action="/options" class="stack-form"><?= csrf_field() ?><input type="hidden" name="form_name" value="password"><label><span>Aktuelles Passwort</span><input type="password" name="current_password" required></label><label><span>Neues Passwort</span><input type="password" name="new_password" minlength="10" required></label><label><span>Neues Passwort wiederholen</span><input type="password" name="new_password_confirm" minlength="10" required></label><button class="primary-button" type="submit">Passwort aktualisieren</button></form>
</article>
</div>
<?php if (!empty($authUser['is_admin'])): ?>
<div class="options-panel" data-options-panel="ai" hidden>
<h2>KI</h2>
<?php if (!empty($aiStatus)): ?><div class="user-list"><div class="user-row"><strong>API-Key</strong><span><?= !empty($aiStatus['has_api_key']) ? 'vorhanden' : 'fehlt' ?></span></div><div class="user-row"><strong>Aktuelles Modell</strong><span><?= e((string) ($aiStatus['model'] ?? '')) ?></span></div><div class="user-row"><strong>Timeout</strong><span><?= e((string) ($aiStatus['timeout'] ?? '')) ?> s</span></div></div><?php endif; ?>
<form method="post" action="/options" class="stack-form"><?= csrf_field() ?><input type="hidden" name="form_name" value="ai_config"><label><span>OpenAI-Modell</span><input type="text" name="ai[model]" value="<?= e((string) ($aiConfig['model'] ?? 'gpt-4o-mini')) ?>" required></label><label><span>Timeout in Sekunden</span><input type="number" name="ai[timeout]" value="<?= e((string) ($aiConfig['timeout'] ?? 25)) ?>" min="5" max="120" required></label><button class="primary-button" type="submit">KI-Konfiguration speichern</button></form>
</div>
<?php endif; ?>
</section>
</div>
</section>
+23 -15
View File
@@ -6,7 +6,7 @@
<h3><?= e(format_display_date($entry['date'])) ?></h3>
</div>
<div class="section-head__actions">
<a class="ghost-link" href="/archive?date=<?= e(rawurlencode($entry['date'])) ?>">Im Archiv ansehen</a>
<a class="ghost-link" href="/archive?view=days&amp;date=<?= e(rawurlencode($entry['date'])) ?>">Im Archiv ansehen</a>
<a class="ghost-link" href="/track?date=<?= e(today()) ?>">Heute</a>
</div>
</div>
@@ -45,23 +45,31 @@
<input type="range" min="1" max="10" step="1" name="pain" value="<?= e((string) $entry['pain']) ?>">
</label>
<label class="checkbox-row checkbox-row--panel checkbox-row--tall">
<input type="checkbox" name="alcohol" value="1" <?= !empty($entry['alcohol']) ? 'checked' : '' ?>>
<span>
<strong>Alkohol</strong>
<small>Wenn du heute Alkohol getrunken hast, werden 5 Punkte abgezogen.</small>
</span>
</label>
<div class="sport-choice-field sport-choice-field--single">
<div class="sport-choice-list sport-choice-list--single">
<label class="sport-choice">
<input type="checkbox" name="alcohol" value="1" <?= !empty($entry['alcohol']) ? 'checked' : '' ?>>
<span class="sport-choice__card sport-choice__card--toggle">
<img src="<?= e(icon_path('alcohol')) ?>" alt="">
<strong>Alkohol</strong>Heute was getrunken?
</span>
</label>
</div>
</div>
</div>
<?php else: ?>
<div class="field-grid field-grid--single">
<label class="checkbox-row checkbox-row--panel">
<input type="checkbox" name="alcohol" value="1" <?= !empty($entry['alcohol']) ? 'checked' : '' ?>>
<span>
<strong>Alkohol</strong>
<small>Wenn du heute Alkohol getrunken hast, werden 5 Punkte abgezogen.</small>
</span>
</label>
<div class="sport-choice-field sport-choice-field--single">
<div class="sport-choice-list sport-choice-list--single">
<label class="sport-choice">
<input type="checkbox" name="alcohol" value="1" <?= !empty($entry['alcohol']) ? 'checked' : '' ?>>
<span class="sport-choice__card sport-choice__card--toggle">
<img src="<?= e(icon_path('alcohol')) ?>" alt="">
<strong>Alkohol</strong>Heute was getrunken?
</span>
</label>
</div>
</div>
</div>
<?php endif; ?>