Compare commits
28 Commits
V1.2.2
..
2932cbb5b2
| Author | SHA1 | Date | |
|---|---|---|---|
| 2932cbb5b2 | |||
| 9f1bb2c351 | |||
| 3a467aca38 | |||
| f5daff1a04 | |||
| a087eb508b | |||
| 2047cae61c | |||
| 1dd5339a46 | |||
| 0df5983f65 | |||
| 7c9f464686 | |||
| abcd35714f | |||
| 0fb8adbb14 | |||
| 3b2c36c849 | |||
| adaff22651 | |||
| 36a15f3ed4 | |||
| 6a5852654b | |||
| 3e497a8047 | |||
| 59c7d89e81 | |||
| 176b07f202 | |||
| d8636f6c41 | |||
| a555f552c2 | |||
| e00cd66fbe | |||
| e36f27da4a | |||
| bc6e850afb | |||
| b8a96e96ef | |||
| 48df9831fd | |||
| 83b4686b6f | |||
| e953d0fd42 | |||
| ab1d8bc677 |
@@ -2,6 +2,10 @@ Options -Indexes
|
|||||||
DirectoryIndex index.php
|
DirectoryIndex index.php
|
||||||
AddType application/manifest+json .webmanifest
|
AddType application/manifest+json .webmanifest
|
||||||
|
|
||||||
|
<IfModule mod_setenvif.c>
|
||||||
|
SetEnvIf Authorization "(.+)" HTTP_AUTHORIZATION=$1
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
<IfModule mod_rewrite.c>
|
<IfModule mod_rewrite.c>
|
||||||
RewriteEngine On
|
RewriteEngine On
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
+3238
-1
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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
File diff suppressed because it is too large
Load Diff
+2197
-43
File diff suppressed because it is too large
Load Diff
@@ -96,6 +96,10 @@ final class EntryRepository
|
|||||||
|
|
||||||
private function parse(string $content, string $fallbackDate): ?array
|
private function parse(string $content, string $fallbackDate): ?array
|
||||||
{
|
{
|
||||||
|
if (str_contains($content, '<!-- mood-tracker:v3 -->')) {
|
||||||
|
return $this->parseV3($content, $fallbackDate);
|
||||||
|
}
|
||||||
|
|
||||||
$sportTypes = [];
|
$sportTypes = [];
|
||||||
$sportTypesRaw = (string) ($this->extract('/^- Sportarten:\s*(.*)$/mu', $content) ?? '');
|
$sportTypesRaw = (string) ($this->extract('/^- Sportarten:\s*(.*)$/mu', $content) ?? '');
|
||||||
if ($sportTypesRaw !== '') {
|
if ($sportTypesRaw !== '') {
|
||||||
@@ -134,6 +138,19 @@ final class EntryRepository
|
|||||||
'walk_steps' => $walkMode === 'steps' ? $walkValue : 0,
|
'walk_steps' => $walkMode === 'steps' ? $walkValue : 0,
|
||||||
'alcohol' => in_array($alcoholRaw, ['ja', 'yes', 'true', '1'], true),
|
'alcohol' => in_array($alcoholRaw, ['ja', 'yes', 'true', '1'], true),
|
||||||
'note' => $this->extractNote($content),
|
'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;
|
return $entry;
|
||||||
@@ -163,18 +180,73 @@ final class EntryRepository
|
|||||||
|
|
||||||
private function toMarkdown(string $username, string $date, array $entry, array $evaluation): string
|
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'] ?? [];
|
$sportTypes = $evaluation['sport_types'] ?? [];
|
||||||
$sportTypeValues = array_map(
|
$sportTypeValues = array_map(
|
||||||
static fn (array $type): string => (string) $type['label'] . ' [' . (string) $type['id'] . ']',
|
static fn (array $type): string => (string) $type['label'] . ' [' . (string) $type['id'] . ']',
|
||||||
array_filter($sportTypes, 'is_array')
|
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 = [
|
$lines = [
|
||||||
'<!-- mood-tracker:v2 -->',
|
'<!-- mood-tracker:v3 -->',
|
||||||
'# Stimmungstracker',
|
'# Stimmungstracker Tag',
|
||||||
'Datum: ' . $date,
|
'Datum: ' . $date,
|
||||||
'Benutzer: ' . normalize_username($username),
|
'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',
|
'## Werte',
|
||||||
'- Stimmung: ' . $entry['mood'],
|
'- Stimmung: ' . $entry['mood'],
|
||||||
'- Energie: ' . $entry['energy'],
|
'- Energie: ' . $entry['energy'],
|
||||||
@@ -202,14 +274,158 @@ final class EntryRepository
|
|||||||
'- Sport: ' . format_points((float) $evaluation['components']['sport_minutes']),
|
'- Sport: ' . format_points((float) $evaluation['components']['sport_minutes']),
|
||||||
'- Sportbonus: ' . format_points((float) ($evaluation['components']['sport_bonus'] ?? 0)),
|
'- Sportbonus: ' . format_points((float) ($evaluation['components']['sport_bonus'] ?? 0)),
|
||||||
'- Spaziergang: ' . format_points((float) $evaluation['components']['walk_minutes']),
|
'- 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)),
|
'- Alkohol: ' . format_points((float) ($evaluation['components']['alcohol'] ?? 0)),
|
||||||
'- Notiz: ' . format_points((float) $evaluation['components']['note']),
|
'- Notiz: ' . format_points((float) $evaluation['components']['note']),
|
||||||
'',
|
'',
|
||||||
'## Notiz',
|
'## Notiz',
|
||||||
trim((string) $entry['note']),
|
trim((string) ($summary['comment'] ?? $entry['note'] ?? '')),
|
||||||
'',
|
'',
|
||||||
];
|
];
|
||||||
|
|
||||||
return implode("\n", $lines);
|
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
@@ -6,25 +6,49 @@ final class ScoringService
|
|||||||
{
|
{
|
||||||
public function normalize(array $input): array
|
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 [
|
return [
|
||||||
'date' => $input['date'] ?? today(),
|
'date' => $input['date'] ?? today(),
|
||||||
'mood' => max(1, min(10, (int) ($input['mood'] ?? 5))),
|
'mood' => max(1, min(10, (int) ($hasSummaryInput ? $derived['mood'] : ($input['mood'] ?? $derived['mood'])))),
|
||||||
'energy' => max(1, min(10, (int) ($input['energy'] ?? 5))),
|
'energy' => max(1, min(10, (int) ($hasSummaryInput ? $derived['energy'] : ($input['energy'] ?? $derived['energy'])))),
|
||||||
'stress' => max(1, min(10, (int) ($input['stress'] ?? 5))),
|
'stress' => max(1, min(10, (int) ($hasSummaryInput ? $derived['stress'] : ($input['stress'] ?? $derived['stress'])))),
|
||||||
'pain' => max(1, min(10, (int) ($input['pain'] ?? 1))),
|
'pain' => max(1, min(10, (int) ($input['pain'] ?? 1))),
|
||||||
'pain_enabled' => $this->normalizeBoolean($input['pain_enabled'] ?? false),
|
'pain_enabled' => $this->normalizeBoolean($input['pain_enabled'] ?? false),
|
||||||
'sleep_hours' => max(0, min(24, (float) ($input['sleep_hours'] ?? 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) ($input['sleep_feeling'] ?? 3))),
|
'sleep_feeling' => max(1, min(5, (int) ($hasSummaryInput ? $derived['sleep_feeling'] : ($input['sleep_feeling'] ?? $derived['sleep_feeling'])))),
|
||||||
'sport_minutes' => max(0, min(1440, (int) ($input['sport_minutes'] ?? 0))),
|
'sport_minutes' => max(0, min(1440, (int) ($hasEventInput ? $derived['sport_minutes'] : ($input['sport_minutes'] ?? $derived['sport_minutes'])))),
|
||||||
'sport_type' => $sportTypes[0] ?? '',
|
'sport_type' => $sportTypes[0] ?? '',
|
||||||
'sport_types' => $sportTypes,
|
'sport_types' => $sportTypes,
|
||||||
'walk_mode' => $this->normalizeWalkMode((string) ($input['walk_mode'] ?? ((isset($input['walk_steps']) && !isset($input['walk_minutes'])) ? 'steps' : 'time'))),
|
'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) ($input['walk_minutes'] ?? 0))),
|
'walk_minutes' => max(0, min(1440, (int) ($hasEventInput ? $derived['walk_minutes'] : ($input['walk_minutes'] ?? $derived['walk_minutes'])))),
|
||||||
'walk_steps' => max(0, min(50000, (int) ($input['walk_steps'] ?? 0))),
|
'walk_steps' => max(0, min(50000, (int) ($hasEventInput ? $derived['walk_steps'] : ($input['walk_steps'] ?? $derived['walk_steps'])))),
|
||||||
'alcohol' => $this->normalizeBoolean($input['alcohol'] ?? false),
|
'alcohol' => $this->normalizeBoolean($hasSummaryInput ? $derived['alcohol'] : ($input['alcohol'] ?? $derived['alcohol'])),
|
||||||
'note' => trim((string) ($input['note'] ?? '')),
|
'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'] ?? []);
|
$ratings = $this->sortedRatings($settings['ratings'] ?? []);
|
||||||
$sportTypes = find_sport_types($settings, $entry['sport_types']);
|
$sportTypes = find_sport_types($settings, $entry['sport_types']);
|
||||||
$sportBonus = $this->sportBonusPoints($entry, $previousEntry, $settings, $sportTypes);
|
$sportBonus = $this->sportBonusPoints($entry, $previousEntry, $settings, $sportTypes);
|
||||||
|
$eventSignalPoints = $this->eventSignalPoints($entry['events']);
|
||||||
$painEnabled = !empty($settings['tracking']['pain_enabled']);
|
$painEnabled = !empty($settings['tracking']['pain_enabled']);
|
||||||
|
|
||||||
$components = [
|
$components = [
|
||||||
@@ -47,6 +72,9 @@ final class ScoringService
|
|||||||
'sport_minutes' => $this->bandPoints((int) $entry['sport_minutes'], $scoring['sport_bands']),
|
'sport_minutes' => $this->bandPoints((int) $entry['sport_minutes'], $scoring['sport_bands']),
|
||||||
'sport_bonus' => $sportBonus,
|
'sport_bonus' => $sportBonus,
|
||||||
'walk_minutes' => $this->walkPoints($entry, $settings),
|
'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,
|
'alcohol' => !empty($entry['alcohol']) ? ((float) abs((float) ($scoring['alcohol_penalty'] ?? 5)) * -1) : 0.0,
|
||||||
'note' => $entry['note'] === '' ? 0 : (float) $scoring['journal_points'],
|
'note' => $entry['note'] === '' ? 0 : (float) $scoring['journal_points'],
|
||||||
];
|
];
|
||||||
@@ -66,6 +94,9 @@ final class ScoringService
|
|||||||
$this->maxBandPoints($scoring['sport_bands']) +
|
$this->maxBandPoints($scoring['sport_bands']) +
|
||||||
$this->maxSportBonusPoints($settings) +
|
$this->maxSportBonusPoints($settings) +
|
||||||
$this->maxWalkPoints($entry, $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'],
|
(float) $scoring['journal_points'],
|
||||||
1
|
1
|
||||||
);
|
);
|
||||||
@@ -95,11 +126,72 @@ final class ScoringService
|
|||||||
'guardrail' => $guardrail,
|
'guardrail' => $guardrail,
|
||||||
'sentiment' => $this->sentimentForLabel($label, $ratings),
|
'sentiment' => $this->sentimentForLabel($label, $ratings),
|
||||||
'percentage' => $maxTotal > 0 ? round(($total / $maxTotal) * 100, 1) : 0,
|
'percentage' => $maxTotal > 0 ? round(($total / $maxTotal) * 100, 1) : 0,
|
||||||
|
'balance' => $this->dayBalance($entry, $components, $settings),
|
||||||
'sport_type' => $sportTypes[0] ?? null,
|
'sport_type' => $sportTypes[0] ?? null,
|
||||||
'sport_types' => $sportTypes,
|
'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
|
private function sleepDurationPoints(float $hours, array $points): float
|
||||||
{
|
{
|
||||||
if ($hours < 4) {
|
if ($hours < 4) {
|
||||||
@@ -170,6 +262,19 @@ final class ScoringService
|
|||||||
return $this->bandPoints((int) $entry['walk_minutes'], $scoring['walk_bands'] ?? []);
|
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
|
private function maxWalkPoints(array $entry, array $settings): float
|
||||||
{
|
{
|
||||||
$scoring = $settings['scoring'] ?? [];
|
$scoring = $settings['scoring'] ?? [];
|
||||||
@@ -186,6 +291,20 @@ final class ScoringService
|
|||||||
return $this->maxBandPoints($scoring['walk_bands'] ?? []);
|
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
|
private function stepTargetPoints(int $steps, array $targets): float
|
||||||
{
|
{
|
||||||
if ($targets === []) {
|
if ($targets === []) {
|
||||||
@@ -304,6 +423,205 @@ final class ScoringService
|
|||||||
return $total;
|
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
|
private function sortedRatings(array $ratings): array
|
||||||
{
|
{
|
||||||
usort($ratings, static fn (array $a, array $b): int => ((int) $a['min']) <=> ((int) $b['min']));
|
usort($ratings, static fn (array $a, array $b): int => ((int) $a['min']) <=> ((int) $b['min']));
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ final class UserRepository
|
|||||||
|
|
||||||
public function verify(string $username, string $password): ?array
|
public function verify(string $username, string $password): ?array
|
||||||
{
|
{
|
||||||
$user = $this->find($username);
|
$user = $this->find($username) ?? [];
|
||||||
|
|
||||||
if ($user === null) {
|
if ($user === null) {
|
||||||
return null;
|
return null;
|
||||||
@@ -51,6 +51,257 @@ final class UserRepository
|
|||||||
return $user;
|
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
|
public function create(string $username, string $password, bool $isAdmin = false): array
|
||||||
{
|
{
|
||||||
$normalized = normalize_username($username);
|
$normalized = normalize_username($username);
|
||||||
|
|||||||
+60
-1
@@ -11,7 +11,7 @@ final class Auth
|
|||||||
public function check(): bool
|
public function check(): bool
|
||||||
{
|
{
|
||||||
if (!isset($_SESSION['user']) || !is_array($_SESSION['user'])) {
|
if (!isset($_SESSION['user']) || !is_array($_SESSION['user'])) {
|
||||||
return false;
|
return $this->attemptRememberedLogin();
|
||||||
}
|
}
|
||||||
|
|
||||||
$username = $_SESSION['user']['username'] ?? null;
|
$username = $_SESSION['user']['username'] ?? null;
|
||||||
@@ -62,17 +62,76 @@ final class Auth
|
|||||||
$_SESSION['remember_me'] = $remember;
|
$_SESSION['remember_me'] = $remember;
|
||||||
|
|
||||||
if ($remember) {
|
if ($remember) {
|
||||||
|
$this->issueRememberCookie($user['username']);
|
||||||
setcookie(session_name(), session_id(), session_cookie_options_for(time() + remember_me_lifetime()));
|
setcookie(session_name(), session_id(), session_cookie_options_for(time() + remember_me_lifetime()));
|
||||||
} else {
|
} else {
|
||||||
|
$this->users->clearRememberToken($user['username']);
|
||||||
|
$this->clearRememberCookie();
|
||||||
setcookie(session_name(), session_id(), session_cookie_options_for());
|
setcookie(session_name(), session_id(), session_cookie_options_for());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function logout(): void
|
public function logout(): void
|
||||||
{
|
{
|
||||||
|
$username = $_SESSION['user']['username'] ?? null;
|
||||||
|
|
||||||
|
if (is_string($username) && $username !== '') {
|
||||||
|
$this->users->clearRememberToken($username);
|
||||||
|
}
|
||||||
|
|
||||||
unset($_SESSION['user']);
|
unset($_SESSION['user']);
|
||||||
unset($_SESSION['remember_me']);
|
unset($_SESSION['remember_me']);
|
||||||
|
$this->clearRememberCookie();
|
||||||
setcookie(session_name(), '', session_cookie_options_for(time() - 3600));
|
setcookie(session_name(), '', session_cookie_options_for(time() - 3600));
|
||||||
session_regenerate_id(true);
|
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,6 +19,19 @@ final class Defaults
|
|||||||
'walk' => [
|
'walk' => [
|
||||||
'mode' => 'time',
|
'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' => [
|
'tracking' => [
|
||||||
'pain_enabled' => false,
|
'pain_enabled' => false,
|
||||||
],
|
],
|
||||||
@@ -88,7 +101,7 @@ final class Defaults
|
|||||||
],
|
],
|
||||||
[
|
[
|
||||||
'id' => 'rowing',
|
'id' => 'rowing',
|
||||||
'label' => 'Rudern',
|
'label' => 'Rudergerät',
|
||||||
'icon' => 'row',
|
'icon' => 'row',
|
||||||
'location' => '',
|
'location' => '',
|
||||||
'recovery_group' => 'rudern',
|
'recovery_group' => 'rudern',
|
||||||
@@ -152,6 +165,11 @@ final class Defaults
|
|||||||
['steps' => 15000, 'points' => 4],
|
['steps' => 15000, 'points' => 4],
|
||||||
['steps' => 20000, 'points' => 0],
|
['steps' => 20000, 'points' => 0],
|
||||||
],
|
],
|
||||||
|
'step_bonus' => [
|
||||||
|
'min' => 10000,
|
||||||
|
'max' => 15000,
|
||||||
|
'points' => 1,
|
||||||
|
],
|
||||||
'journal_points' => 2,
|
'journal_points' => 2,
|
||||||
'alcohol_penalty' => 5,
|
'alcohol_penalty' => 5,
|
||||||
],
|
],
|
||||||
|
|||||||
+213
@@ -115,6 +115,23 @@ function format_points(float $value): string
|
|||||||
return number_format($rounded, 1, ',', '.');
|
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
|
function normalize_username(string $username): string
|
||||||
{
|
{
|
||||||
return strtolower(trim($username));
|
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;
|
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
|
function format_display_datetime(string $value): string
|
||||||
{
|
{
|
||||||
try {
|
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');
|
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
|
function iso_week_key(string $date): string
|
||||||
{
|
{
|
||||||
$current = DateTimeImmutable::createFromFormat('Y-m-d', $date);
|
$current = DateTimeImmutable::createFromFormat('Y-m-d', $date);
|
||||||
@@ -313,6 +352,11 @@ function remember_me_lifetime(): int
|
|||||||
return 60 * 60 * 24 * 30;
|
return 60 * 60 * 24 * 30;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function remember_cookie_name(): string
|
||||||
|
{
|
||||||
|
return 'mood_remember';
|
||||||
|
}
|
||||||
|
|
||||||
function session_cookie_params_for(int $lifetime = 0): array
|
function session_cookie_params_for(int $lifetime = 0): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
@@ -612,3 +656,172 @@ function find_sport_types(array $settings, array $ids): array
|
|||||||
|
|
||||||
return $types;
|
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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
+34
-28
@@ -3,20 +3,23 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
$brandSubtitle = match ($page) {
|
$brandSubtitle = match ($page) {
|
||||||
'dashboard' => 'Statistiken und Verlauf',
|
'dashboard' => '',
|
||||||
'track' => 'Tag erfassen und bewerten',
|
'track' => 'Tag erfassen und bewerten',
|
||||||
'archive' => 'Rückblick auf vergangene Tage',
|
'archive' => '',
|
||||||
'options' => 'Logik, Erinnerungen, Sicherheit und Accounts',
|
'options' => 'Logik, Erinnerungen, Sicherheit und Accounts',
|
||||||
'login' => 'Geschützter Zugang',
|
'login' => 'Geschützter Zugang',
|
||||||
'setup' => 'Erstkonfiguration',
|
'setup' => 'Erstkonfiguration',
|
||||||
default => 'Stimmungstracker',
|
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>
|
<!DOCTYPE html>
|
||||||
<html lang="de">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<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="theme-color" content="#0b1e2e">
|
||||||
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet">
|
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet">
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<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="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="apple-touch-icon" sizes="180x180" href="/assets/branding/apple-touch-icon.png?v=20260412">
|
||||||
<link rel="manifest" href="/manifest.webmanifest">
|
<link rel="manifest" href="/manifest.webmanifest">
|
||||||
<link rel="stylesheet" href="/assets/css/app.css">
|
<?php if (($page ?? '') === 'dashboard' && ($dashboardView ?? '') === 'day'): ?>
|
||||||
<script defer src="/assets/js/app.js"></script>
|
<link rel="prefetch" href="/?view=day&date=<?= e(rawurlencode((string) ($dashboardPrevDate ?? shift_date(today(), -1)))) ?>">
|
||||||
|
<link rel="prefetch" href="/?view=day&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>
|
</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-one"></div>
|
||||||
<div class="aurora aurora-two"></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="pull-refresh-indicator glass-panel" data-pull-refresh-indicator aria-hidden="true">Zum Aktualisieren ziehen</div>
|
||||||
<div class="shell">
|
<div class="shell<?= $immersiveDashboard ? ' shell--dashboard' : '' ?>">
|
||||||
<?php if ($authUser !== null): ?>
|
<?php if ($authUser !== null && !$immersiveDashboard): ?>
|
||||||
<aside class="sidebar glass-panel">
|
<aside class="sidebar glass-panel">
|
||||||
<div class="brand-block">
|
<div class="brand-block">
|
||||||
<div class="brand-mark">
|
<div class="brand-mark">
|
||||||
@@ -56,11 +63,7 @@ $brandSubtitle = match ($page) {
|
|||||||
<nav class="main-nav" aria-label="Hauptnavigation">
|
<nav class="main-nav" aria-label="Hauptnavigation">
|
||||||
<a class="<?= is_active_path('/') ? 'active' : '' ?>" href="/">
|
<a class="<?= is_active_path('/') ? 'active' : '' ?>" href="/">
|
||||||
<img class="nav-icon" src="<?= e(icon_path('dashboard')) ?>" alt="">
|
<img class="nav-icon" src="<?= e(icon_path('dashboard')) ?>" alt="">
|
||||||
<span>Dashboard</span>
|
<span>Start</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>
|
||||||
<a class="<?= is_active_path('/archive') ? 'active' : '' ?>" href="/archive">
|
<a class="<?= is_active_path('/archive') ? 'active' : '' ?>" href="/archive">
|
||||||
<img class="nav-icon" src="<?= e(icon_path('archive')) ?>" alt="">
|
<img class="nav-icon" src="<?= e(icon_path('archive')) ?>" alt="">
|
||||||
@@ -86,10 +89,12 @@ $brandSubtitle = match ($page) {
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<main class="content">
|
<main class="content">
|
||||||
<?php if ($authUser !== null): ?>
|
<?php if ($authUser !== null && !$immersiveDashboard): ?>
|
||||||
<header class="topbar glass-panel">
|
<header class="topbar glass-panel">
|
||||||
<div>
|
<div>
|
||||||
|
<?php if ($brandSubtitle !== ''): ?>
|
||||||
<p class="eyebrow"><?= e($brandSubtitle) ?></p>
|
<p class="eyebrow"><?= e($brandSubtitle) ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
<h2><?= e($pageTitle) ?></h2>
|
<h2><?= e($pageTitle) ?></h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="topbar__meta">
|
<div class="topbar__meta">
|
||||||
@@ -113,28 +118,29 @@ $brandSubtitle = match ($page) {
|
|||||||
|
|
||||||
<?= $content ?>
|
<?= $content ?>
|
||||||
|
|
||||||
|
<?php if (!$immersiveDashboard): ?>
|
||||||
<footer class="site-footer glass-panel">
|
<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://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>
|
<a class="site-footer__link" href="https://hnz.io" target="_blank" rel="noreferrer">(c) 2026 @hnz.io</a>
|
||||||
</footer>
|
</footer>
|
||||||
|
<?php endif; ?>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<?php if ($authUser !== null): ?>
|
<?php if ($authUser !== null): ?>
|
||||||
<nav class="mobile-nav glass-panel" aria-label="Mobile Hauptnavigation">
|
<nav class="ios-tabbar" aria-label="Mobile Navigation">
|
||||||
<a class="<?= is_active_path('/') ? 'active' : '' ?>" href="/">
|
<a class="<?= $page === 'dashboard' && ($dashboardView ?? 'day') === 'day' ? 'active' : '' ?>" href="/?view=day&date=<?= e(rawurlencode(today())) ?>">
|
||||||
<img class="nav-icon" src="<?= e(icon_path('dashboard')) ?>" alt="">
|
<span class="ios-tabbar__icon" aria-hidden="true"></span>
|
||||||
<span>Dashboard</span>
|
<span>Heute</span>
|
||||||
</a>
|
</a>
|
||||||
<a class="<?= is_active_path('/track') ? 'active' : '' ?>" href="/track">
|
<a class="<?= $page === 'dashboard' && ($dashboardView ?? '') === 'week' ? 'active' : '' ?>" href="/?view=week&date=<?= e(rawurlencode(today())) ?>">
|
||||||
<img class="nav-icon" src="<?= e(icon_path('track')) ?>" alt="">
|
<span class="ios-tabbar__icon" aria-hidden="true"></span>
|
||||||
<span>Tracken</span>
|
<span>Woche</span>
|
||||||
</a>
|
</a>
|
||||||
<a class="<?= is_active_path('/archive') ? 'active' : '' ?>" href="/archive">
|
<a class="<?= $page === 'dashboard' && ($dashboardView ?? '') === 'month' ? 'active' : '' ?>" href="/?view=month&date=<?= e(rawurlencode(today())) ?>">
|
||||||
<img class="nav-icon" src="<?= e(icon_path('archive')) ?>" alt="">
|
<span class="ios-tabbar__icon" aria-hidden="true"></span>
|
||||||
<span>Archiv</span>
|
<span>Monat</span>
|
||||||
</a>
|
</a>
|
||||||
<a class="<?= is_active_path('/options') ? 'active' : '' ?>" href="/options">
|
<a class="<?= $page === 'options' ? 'active' : '' ?>" href="/options">
|
||||||
<img class="nav-icon" src="<?= e(icon_path('options')) ?>" alt="">
|
<span class="ios-tabbar__icon" aria-hidden="true"></span>
|
||||||
<span>Optionen</span>
|
<span>Optionen</span>
|
||||||
</a>
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
+250
-184
@@ -1,190 +1,169 @@
|
|||||||
<section class="page-grid">
|
<?php
|
||||||
<article class="glass-panel archive-list">
|
$baseParams = ['view' => $archiveView];
|
||||||
<div class="section-head">
|
if ($archiveFilterMonth !== '') {
|
||||||
<div>
|
$baseParams['filter_month'] = $archiveFilterMonth;
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section class="archive-summary-section">
|
$archiveUrl = static function (array $params = []) use ($baseParams): string {
|
||||||
<div class="section-head section-head--compact">
|
$query = array_filter(array_merge($baseParams, $params), static fn (mixed $value): bool => $value !== null && $value !== '');
|
||||||
<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>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<?php if ($monthlyArchive === []): ?>
|
return $query === [] ? '/archive' : '/archive?' . http_build_query($query);
|
||||||
<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>
|
$detailType = $selectedEntry !== null
|
||||||
<p class="helper-text"><?= e((string) $month['weekly_summary_count']) ?> KI-Wochenzusammenfassungen im Monat verfügbar</p>
|
? 'day'
|
||||||
|
: ($selectedWeek !== null
|
||||||
|
? 'week'
|
||||||
|
: ($selectedMonth !== null ? 'month' : null));
|
||||||
|
|
||||||
<?php if (!empty($month['summary'])): ?>
|
$detailOpen = $detailType !== null;
|
||||||
<p class="helper-text">Erstellt am <?= e(format_display_datetime((string) $month['summary']['created_at'])) ?></p>
|
?>
|
||||||
<?php else: ?>
|
|
||||||
<p class="helper-text">Mindestens 2 KI-Wochenzusammenfassungen nötig.</p>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<div class="archive-item__actions archive-item__actions--stack">
|
<section class="archive-page">
|
||||||
<?php if (!empty($month['summary'])): ?>
|
<article class="glass-panel archive-shell">
|
||||||
<a class="ghost-link archive-action" href="/archive?summary_kind=monthly&summary_key=<?= e(rawurlencode((string) $month['summary_key'])) ?>">Öffnen</a>
|
<div class="archive-toolbar archive-toolbar--compact">
|
||||||
<?php endif; ?>
|
<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="post" action="/archive">
|
<form method="get" action="/archive" class="archive-filter">
|
||||||
<?= csrf_field() ?>
|
<input type="hidden" name="view" value="<?= e($archiveView) ?>">
|
||||||
<input type="hidden" name="form_name" value="generate_monthly_summary">
|
<label>
|
||||||
<input type="hidden" name="month_key" value="<?= e((string) $month['summary_key']) ?>">
|
<span>Zeitraum</span>
|
||||||
<button class="ghost-button ghost-button--small" type="submit" <?= !$month['can_generate'] || empty($aiAvailable) ? 'disabled' : '' ?>>
|
<select name="filter_month" onchange="this.form.submit()">
|
||||||
<?= !empty($month['has_summary']) ? 'Neu generieren' : 'KI-Monatszusammenfassung erzeugen' ?>
|
<option value="">Alle Monate</option>
|
||||||
</button>
|
<?php foreach ($archiveMonthOptions as $monthOption): ?>
|
||||||
|
<option value="<?= e($monthOption) ?>" <?= $archiveFilterMonth === $monthOption ? 'selected' : '' ?>><?= e(month_label($monthOption)) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="archive-summary-section">
|
<div class="archive-workspace">
|
||||||
<div class="section-head section-head--compact">
|
<section class="archive-main">
|
||||||
<div>
|
<?php if ($archiveView === 'days'): ?>
|
||||||
<p class="eyebrow">KI</p>
|
<div class="archive-list-header">
|
||||||
<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>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<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&summary_key=<?= e(rawurlencode((string) $week['summary_key'])) ?>">Öffnen</a>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="archive-summary-section">
|
|
||||||
<div class="section-head section-head--compact">
|
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Tage</p>
|
<p class="eyebrow">Tage</p>
|
||||||
<h4>Alle gespeicherten Tage</h4>
|
<h4>Gespeicherte Tage</h4>
|
||||||
</div>
|
</div>
|
||||||
|
<span class="chart-chip"><?= e((string) count($entries)) ?> Tage</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if ($entries === []): ?>
|
<?php if ($entries === []): ?>
|
||||||
<p class="empty-state">Noch keine Einträge vorhanden. Auf der Tracking-Seite kannst du den ersten Tag anlegen.</p>
|
<p class="empty-state">Für diesen Zeitraum gibt es noch keine getrackten Tage.</p>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<div class="archive-items">
|
<div class="archive-rows">
|
||||||
<?php foreach ($entries as $entry): ?>
|
<?php foreach ($entries as $entry): ?>
|
||||||
<article class="archive-item <?= $selectedEntry !== null && $selectedEntry['date'] === $entry['date'] ? 'active' : '' ?>">
|
<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>
|
<div class="archive-row__main">
|
||||||
<strong><?= e(format_display_date($entry['date'], false)) ?></strong>
|
<strong><?= e(format_compact_date($entry['date'])) ?></strong>
|
||||||
<span><?= e($entry['evaluation']['label']) ?></span>
|
<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>
|
</div>
|
||||||
<div class="archive-item__meta">
|
<div class="archive-row__meta">
|
||||||
<span><?= e(format_points((float) $entry['evaluation']['total'])) ?></span>
|
<span><?= e(format_points((float) $entry['evaluation']['total'])) ?> Punkte</span>
|
||||||
<span>Stimmung <?= e((string) $entry['mood']) ?>/10</span>
|
<span>Stimmung <?= e((string) $entry['mood']) ?>/10</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="archive-item__actions">
|
<span class="archive-row__hint">Ansehen</span>
|
||||||
<a class="ghost-link archive-action" href="/archive?date=<?= e(rawurlencode($entry['date'])) ?>">Ansehen</a>
|
</a>
|
||||||
<a class="ghost-link archive-action" href="/track?date=<?= e(rawurlencode($entry['date'])) ?>">Bearbeiten</a>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</section>
|
<?php elseif ($archiveView === 'weeks'): ?>
|
||||||
</article>
|
<div class="archive-list-header">
|
||||||
|
<div>
|
||||||
<aside class="stack-column">
|
<p class="eyebrow">Wochen</p>
|
||||||
<?php if ($selectedSummary !== null): ?>
|
<h4>Wöchentliche KI-Rückblicke</h4>
|
||||||
<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="note-box note-box--summary">
|
|
||||||
<h4>Text</h4>
|
|
||||||
<p><?= e($selectedSummary['text']) ?></p>
|
|
||||||
</div>
|
</div>
|
||||||
</article>
|
<span class="chart-chip"><?= e((string) count($weeklyArchive)) ?> Wochen</span>
|
||||||
<?php elseif ($selectedEntry !== null): ?>
|
</div>
|
||||||
<article class="glass-panel detail-card">
|
|
||||||
<p class="eyebrow">Ausgewählt</p>
|
<?php if ($weeklyArchive === []): ?>
|
||||||
<h3><?= e(format_display_date($selectedEntry['date'])) ?></h3>
|
<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; ?>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<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: ?>
|
||||||
|
<h3>Archivansicht</h3>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?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>
|
||||||
|
|
||||||
|
<?php if ($detailType === 'day'): ?>
|
||||||
<p class="hero-label"><?= e($selectedEntry['evaluation']['label']) ?> · <?= e(format_points((float) $selectedEntry['evaluation']['total'])) ?> Punkte</p>
|
<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>
|
<a class="primary-button button-link" href="/track?date=<?= e(rawurlencode($selectedEntry['date'])) ?>">Diesen Tag bearbeiten</a>
|
||||||
|
|
||||||
<dl class="detail-grid">
|
<dl class="detail-grid detail-grid--archive-day">
|
||||||
<div><dt>Stimmung</dt><dd><?= e((string) $selectedEntry['mood']) ?>/10</dd></div>
|
<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>Energie</dt><dd><?= e((string) $selectedEntry['energy']) ?>/10</dd></div>
|
||||||
<div><dt>Stress</dt><dd><?= e((string) $selectedEntry['stress']) ?>/10</dd></div>
|
<div><dt>Stress</dt><dd><?= e((string) $selectedEntry['stress']) ?>/10</dd></div>
|
||||||
@@ -194,24 +173,6 @@
|
|||||||
<div><dt>Schlaf</dt><dd><?= e((string) $selectedEntry['sleep_hours']) ?> h</dd></div>
|
<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>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>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
|
|
||||||
<?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>Spaziergang</dt><dd><?= e(format_walk_value($selectedEntry)) ?></dd></div>
|
||||||
<div><dt>Alkohol</dt><dd><?= !empty($selectedEntry['alcohol']) ? 'ja' : 'nein' ?></dd></div>
|
<div><dt>Alkohol</dt><dd><?= !empty($selectedEntry['alcohol']) ? 'ja' : 'nein' ?></dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
@@ -220,13 +181,118 @@
|
|||||||
<h4>Notiz</h4>
|
<h4>Notiz</h4>
|
||||||
<p><?= nl2br(e($selectedEntry['note'] !== '' ? $selectedEntry['note'] : 'Keine Notiz hinterlegt.')) ?></p>
|
<p><?= nl2br(e($selectedEntry['note'] !== '' ? $selectedEntry['note'] : 'Keine Notiz hinterlegt.')) ?></p>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
<?php elseif ($detailType === 'week'): ?>
|
||||||
<?php else: ?>
|
<p class="hero-label"><?= e(format_compact_date((string) $selectedWeek['date_from'])) ?> bis <?= e(format_compact_date((string) $selectedWeek['date_to'])) ?></p>
|
||||||
<article class="glass-panel detail-card">
|
|
||||||
<p class="eyebrow">Details</p>
|
<div class="archive-detail__status-row">
|
||||||
<h3>Archivansicht</h3>
|
<span class="status-badge status-badge--<?= e($selectedWeek['status_tone']) ?>"><?= e($selectedWeek['status_label']) ?></span>
|
||||||
<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>
|
<span class="chart-chip"><?= e($selectedWeek['trend_label']) ?></span>
|
||||||
</article>
|
</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; ?>
|
<?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="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>
|
||||||
|
|
||||||
|
<div class="note-box note-box--summary">
|
||||||
|
<h4>KI-Wochenzusammenfassung</h4>
|
||||||
|
<p><?= e((string) ($selectedWeek['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_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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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; ?>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<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>
|
||||||
</aside>
|
</aside>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
+695
-82
@@ -1,99 +1,712 @@
|
|||||||
<section class="hero-grid">
|
<?php
|
||||||
<article class="hero-card hero-card--wide glass-panel">
|
$dayDateLabel = format_display_date((string) $dayEntry['date']);
|
||||||
<p class="eyebrow">Stimmung im Blick</p>
|
$dayWeekday = strtok($dayDateLabel, ',');
|
||||||
<h3>Dein Dashboard verbindet Verlauf, Score und Bewegungsdaten in einer schnellen Übersicht.</h3>
|
$dayBackground = is_string($dayEntry['background_image_url'] ?? null) ? (string) $dayEntry['background_image_url'] : null;
|
||||||
<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>
|
$summaryComment = trim((string) ($dayEntry['summary']['comment'] ?? $dayEntry['summary_comment'] ?? ''));
|
||||||
</article>
|
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">
|
$mode = (string) ($settings['display']['score_mode'] ?? 'scale');
|
||||||
<p class="eyebrow">Heute</p>
|
$balance = is_array($entry['evaluation']['balance'] ?? null) ? $entry['evaluation']['balance'] : [];
|
||||||
<?php if ($summary['today'] !== null): ?>
|
if ($mode === 'points') {
|
||||||
<div class="hero-score"><?= e(format_points((float) $summary['today']['evaluation']['total'])) ?></div>
|
return format_points((float) ($entry['evaluation']['total'] ?? 0)) . ' Punkte';
|
||||||
<p class="hero-label"><?= e($summary['today']['evaluation']['label']) ?></p>
|
}
|
||||||
|
|
||||||
|
if ($mode === 'percent') {
|
||||||
|
return format_points((float) ($balance['percentage'] ?? $entry['evaluation']['percentage'] ?? 0)) . ' %';
|
||||||
|
}
|
||||||
|
|
||||||
|
$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>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<header class="dashboard-topbar">
|
||||||
|
<nav class="dashboard-switcher glass-panel" aria-label="Ansicht wechseln">
|
||||||
|
<a class="<?= $dashboardView === 'day' && $dashboardDate === today() ? 'active' : '' ?>" href="/?view=day&date=<?= e(rawurlencode(today())) ?>">Heute</a>
|
||||||
|
<a class="<?= $dashboardView === 'week' ? 'active' : '' ?>" href="/?view=week&date=<?= e(rawurlencode(today())) ?>">Woche</a>
|
||||||
|
<a class="<?= $dashboardView === 'month' ? 'active' : '' ?>" href="/?view=month&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&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: ?>
|
<?php else: ?>
|
||||||
<div class="hero-score">-</div>
|
<span class="day-summary-card__chips"><span class="day-chip day-chip--score">Bilanz <?= e($formatBalanceValue($dayEntry)) ?></span></span>
|
||||||
<p class="hero-label">Noch kein Eintrag für heute</p>
|
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</article>
|
</button>
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="stats-grid">
|
<section class="dashboard-moments-block">
|
||||||
<article class="metric-card glass-panel">
|
<div class="section-head section-head--compact section-head--dashboard">
|
||||||
<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>
|
|
||||||
|
|
||||||
<section class="dashboard-grid">
|
|
||||||
<article class="glass-panel chart-card chart-card--calendar">
|
|
||||||
<div class="section-head">
|
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Kalender</p>
|
<p class="eyebrow">Deine Momente</p>
|
||||||
<h3>Gesamtstimmung pro Tag</h3>
|
<h2>Momente des Tages</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="calendar-heatmap" class="calendar-heatmap" data-payload="<?= e($chartPayload) ?>"></div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article class="glass-panel chart-card">
|
<div class="timeline-list">
|
||||||
<div class="section-head">
|
<?php if ($dashboardTimeline === []): ?>
|
||||||
<div>
|
<article class="timeline-card timeline-card--empty glass-panel">
|
||||||
<p class="eyebrow">Trend</p>
|
<div class="timeline-card__body">
|
||||||
<h3>Tagesstimmung</h3>
|
<h3>Noch keine Momente</h3>
|
||||||
|
<p>Du kannst auch vergangene Tage jederzeit nachtragen.</p>
|
||||||
</div>
|
</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>
|
</article>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<article class="glass-panel chart-card chart-card--wide">
|
<?php foreach ($dashboardTimeline as $item): ?>
|
||||||
<div class="section-head">
|
<?php $eventType = (string) ($item['type'] ?? 'event'); ?>
|
||||||
|
<?php $eventComment = trim((string) ($item['comment'] ?? '')); ?>
|
||||||
|
<?php if (preg_match('/^\s*-\s*(?:Stimmung|Energie|Stress)\s*:\s*[+-]?\d+\s*$/u', $eventComment) === 1) { $eventComment = ''; } ?>
|
||||||
|
<?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>
|
<div>
|
||||||
<p class="eyebrow">Aktivität</p>
|
<strong class="timeline-card__time"><?= e($item['time'] !== '' ? $item['time'] : '--:--') ?></strong>
|
||||||
<h3>Sport und Spaziergang</h3>
|
|
||||||
</div>
|
</div>
|
||||||
<span class="chart-chip chart-chip--cool">Aktivität pro Tag</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="bar-chart" data-chart-type="bars" data-series="sport" data-payload="<?= e($chartPayload) ?>"></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="Schlafdauer" style="--sleep-optimal-left: <?= e((string) $sleepOptimalPercent) ?>%; --sleep-actual-width: <?= e((string) $sleepActualPercent) ?>%">
|
||||||
|
<span class="sleep-phase-bar__fill" aria-hidden="true"></span>
|
||||||
|
<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 if ($sleepPhaseTotal > 0): ?>
|
||||||
|
<div class="sleep-phase-legend" aria-label="Schlafphasen">
|
||||||
|
<?php foreach (['deep' => ['Tief', 'deep'], 'rem' => ['REM', 'rem'], 'core' => ['Kern', 'core']] as $phase => [$label, $class]): ?>
|
||||||
|
<?php $phaseHours = max(0.0, (float) ($sleepPhases[$phase] ?? 0)); ?>
|
||||||
|
<?php if ($phaseHours <= 0) { continue; } ?>
|
||||||
|
<span class="sleep-phase-legend__item sleep-phase-legend__item--<?= e($class) ?>"><strong><?= e($label) ?></strong> <?= e(format_duration_hours($phaseHours)) ?></span>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?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>
|
</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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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&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&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&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'] ?? '')) : '';
|
||||||
|
$eventComment = trim((string) ($event['comment'] ?? ''));
|
||||||
|
if (preg_match('/^\s*-\s*(?:Stimmung|Energie|Stress)\s*:\s*[+-]?\d+\s*$/u', $eventComment) === 1) {
|
||||||
|
$eventComment = '';
|
||||||
|
}
|
||||||
|
$eventTitle = day_event_type_label($eventType);
|
||||||
|
$eventDetails = array_values(array_filter([$eventValueText, $eventComment], static fn (string $value): bool => trim($value) !== ''));
|
||||||
|
|
||||||
|
if ($eventType === 'sport') {
|
||||||
|
$eventTitle = (string) ($sportType['label'] ?? 'Sport');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($eventType === 'sleep') {
|
||||||
|
$eventTitle = 'Schlaf';
|
||||||
|
} elseif ($eventType === 'walk') {
|
||||||
|
$eventTitle = 'Spaziergang';
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<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 foreach ($eventDetails as $eventDetail): ?>
|
||||||
|
<span><?= e($eventDetail) ?></span>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</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&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&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&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>
|
</section>
|
||||||
|
|||||||
+232
-374
@@ -1,429 +1,275 @@
|
|||||||
<section class="page-grid">
|
<section class="options-shell">
|
||||||
<article class="glass-panel form-panel form-panel--wide">
|
<div class="options-overlay" data-options-overlay data-options-standalone="1" data-open-panel="<?= e((string) ($optionsOpenPanel ?? '')) ?>">
|
||||||
<div class="section-head">
|
<div class="options-overlay__backdrop" data-options-close></div>
|
||||||
<div>
|
<section class="options-modal glass-panel" role="dialog" aria-modal="true">
|
||||||
<p class="eyebrow">Dein Account</p>
|
<div class="options-modal__controls">
|
||||||
<h3>Score und Sportarten persönlich anpassen</h3>
|
<button class="dashboard-modal__round" type="button" data-options-back>‹</button>
|
||||||
<p class="helper-text">Alle Einstellungen auf dieser Seite gelten nur für deinen eigenen Account und beeinflussen keine anderen Nutzer.</p>
|
<button class="dashboard-modal__round" type="button" data-options-close>×</button>
|
||||||
</div>
|
|
||||||
<span class="chart-chip">Maximal <?= e(format_points((float) $maxScore)) ?> Punkte</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="options-menu-panel" data-options-menu>
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Optionen</p>
|
||||||
|
<h3>Einstellungen und Bereiche</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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="options-panel" data-options-panel="sports" hidden>
|
||||||
|
<h2>Sportarten anpassen</h2>
|
||||||
<form method="post" action="/options" class="stack-form stack-form--spacious">
|
<form method="post" action="/options" class="stack-form stack-form--spacious">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<input type="hidden" name="form_name" value="settings">
|
<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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-section">
|
|
||||||
<div class="section-head section-head--compact">
|
|
||||||
<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>
|
|
||||||
</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>
|
|
||||||
</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>
|
|
||||||
<?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="settings-section">
|
||||||
<div class="section-head section-head--compact">
|
<div class="section-head section-head--compact">
|
||||||
<div>
|
<div>
|
||||||
<h4>Sportarten und Bonuspunkte</h4>
|
<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>
|
<p class="helper-text">Diese Sportarten stehen in deinen Momenten zur Auswahl.</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="ghost-button" type="button" data-add-sport-type>Sportart hinzufügen</button>
|
<button class="ghost-button" type="button" data-add-sport-type>Sportart hinzufügen</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input type="hidden" name="settings[sport_types_present]" value="1">
|
<input type="hidden" name="settings[sport_types_present]" value="1">
|
||||||
|
|
||||||
<?php if (!empty($sportTypePresets)): ?>
|
<?php if (!empty($sportTypePresets)): ?>
|
||||||
<div class="preset-list">
|
<div class="preset-list">
|
||||||
<?php foreach ($sportTypePresets as $preset): ?>
|
<?php foreach ($sportTypePresets as $preset): ?>
|
||||||
<button
|
<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' ?>">
|
||||||
class="preset-pill"
|
<img src="<?= e(sport_icon_path($preset['icon'])) ?>" alt=""><span><?= e($preset['label']) ?></span>
|
||||||
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>
|
</button>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<div class="sport-type-list" data-sport-type-list>
|
<div class="sport-type-list" data-sport-type-list>
|
||||||
<?php foreach ($settings['sport_types'] as $index => $sportType): ?>
|
<?php foreach ($settings['sport_types'] as $index => $sportType): ?>
|
||||||
<div class="sport-type-card band-card" data-sport-type-row>
|
<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]">
|
<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">
|
<div class="field-grid field-grid--four">
|
||||||
<label>
|
<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>
|
||||||
<span>Bezeichnung</span>
|
<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>
|
||||||
<input type="text" name="settings[sport_types][<?= e((string) $index) ?>][label]" value="<?= e($sportType['label']) ?>" data-name-template="settings[sport_types][__INDEX__][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>
|
<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>
|
||||||
|
|
||||||
<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>
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="section-actions">
|
|
||||||
<button class="primary-button" type="submit">Sportarten speichern</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template id="sport-type-row-template">
|
<template id="sport-type-row-template">
|
||||||
<div class="sport-type-card band-card" data-sport-type-row>
|
<div class="sport-type-card band-card" data-sport-type-row>
|
||||||
<input type="hidden" value="" data-name-template="settings[sport_types][__INDEX__][id]">
|
<input type="hidden" value="" data-name-template="settings[sport_types][__INDEX__][id]">
|
||||||
|
|
||||||
<div class="field-grid field-grid--four">
|
<div class="field-grid field-grid--four">
|
||||||
<label>
|
<label><span>Bezeichnung</span><input type="text" value="" data-name-template="settings[sport_types][__INDEX__][label]"></label>
|
||||||
<span>Bezeichnung</span>
|
<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>
|
||||||
<input type="text" value="" placeholder="z. B. Krafttraining Zuhause" data-name-template="settings[sport_types][__INDEX__][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>
|
<label><span>Erholungsgruppe</span><input type="text" value="" data-name-template="settings[sport_types][__INDEX__][recovery_group]"></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>
|
||||||
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
<button class="primary-button" type="submit">Sportarten speichern</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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">
|
<div class="settings-section">
|
||||||
<div class="section-head section-head--compact">
|
<h4>Schritte-Bonus</h4>
|
||||||
<div>
|
<div class="field-grid field-grid--three">
|
||||||
<h4>Erinnerungen</h4>
|
<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>
|
||||||
<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>
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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">
|
<div class="field-grid field-grid--two">
|
||||||
<label class="checkbox-row checkbox-row--panel">
|
<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>
|
||||||
<input type="checkbox" name="settings[notifications][enabled]" value="1" <?= !empty($settings['notifications']['enabled']) ? 'checked' : '' ?>>
|
<label><span>Uhrzeit der Erinnerung</span><input type="time" name="settings[notifications][time]" value="<?= e((string) ($settings['notifications']['time'] ?? '20:30')) ?>"></label>
|
||||||
<span>Tägliche Push-Erinnerung aktivieren</span>
|
</div>
|
||||||
</label>
|
<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>
|
||||||
<label>
|
<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>
|
||||||
<span>Uhrzeit der Erinnerung</span>
|
</div>
|
||||||
<input type="time" name="settings[notifications][time]" value="<?= e((string) ($settings['notifications']['time'] ?? '20:30')) ?>">
|
<button class="primary-button" type="submit">Erinnerungen speichern</button>
|
||||||
</label>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div class="options-panel" data-options-panel="health" hidden>
|
||||||
class="push-panel band-card"
|
<h2>Health Import</h2>
|
||||||
data-push-panel
|
<article class="detail-card detail-card--overlay">
|
||||||
data-push-ready="<?= !empty($pushAvailable) && !empty($pushPublicKey) ? '1' : '0' ?>"
|
<p class="eyebrow">REST-Endpunkt</p>
|
||||||
>
|
<div class="stack-form">
|
||||||
<div>
|
<label><span>URL in Health Auto Export</span><input type="text" value="<?= e((string) $healthImportUrl) ?>" readonly></label>
|
||||||
<h5>Push auf diesem Gerät</h5>
|
<label><span>HTTP-Header</span><input type="text" value="Authorization: Bearer <?= !empty($healthImportConfig['token_prefix']) ? e((string) $healthImportConfig['token_prefix']) . '…' : 'TOKEN' ?>" readonly></label>
|
||||||
<p class="helper-text" data-push-status>
|
</div>
|
||||||
<?php if (!empty($pushAvailable) && !empty($pushPublicKey)): ?>
|
<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>
|
||||||
Installiere die App auf Wunsch als PWA und aktiviere dann Push direkt auf diesem Gerät.
|
</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: ?>
|
<?php else: ?>
|
||||||
Push ist auf diesem Server gerade noch nicht verfügbar.
|
Noch kein Import gelaufen.
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="user-list">
|
||||||
<div class="push-actions">
|
<div class="user-row"><strong>Token</strong><span data-health-token-state><?= !empty($healthImportConfig['enabled']) ? 'aktiv' : 'nicht eingerichtet' ?></span></div>
|
||||||
<button class="ghost-button" type="button" data-push-enable <?= empty($pushAvailable) || empty($pushPublicKey) ? 'disabled' : '' ?>>Push aktivieren</button>
|
<?php if (!empty($healthImportConfig['last_import_at'])): ?>
|
||||||
<button class="ghost-button" type="button" data-push-disable <?= empty($pushAvailable) || empty($pushPublicKey) ? 'disabled' : '' ?>>Auf diesem Gerät entfernen</button>
|
<div class="user-row"><strong>Letzter Import</strong><span data-health-last-import><?= e(format_display_datetime((string) $healthImportConfig['last_import_at'])) ?></span></div>
|
||||||
<button class="ghost-button" type="button" data-push-test <?= empty($pushAvailable) || empty($pushPublicKey) ? 'disabled' : '' ?>>Test senden</button>
|
<?php else: ?>
|
||||||
</div>
|
<div class="user-row"><strong>Letzter Import</strong><span data-health-last-import>-</span></div>
|
||||||
</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 class="section-actions">
|
|
||||||
<button class="primary-button" type="submit">Erinnerungen speichern</button>
|
|
||||||
</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>
|
||||||
|
|
||||||
|
<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">
|
<div class="settings-section">
|
||||||
<h4>Bewertungsskala</h4>
|
<h4>Tagesbilanz als Hauptmetrik</h4>
|
||||||
<p class="helper-text">Diese Score-Grenzen und Schutzregeln gelten ebenfalls nur für deinen eigenen Account.</p>
|
<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="band-grid">
|
<div class="field-grid field-grid--three">
|
||||||
<?php foreach ($settings['ratings'] as $index => $rating): ?>
|
<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>
|
||||||
<div class="band-card">
|
<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>Label</span><input type="text" name="settings[ratings][<?= e((string) $index) ?>][label]" value="<?= e($rating['label']) ?>"></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>
|
||||||
<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>
|
</div>
|
||||||
<?php endforeach; ?>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
<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">
|
<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>
|
||||||
<h4>Schutzregeln</h4>
|
<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>
|
||||||
<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>
|
<button class="primary-button" type="submit">Bewertung speichern</button>
|
||||||
</form>
|
</form>
|
||||||
</article>
|
</div>
|
||||||
|
|
||||||
<aside class="stack-column">
|
<div class="options-panel" data-options-panel="stats" hidden>
|
||||||
<article class="glass-panel detail-card">
|
<h2>Statistik</h2>
|
||||||
<p class="eyebrow">Backup</p>
|
<form method="post" action="/options" class="stack-form stack-form--spacious stats-settings-form">
|
||||||
<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() ?>
|
<?= csrf_field() ?>
|
||||||
<input type="hidden" name="form_name" value="export_backup">
|
<input type="hidden" name="form_name" value="settings">
|
||||||
<button class="primary-button" type="submit" <?= empty($backupAvailable) ? 'disabled' : '' ?>>Backup herunterladen</button>
|
<div class="settings-section">
|
||||||
<?php if (empty($backupAvailable)): ?>
|
<h4>Statistik-Darstellung</h4>
|
||||||
<p class="helper-text">Auf diesem Server fehlt gerade die ZIP-Erweiterung für den Download.</p>
|
<label><span>Hauptwert anzeigen als</span><select name="settings[display][score_mode]">
|
||||||
<?php endif; ?>
|
<?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>
|
||||||
|
<button class="primary-button" type="submit">Statistik speichern</button>
|
||||||
</form>
|
</form>
|
||||||
|
<section class="stats-grid">
|
||||||
<form method="post" action="/options" enctype="multipart/form-data" class="stack-form">
|
<article class="metric-card glass-panel"><span>Getrackte Tage</span><strong><?= e((string) $statsSummary['tracked_days']) ?></strong></article>
|
||||||
<?= csrf_field() ?>
|
<article class="metric-card glass-panel"><span>Ø Score</span><strong><?= e(format_points((float) $statsSummary['average_score'])) ?></strong></article>
|
||||||
<input type="hidden" name="form_name" value="import_backup">
|
<article class="metric-card glass-panel"><span>Ø Bilanz</span><strong><?= ((float) $statsSummary['average_balance']) > 0 ? '+' : '' ?><?= e(format_points((float) $statsSummary['average_balance'])) ?></strong></article>
|
||||||
<label>
|
<article class="metric-card glass-panel"><span>Ø Stimmung</span><strong><?= e(format_points((float) $statsSummary['average_mood'])) ?>/10</strong></article>
|
||||||
<span>Backup importieren</span>
|
<article class="metric-card glass-panel"><span>Ø Stress</span><strong><?= e(format_points((float) $statsSummary['average_stress'])) ?>/10</strong></article>
|
||||||
<input type="file" name="backup_files[]" accept=".zip,.txt" multiple>
|
<article class="metric-card glass-panel"><span>Serie</span><strong><?= e((string) $statsSummary['streak']) ?> Tage</strong></article>
|
||||||
</label>
|
</section>
|
||||||
<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>
|
<section class="dashboard-grid dashboard-grid--embedded-stats">
|
||||||
<button class="ghost-button" type="submit">Backup importieren</button>
|
<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>
|
||||||
</form>
|
<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>
|
<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>
|
||||||
<article class="glass-panel detail-card">
|
</section>
|
||||||
<p class="eyebrow">Sicherheit</p>
|
</div>
|
||||||
<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'])): ?>
|
<?php if (!empty($authUser['is_admin'])): ?>
|
||||||
<article class="glass-panel detail-card">
|
<div class="options-panel" data-options-panel="users" hidden>
|
||||||
<p class="eyebrow">KI</p>
|
<h2>Neue Nutzer anlegen</h2>
|
||||||
<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>
|
|
||||||
<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>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<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">
|
<form method="post" action="/options" class="stack-form">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<input type="hidden" name="form_name" value="create_user">
|
<input type="hidden" name="form_name" value="create_user">
|
||||||
@@ -432,18 +278,30 @@
|
|||||||
<label class="checkbox-row"><input type="checkbox" name="is_admin" value="1"><span>Als Admin anlegen</span></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>
|
<button class="primary-button" type="submit">Account erstellen</button>
|
||||||
</form>
|
</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; ?>
|
||||||
|
|
||||||
<?php if ($users !== []): ?>
|
<div class="options-panel" data-options-panel="security" hidden>
|
||||||
<div class="user-list">
|
<h2>Sicherheit</h2>
|
||||||
<?php foreach ($users as $account): ?>
|
<article class="detail-card detail-card--overlay">
|
||||||
<div class="user-row">
|
<p class="eyebrow">Backup</p>
|
||||||
<strong><?= e($account['username']) ?></strong>
|
<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>
|
||||||
<span><?= !empty($account['is_admin']) ? 'Admin' : 'Nutzer' ?></span>
|
<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>
|
||||||
</div>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
</article>
|
</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; ?>
|
<?php endif; ?>
|
||||||
</aside>
|
</section>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<h3><?= e(format_display_date($entry['date'])) ?></h3>
|
<h3><?= e(format_display_date($entry['date'])) ?></h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="section-head__actions">
|
<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&date=<?= e(rawurlencode($entry['date'])) ?>">Im Archiv ansehen</a>
|
||||||
<a class="ghost-link" href="/track?date=<?= e(today()) ?>">Heute</a>
|
<a class="ghost-link" href="/track?date=<?= e(today()) ?>">Heute</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -45,24 +45,32 @@
|
|||||||
<input type="range" min="1" max="10" step="1" name="pain" value="<?= e((string) $entry['pain']) ?>">
|
<input type="range" min="1" max="10" step="1" name="pain" value="<?= e((string) $entry['pain']) ?>">
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="checkbox-row checkbox-row--panel checkbox-row--tall">
|
<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' : '' ?>>
|
<input type="checkbox" name="alcohol" value="1" <?= !empty($entry['alcohol']) ? 'checked' : '' ?>>
|
||||||
<span>
|
<span class="sport-choice__card sport-choice__card--toggle">
|
||||||
<strong>Alkohol</strong>
|
<img src="<?= e(icon_path('alcohol')) ?>" alt="">
|
||||||
<small>Wenn du heute Alkohol getrunken hast, werden 5 Punkte abgezogen.</small>
|
<strong>Alkohol</strong>Heute was getrunken?
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<div class="field-grid field-grid--single">
|
<div class="field-grid field-grid--single">
|
||||||
<label class="checkbox-row checkbox-row--panel">
|
<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' : '' ?>>
|
<input type="checkbox" name="alcohol" value="1" <?= !empty($entry['alcohol']) ? 'checked' : '' ?>>
|
||||||
<span>
|
<span class="sport-choice__card sport-choice__card--toggle">
|
||||||
<strong>Alkohol</strong>
|
<img src="<?= e(icon_path('alcohol')) ?>" alt="">
|
||||||
<small>Wenn du heute Alkohol getrunken hast, werden 5 Punkte abgezogen.</small>
|
<strong>Alkohol</strong>Heute was getrunken?
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<div class="field-grid field-grid--two">
|
<div class="field-grid field-grid--two">
|
||||||
|
|||||||
Reference in New Issue
Block a user