37 Commits

Author SHA1 Message Date
hnzio 2932cbb5b2 Add iOS mobile polish 2026-05-21 18:30:11 +02:00
hnzio 9f1bb2c351 Render sleep bar fill directly 2026-05-21 17:46:20 +02:00
hnzio 3a467aca38 Fix sleep bar fill rendering 2026-05-21 13:10:22 +02:00
hnzio f5daff1a04 Refine swipe affordance and sleep bar 2026-05-21 13:07:03 +02:00
hnzio a087eb508b Improve day swipe and sleep handling 2026-05-21 13:00:10 +02:00
hnzio 2047cae61c Slide day header and prefetch adjacent days 2026-05-21 12:51:10 +02:00
hnzio 1dd5339a46 Fix light mode summary text 2026-05-21 12:49:05 +02:00
hnzio 0df5983f65 Make day strip draggable and fix sleep bars 2026-05-21 12:47:30 +02:00
hnzio 7c9f464686 Fix dashboard swipe and visual details 2026-05-21 12:36:53 +02:00
hnzio abcd35714f Refine balance scoring and dashboard views 2026-05-21 12:19:52 +02:00
hnzio 0fb8adbb14 Make sleep phase bars proportional 2026-05-19 16:43:33 +02:00
hnzio 3b2c36c849 Fix proportional sleep bar and image overlays 2026-05-19 16:39:25 +02:00
hnzio adaff22651 Polish mobile media and sleep bars 2026-05-19 16:34:25 +02:00
hnzio 36a15f3ed4 Fix media and sleep bar layout 2026-05-19 16:24:11 +02:00
hnzio 6a5852654b Fix media lightbox and sleep target 2026-05-19 16:07:35 +02:00
hnzio 3e497a8047 Refine Health import event presentation 2026-05-19 15:54:50 +02:00
hnzio 59c7d89e81 Recognize German Health walk workouts 2026-05-19 15:33:59 +02:00
hnzio 176b07f202 Add Health import failure diagnostics 2026-05-19 15:30:43 +02:00
hnzio d8636f6c41 Handle flexible Health Auto Export payloads 2026-05-19 15:26:32 +02:00
hnzio a555f552c2 Support Health Auto Export metric names 2026-05-19 15:21:58 +02:00
hnzio e00cd66fbe Clarify Health Auto Export config uploads 2026-05-19 15:19:24 +02:00
hnzio e36f27da4a add health import 2026-05-19 14:50:19 +02:00
hnzio bc6e850afb feat(dashboard): refine moment media experience 2026-05-18 23:49:15 +02:00
hnzio b8a96e96ef fix(overlays): improve ios safe area scrolling 2026-05-18 16:39:38 +02:00
hnzio 48df9831fd fix(dashboard): improve light mode styling 2026-05-18 16:37:00 +02:00
hnzio 83b4686b6f feat(dashboard): add immersive day range views 2026-05-18 16:32:22 +02:00
hnzio e953d0fd42 feat(track): replace alcohol checkbox with a selectable tile 2026-04-14 15:09:25 +02:00
hnzio ab1d8bc677 refactor(archive): redesign segmented archive experience 2026-04-14 15:09:25 +02:00
hnzio 297f63c7d5 Refine AI summary tone to reduce generic therapeutic phrasing 2026-04-14 10:25:16 +02:00
hnzio 889f5ffa8a Tighten weekly AI summary length for denser output 2026-04-14 10:20:59 +02:00
hnzio 41183f04db Refine weekly AI prompts for more natural, non-chronological summaries 2026-04-14 10:18:15 +02:00
hnzio 796e5b23d2 Refine weekly AI summary prompts to reduce diary-style output 2026-04-14 10:14:15 +02:00
hnzio af84243866 Refine weekly AI summary prompts to reduce diary-style output 2026-04-14 10:09:36 +02:00
hnzio 9e79e93724 Add AI weekly and monthly summaries with archive UI and backup support 2026-04-14 09:57:53 +02:00
hnzio 0a8ccef5a7 Add encrypted day storage and personal backups 2026-04-13 12:04:17 +02:00
hnzio 4a884dd166 Add dashboard pain chart and version footer 2026-04-13 10:30:51 +02:00
hnzio 5ea1b56649 Add optional pain tracking and fix reminder delivery 2026-04-13 10:22:41 +02:00
28 changed files with 10970 additions and 650 deletions
+4
View File
@@ -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
+96
View File
@@ -0,0 +1,96 @@
# AGENTS.md
## Projektueberblick
Mood ist ein dateibasierter Stimmungstracker fuer klassische PHP/LAMP-Deployments ohne Datenbank.
Die App rendert serverseitig PHP-Templates und speichert Nutzer-, Einstellungs- und Trackingdaten unter `storage/`.
## Einstiegspunkte
- `index.php`: Front-Controller, bootet die App.
- `src/bootstrap.php`: laedt Dateien, initialisiert Session und stellt `storage/` sicher.
- `src/App.php`: zentrales Routing und Grossteil der Anwendungslogik.
## Wichtige Struktur
- `src/Domain/`: dateibasierte Repositories und Fachlogik.
- `src/Support/`: Auth, View, Verschluesselung, OpenAI, Web Push.
- `templates/layout.php`: globales Layout.
- `templates/pages/`: serverseitige Seiten.
- `assets/css/app.css`: gesamtes Styling.
- `assets/js/app.js`: Frontend-Logik fuer Charts, Formulare, Archiv, Push und PWA.
- `storage/system/`: globale Systemdaten wie Nutzer, Throttle, Notifications, Key-Dateien.
- `storage/users/<user>/`: Nutzerdaten, Einstellungen, Tage, Zusammenfassungen und Push-Status.
## Routing
Die App nutzt keinen Router von aussen. Routen werden direkt in `App::run()` per `switch ($path)` behandelt.
Wichtige Routen:
- `/setup`
- `/login`
- `/logout`
- `/`
- `/track`
- `/archive`
- `/options`
- `/push/subscribe`
- `/push/unsubscribe`
- `/push/test`
- `/reminders/run`
## Datenmodell
- Nutzer stehen in `storage/system/users.json`.
- Einstellungen pro Nutzer in `storage/users/<user>/settings.json`.
- Tagesdaten in `storage/users/<user>/days/YYYY-MM-DD.txt`.
- Wochen- und Monatszusammenfassungen unter `storage/users/<user>/summaries/`.
- Push-Subscriptions und Reminder-State liegen ebenfalls pro Nutzer unter `storage/users/<user>/`.
Tagesdateien und Zusammenfassungen koennen serverseitig verschluesselt gespeichert werden. Die Logik liegt in `src/Support/EntryCrypto.php`.
## Sicherheitsrelevante Regeln
- Form-POSTs nutzen CSRF-Token via `csrf_field()` und `App::enforceCsrf()`.
- JSON-POSTs nutzen `App::enforceRequestCsrf()`.
- Auth-Logik liegt in `src/Support/Auth.php`.
- Security-Header werden zentral in `App::sendSecurityHeaders()` gesetzt.
- Aendere keine Auth-, Cookie-, CSRF- oder Reminder-Token-Logik leichtfertig.
## Arbeitsregeln fuer Aenderungen
- Bevorzuge kleine, lokale Aenderungen. Die App ist bewusst simpel und frameworkfrei.
- Ziehe bestehende Hilfsfunktionen in `src/helpers.php` vor, statt neue Utility-Dateien einzufuehren.
- Wenn moeglich dem bestehenden Muster folgen: Daten lesen/schreiben in Repositories, Seiten in `App`, Ausgabe in Templates.
- Fuehre keine grossen Architekturumbauten ohne konkreten Bedarf ein. `src/App.php` ist zentral und gewollt monolithisch.
- Beruehre `storage/` nur, wenn die Aufgabe das wirklich erfordert. Dort koennen echte Nutzerdaten liegen.
- Fuehre keine Massenformatierung oder kosmetische Grossumbauten ohne Anlass durch.
## Frontend-Hinweise
- Das UI ist servergerendert; JavaScript erweitert nur interaktive Teile.
- Neue UI-Logik moeglichst in `assets/js/app.js` integrieren, statt neue Build-Schritte einzufuehren.
- Externe CDNs oder Frontend-Frameworks nicht einfuehren.
## KI- und Push-Integrationen
- OpenAI-Zusammenfassungen laufen ueber `src/Support/OpenAiSummaryService.php`.
- Web Push und VAPID laufen ueber `src/Support/WebPushService.php` und `src/Domain/NotificationRepository.php`.
- Bei Aenderungen in diesen Bereichen besonders auf Datenschutz, Fehlerbehandlung und Rueckwaertskompatibilitaet der gespeicherten Daten achten.
## Lokale Checks
Es gibt aktuell keine sichtbare Composer- oder PHPUnit-Konfiguration im Projekt.
Sinnvolle manuelle Checks:
- PHP-Syntax fuer geaenderte Dateien pruefen: `php -l <datei>`
- Setup/Login/Tracken/Archiv/Optionen im Browser kurz durchklicken
- Falls Push oder Reminder betroffen sind: relevante Endpunkte gezielt testen
## Deployment-Annahmen
- Ziel ist klassisches Apache/LAMP bzw. Cloudron.
- `.htaccess` und Schreibrechte auf `storage/` sind wichtig.
- Die App erwartet keinen Datenbankserver und keinen JS-Buildprozess.
+16
View File
@@ -7,6 +7,7 @@ Dateibasierter Stimmungstracker für LAMP/Cloudron ohne Datenbank.
- Geschützter Login mit Session, `password_hash`, CSRF-Schutz und Security-Headern - Geschützter Login mit Session, `password_hash`, CSRF-Schutz und Security-Headern
- Vier Bereiche: Dashboard, Tracking, Optionen, Archiv - Vier Bereiche: Dashboard, Tracking, Optionen, Archiv
- Speicherung aller Tage als Markdown in `storage/users/<user>/days/YYYY-MM-DD.txt` - Speicherung aller Tage als Markdown in `storage/users/<user>/days/YYYY-MM-DD.txt`
- KI-Wochen- und Monatszusammenfassungen im Archiv mit verschlüsselter dateibasierter Ablage
- Pro Nutzer eigene Einstellungen für die Bewertungslogik - Pro Nutzer eigene Einstellungen für die Bewertungslogik
- Admin kann weitere Accounts direkt in der Weboberfläche anlegen - Admin kann weitere Accounts direkt in der Weboberfläche anlegen
- Moderner, responsiver Liquid-Glass-Look mit lokalen Assets und ohne externe CDNs - Moderner, responsiver Liquid-Glass-Look mit lokalen Assets und ohne externe CDNs
@@ -30,9 +31,24 @@ Dateibasierter Stimmungstracker für LAMP/Cloudron ohne Datenbank.
## Hinweise ## Hinweise
- Die Inhalte liegen absichtlich nicht in einer Datenbank, sondern in menschenlesbaren TXT-Dateien. - Die Inhalte liegen absichtlich nicht in einer Datenbank, sondern in menschenlesbaren TXT-Dateien.
- Tagesdateien und KI-Zusammenfassungen werden serverseitig verschlüsselt gespeichert und im Backup wieder als lesbare TXT-Dateien exportiert.
- Mehrere Accounts sind möglich und verursachen hier wenig Overhead, weil jeder Nutzer nur einen eigenen Unterordner mit Tagen und Einstellungen bekommt. - Mehrere Accounts sind möglich und verursachen hier wenig Overhead, weil jeder Nutzer nur einen eigenen Unterordner mit Tagen und Einstellungen bekommt.
- Wenn du später Reverse Proxy oder HTTPS über Cloudron nutzt, bleiben die Daten weiterhin nur über die App erreichbar. - Wenn du später Reverse Proxy oder HTTPS über Cloudron nutzt, bleiben die Daten weiterhin nur über die App erreichbar.
## KI-Zusammenfassungen
- Für KI-Zusammenfassungen im Archiv wird ein OpenAI-Modell aus der Mini-Klasse verwendet.
- Der API-Key kommt aus der Server-Umgebung, das Modell und der Timeout können zusätzlich zentral durch einen Admin in den Optionen angepasst werden.
- Wochenzusammenfassungen werden als `storage/users/<user>/summaries/weekly/YYYY-KW-XX.txt` gespeichert.
- Monatszusammenfassungen werden als `storage/users/<user>/summaries/monthly/YYYY-MM.txt` gespeichert.
- Der Backup-Export nimmt diese Dateien automatisch mit und legt sie im ZIP unter `summaries/weekly/` und `summaries/monthly/` ab.
### Benötigte Umgebungsvariablen
- `OPENAI_API_KEY` (erforderlich)
- `OPENAI_MODEL` (optional, Standard: `gpt-4o-mini`)
- `OPENAI_TIMEOUT` (optional, Standard: `25`)
## Lizenz ## Lizenz
- Dieses Projekt steht unter der `PolyForm Noncommercial License 1.0.0`. - Dieses Projekt steht unter der `PolyForm Noncommercial License 1.0.0`.
+3355 -1
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="12" y="18" width="40" height="28" rx="10" stroke="#DFF7FF" stroke-width="4"/>
<path d="M20 28H44" stroke="#90E3FF" stroke-width="4" stroke-linecap="round"/>
<path d="M20 36H34" stroke="#DFF7FF" stroke-width="4" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 348 B

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

After

Width:  |  Height:  |  Size: 434 B

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

After

Width:  |  Height:  |  Size: 1.0 KiB

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

After

Width:  |  Height:  |  Size: 349 B

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

After

Width:  |  Height:  |  Size: 438 B

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

After

Width:  |  Height:  |  Size: 620 B

+949 -9
View File
File diff suppressed because it is too large Load Diff
+3021 -79
View File
File diff suppressed because it is too large Load Diff
+68
View File
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
final class AiConfigRepository
{
private string $path;
public function __construct()
{
$this->path = storage_path('system/ai.json');
}
public function get(): array
{
$saved = decode_json_file($this->path, []);
$config = array_replace($this->defaults(), is_array($saved) ? $saved : []);
$config['model'] = trim((string) ($config['model'] ?? $this->defaults()['model']));
if ($config['model'] === '') {
$config['model'] = $this->defaults()['model'];
}
$config['timeout'] = max(5, min(120, (int) ($config['timeout'] ?? $this->defaults()['timeout'])));
return $config;
}
public function save(array $input): array
{
$config = [
'model' => trim((string) ($input['model'] ?? $this->defaults()['model'])),
'timeout' => max(5, min(120, (int) ($input['timeout'] ?? $this->defaults()['timeout']))),
];
if ($config['model'] === '') {
$config['model'] = $this->defaults()['model'];
}
$directory = dirname($this->path);
if (!is_dir($directory)) {
mkdir($directory, 0775, true);
}
$bytes = file_put_contents(
$this->path,
json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
LOCK_EX
);
if ($bytes === false) {
throw new RuntimeException('Die KI-Konfiguration konnte nicht gespeichert werden.');
}
return $config;
}
private function defaults(): array
{
$model = trim((string) ($_ENV['OPENAI_MODEL'] ?? getenv('OPENAI_MODEL') ?: 'gpt-4o-mini'));
$timeout = (int) ($_ENV['OPENAI_TIMEOUT'] ?? getenv('OPENAI_TIMEOUT') ?: 25);
return [
'model' => $model !== '' ? $model : 'gpt-4o-mini',
'timeout' => max(5, min(120, $timeout > 0 ? $timeout : 25)),
];
}
}
+265 -6
View File
@@ -4,6 +4,13 @@ declare(strict_types=1);
final class EntryRepository final class EntryRepository
{ {
private EntryCrypto $crypto;
public function __construct()
{
$this->crypto = new EntryCrypto();
}
public function save(string $username, string $date, array $entry, array $evaluation): void public function save(string $username, string $date, array $entry, array $evaluation): void
{ {
$path = $this->pathFor($username, $date); $path = $this->pathFor($username, $date);
@@ -13,7 +20,8 @@ final class EntryRepository
mkdir($directory, 0775, true); mkdir($directory, 0775, true);
} }
file_put_contents($path, $this->toMarkdown($username, $date, $entry, $evaluation)); $markdown = $this->toMarkdown($username, $date, $entry, $evaluation);
file_put_contents($path, $this->crypto->encrypt($markdown), LOCK_EX);
} }
public function find(string $username, string $date): ?array public function find(string $username, string $date): ?array
@@ -24,7 +32,14 @@ final class EntryRepository
return null; return null;
} }
return $this->parse((string) file_get_contents($path), $date); $content = (string) file_get_contents($path);
$plaintext = $this->crypto->decrypt($content);
if ($this->crypto->shouldEncrypt() && !$this->crypto->isEncrypted($content)) {
file_put_contents($path, $this->crypto->encrypt($plaintext), LOCK_EX);
}
return $this->parse($plaintext, $date);
} }
public function all(string $username): array public function all(string $username): array
@@ -41,7 +56,14 @@ final class EntryRepository
$entries = []; $entries = [];
foreach ($files as $file) { foreach ($files as $file) {
$date = basename($file, '.txt'); $date = basename($file, '.txt');
$parsed = $this->parse((string) file_get_contents($file), $date); $content = (string) file_get_contents($file);
$plaintext = $this->crypto->decrypt($content);
if ($this->crypto->shouldEncrypt() && !$this->crypto->isEncrypted($content)) {
file_put_contents($file, $this->crypto->encrypt($plaintext), LOCK_EX);
}
$parsed = $this->parse($plaintext, $date);
if ($parsed !== null) { if ($parsed !== null) {
$entries[] = $parsed; $entries[] = $parsed;
} }
@@ -50,6 +72,18 @@ final class EntryRepository
return $entries; return $entries;
} }
public function parseMarkdown(string $content, string $fallbackDate): ?array
{
$plaintext = $this->crypto->decrypt($content);
return $this->parse($plaintext, $fallbackDate);
}
public function exportMarkdown(string $username, string $date, array $entry, array $evaluation): string
{
return $this->toMarkdown($username, $date, $entry, $evaluation);
}
private function directoryFor(string $username): string private function directoryFor(string $username): string
{ {
return storage_path('users/' . normalize_username($username) . '/days'); return storage_path('users/' . normalize_username($username) . '/days');
@@ -62,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 !== '') {
@@ -80,12 +118,16 @@ final class EntryRepository
$walkModeRaw = strtolower((string) ($this->extract('/^- Spaziergang-Modus:\s*(.+)$/mu', $content) ?? 'zeit')); $walkModeRaw = strtolower((string) ($this->extract('/^- Spaziergang-Modus:\s*(.+)$/mu', $content) ?? 'zeit'));
$walkMode = $walkModeRaw === 'schritte' ? 'steps' : 'time'; $walkMode = $walkModeRaw === 'schritte' ? 'steps' : 'time';
$walkValue = (int) ($this->extract('/^- Spaziergang:\s*(.+)$/m', $content) ?? 0); $walkValue = (int) ($this->extract('/^- Spaziergang:\s*(.+)$/m', $content) ?? 0);
$painRaw = $this->extract('/^- Schmerzen:\s*(.+)$/mu', $content);
$alcoholRaw = strtolower((string) ($this->extract('/^- Alkohol:\s*(.+)$/mu', $content) ?? 'nein'));
$entry = [ $entry = [
'date' => $this->extract('/^Datum:\s*(\d{4}-\d{2}-\d{2})$/m', $content) ?? $fallbackDate, 'date' => $this->extract('/^Datum:\s*(\d{4}-\d{2}-\d{2})$/m', $content) ?? $fallbackDate,
'mood' => (int) ($this->extract('/^- Stimmung:\s*(.+)$/m', $content) ?? 5), 'mood' => (int) ($this->extract('/^- Stimmung:\s*(.+)$/m', $content) ?? 5),
'energy' => (int) ($this->extract('/^- Energie:\s*(.+)$/m', $content) ?? 5), 'energy' => (int) ($this->extract('/^- Energie:\s*(.+)$/m', $content) ?? 5),
'stress' => (int) ($this->extract('/^- Stress:\s*(.+)$/m', $content) ?? 5), 'stress' => (int) ($this->extract('/^- Stress:\s*(.+)$/m', $content) ?? 5),
'pain' => $painRaw !== null ? (int) $painRaw : 1,
'pain_enabled' => $painRaw !== null,
'sleep_hours' => (float) ($this->extract('/^- Schlafdauer:\s*(.+)$/m', $content) ?? 0), 'sleep_hours' => (float) ($this->extract('/^- Schlafdauer:\s*(.+)$/m', $content) ?? 0),
'sleep_feeling' => (int) ($this->extract('/^- Schlaf(?:gefühl|gefuehl):\s*(.+)$/mu', $content) ?? 3), 'sleep_feeling' => (int) ($this->extract('/^- Schlaf(?:gefühl|gefuehl):\s*(.+)$/mu', $content) ?? 3),
'sport_minutes' => (int) ($this->extract('/^- Sport:\s*(.+)$/m', $content) ?? 0), 'sport_minutes' => (int) ($this->extract('/^- Sport:\s*(.+)$/m', $content) ?? 0),
@@ -94,7 +136,21 @@ final class EntryRepository
'walk_mode' => $walkMode, 'walk_mode' => $walkMode,
'walk_minutes' => $walkMode === 'time' ? $walkValue : 0, 'walk_minutes' => $walkMode === 'time' ? $walkValue : 0,
'walk_steps' => $walkMode === 'steps' ? $walkValue : 0, 'walk_steps' => $walkMode === 'steps' ? $walkValue : 0,
'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;
@@ -124,28 +180,85 @@ 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'],
'- Stress: ' . $entry['stress'], '- Stress: ' . $entry['stress'],
...(!empty($entry['pain_enabled']) ? ['- Schmerzen: ' . $entry['pain']] : []),
'- Schlafdauer: ' . $entry['sleep_hours'], '- Schlafdauer: ' . $entry['sleep_hours'],
'- Schlafgefühl: ' . $entry['sleep_feeling'], '- Schlafgefühl: ' . $entry['sleep_feeling'],
'- Sport: ' . $entry['sport_minutes'], '- Sport: ' . $entry['sport_minutes'],
'- Sportarten: ' . implode(', ', $sportTypeValues), '- Sportarten: ' . implode(', ', $sportTypeValues),
'- Spaziergang-Modus: ' . (($entry['walk_mode'] ?? 'time') === 'steps' ? 'schritte' : 'zeit'), '- Spaziergang-Modus: ' . (($entry['walk_mode'] ?? 'time') === 'steps' ? 'schritte' : 'zeit'),
'- Spaziergang: ' . (($entry['walk_mode'] ?? 'time') === 'steps' ? (int) ($entry['walk_steps'] ?? 0) : (int) ($entry['walk_minutes'] ?? 0)), '- Spaziergang: ' . (($entry['walk_mode'] ?? 'time') === 'steps' ? (int) ($entry['walk_steps'] ?? 0) : (int) ($entry['walk_minutes'] ?? 0)),
'- Alkohol: ' . (!empty($entry['alcohol']) ? 'ja' : 'nein'),
'', '',
'## Bewertung', '## Bewertung',
'- Punkte: ' . format_points((float) $evaluation['total']) . ' / ' . format_points((float) $evaluation['max_total']), '- Punkte: ' . format_points((float) $evaluation['total']) . ' / ' . format_points((float) $evaluation['max_total']),
@@ -155,18 +268,164 @@ final class EntryRepository
'- Stimmung: ' . format_points((float) $evaluation['components']['mood']), '- Stimmung: ' . format_points((float) $evaluation['components']['mood']),
'- Energie: ' . format_points((float) $evaluation['components']['energy']), '- Energie: ' . format_points((float) $evaluation['components']['energy']),
'- Stress: ' . format_points((float) $evaluation['components']['stress']), '- Stress: ' . format_points((float) $evaluation['components']['stress']),
...(array_key_exists('pain', $evaluation['components']) ? ['- Schmerzen: ' . format_points((float) $evaluation['components']['pain'])] : []),
'- Schlafdauer: ' . format_points((float) $evaluation['components']['sleep_hours']), '- Schlafdauer: ' . format_points((float) $evaluation['components']['sleep_hours']),
'- Schlafgefühl: ' . format_points((float) $evaluation['components']['sleep_feeling']), '- Schlafgefühl: ' . format_points((float) $evaluation['components']['sleep_feeling']),
'- 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)),
'- 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;
}
} }
+356 -11
View File
@@ -6,22 +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'])))),
'sleep_hours' => max(0, min(24, (float) ($input['sleep_hours'] ?? 0))), 'pain' => max(1, min(10, (int) ($input['pain'] ?? 1))),
'sleep_feeling' => max(1, min(5, (int) ($input['sleep_feeling'] ?? 3))), 'pain_enabled' => $this->normalizeBoolean($input['pain_enabled'] ?? false),
'sport_minutes' => max(0, min(1440, (int) ($input['sport_minutes'] ?? 0))), 'sleep_hours' => max(0, min(24, (float) ($hasEventInput ? $derived['sleep_hours'] : ($input['sleep_hours'] ?? $derived['sleep_hours'])))),
'sleep_feeling' => max(1, min(5, (int) ($hasSummaryInput ? $derived['sleep_feeling'] : ($input['sleep_feeling'] ?? $derived['sleep_feeling'])))),
'sport_minutes' => max(0, min(1440, (int) ($hasEventInput ? $derived['sport_minutes'] : ($input['sport_minutes'] ?? $derived['sport_minutes'])))),
'sport_type' => $sportTypes[0] ?? '', 'sport_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'])))),
'note' => trim((string) ($input['note'] ?? '')), 'alcohol' => $this->normalizeBoolean($hasSummaryInput ? $derived['alcohol'] : ($input['alcohol'] ?? $derived['alcohol'])),
'note' => trim((string) ($input['note'] ?? $summary['comment'])),
'summary' => $summary,
'summary_comment' => $summary['comment'],
'summary_mood' => $summary['mood'],
'summary_energy' => $summary['energy'],
'summary_stress' => $summary['stress'],
'summary_alcohol' => !empty($summary['alcohol']),
'background_image' => trim((string) ($input['background_image'] ?? '')),
'health' => $health,
'events' => $events,
]; ];
} }
@@ -33,6 +60,8 @@ 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']);
$components = [ $components = [
'mood' => $entry['mood'] * (float) $scoring['mood_multiplier'], 'mood' => $entry['mood'] * (float) $scoring['mood_multiplier'],
@@ -43,19 +72,31 @@ 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,
'note' => $entry['note'] === '' ? 0 : (float) $scoring['journal_points'], 'note' => $entry['note'] === '' ? 0 : (float) $scoring['journal_points'],
]; ];
if ($painEnabled) {
$components['pain'] = (11 - $entry['pain']) * (float) $scoring['pain_multiplier'];
}
$total = round(array_sum($components), 1); $total = round(array_sum($components), 1);
$maxTotal = round( $maxTotal = round(
(10 * (float) $scoring['mood_multiplier']) + (10 * (float) $scoring['mood_multiplier']) +
(10 * (float) $scoring['energy_multiplier']) + (10 * (float) $scoring['energy_multiplier']) +
(10 * (float) $scoring['stress_multiplier']) + (10 * (float) $scoring['stress_multiplier']) +
($painEnabled ? (10 * (float) $scoring['pain_multiplier']) : 0.0) +
max(array_map('floatval', $scoring['sleep_duration_points'])) + max(array_map('floatval', $scoring['sleep_duration_points'])) +
(5 * (float) $scoring['sleep_feeling_multiplier']) + (5 * (float) $scoring['sleep_feeling_multiplier']) +
$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
); );
@@ -85,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) {
@@ -160,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'] ?? [];
@@ -176,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 === []) {
@@ -294,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']));
@@ -353,4 +681,21 @@ final class ScoringService
{ {
return $mode === 'steps' ? 'steps' : 'time'; return $mode === 'steps' ? 'steps' : 'time';
} }
private function normalizeBoolean(mixed $value): bool
{
if (is_bool($value)) {
return $value;
}
if (is_int($value) || is_float($value)) {
return (int) $value === 1;
}
if (!is_string($value)) {
return false;
}
return in_array(strtolower(trim($value)), ['1', 'true', 'yes', 'ja', 'on'], true);
}
} }
+320
View File
@@ -0,0 +1,320 @@
<?php
declare(strict_types=1);
final class SummaryRepository
{
private EntryCrypto $crypto;
public function __construct()
{
$this->crypto = new EntryCrypto();
}
public function all(string $username): array
{
$items = array_merge(
$this->readKind($username, 'weekly'),
$this->readKind($username, 'monthly')
);
usort($items, static function (array $left, array $right): int {
$leftDate = (string) ($left['date_to'] ?? '');
$rightDate = (string) ($right['date_to'] ?? '');
$byDate = strcmp($rightDate, $leftDate);
if ($byDate !== 0) {
return $byDate;
}
return strcmp((string) ($right['summary_key'] ?? ''), (string) ($left['summary_key'] ?? ''));
});
return $items;
}
public function weekly(string $username): array
{
return $this->readKind($username, 'weekly');
}
public function monthly(string $username): array
{
return $this->readKind($username, 'monthly');
}
public function find(string $username, string $kind, string $key): ?array
{
$path = $this->pathFor($username, $kind, $key);
if (!is_file($path)) {
return null;
}
$content = (string) file_get_contents($path);
$plaintext = $this->crypto->decrypt($content);
if ($this->crypto->shouldEncrypt() && !$this->crypto->isEncrypted($content)) {
file_put_contents($path, $this->crypto->encrypt($plaintext), LOCK_EX);
}
return $this->parse($plaintext, $kind, $key);
}
public function save(string $username, string $kind, string $key, array $summary): void
{
$normalized = $this->normalizeSummary($kind, $key, $summary);
$path = $this->pathFor($username, $kind, $key);
$directory = dirname($path);
if (!is_dir($directory)) {
mkdir($directory, 0775, true);
}
file_put_contents($path, $this->crypto->encrypt($this->toText($normalized)), LOCK_EX);
}
public function exportBackupFiles(string $username): array
{
$exports = [];
foreach (['weekly', 'monthly'] as $kind) {
foreach ($this->readKind($username, $kind) as $summary) {
$exports[] = [
'path' => 'summaries/' . $kind . '/' . (string) $summary['summary_key'] . '.txt',
'content' => $this->toText($summary),
];
}
}
return $exports;
}
public function importBackupFile(string $username, string $fileName, string $content): bool
{
$detected = $this->detectBackupFile($fileName);
if ($detected === null) {
return false;
}
$summary = $this->parse($content, $detected['kind'], $detected['key']);
if ($summary === null) {
throw new RuntimeException('Eine KI-Zusammenfassung aus dem Backup konnte nicht gelesen werden.');
}
$this->save($username, $detected['kind'], $detected['key'], $summary);
return true;
}
private function readKind(string $username, string $kind): array
{
$directory = $this->directoryFor($username, $kind);
if (!is_dir($directory)) {
return [];
}
$files = glob($directory . '/*.txt') ?: [];
rsort($files, SORT_STRING);
$summaries = [];
foreach ($files as $file) {
$key = basename($file, '.txt');
$content = (string) file_get_contents($file);
$plaintext = $this->crypto->decrypt($content);
if ($this->crypto->shouldEncrypt() && !$this->crypto->isEncrypted($content)) {
file_put_contents($file, $this->crypto->encrypt($plaintext), LOCK_EX);
}
$summary = $this->parse($plaintext, $kind, $key);
if ($summary !== null) {
$summaries[] = $summary;
}
}
return $summaries;
}
private function parse(string $content, string $kind, string $key): ?array
{
$plaintext = $this->crypto->decrypt($content);
$kind = $this->normalizeKind($kind);
if ($kind === null || !$this->isValidKey($kind, $key)) {
return null;
}
$title = $this->extract('/^Titel:\s*(.+)$/mu', $plaintext);
$type = $this->extract('/^Typ:\s*(.+)$/mu', $plaintext);
$createdAt = $this->extract('/^Erstellt am:\s*(.+)$/mu', $plaintext);
if (!preg_match('/^Zeitraum:\s*(\d{4}-\d{2}-\d{2})\s+bis\s+(\d{4}-\d{2}-\d{2})$/mu', $plaintext, $rangeMatch)) {
return null;
}
if (!preg_match('/^Zeitraum:\s*.+$\R\R([\s\S]*)\z/mu', $plaintext, $textMatch)) {
return null;
}
if ($title === null || $type === null || $createdAt === null) {
return null;
}
$expectedType = $kind === 'weekly' ? 'ki-wochenauswertung' : 'ki-monatsauswertung';
if (trim($type) !== $expectedType) {
return null;
}
$summary = [
'summary_kind' => $kind,
'summary_key' => $key,
'title' => trim($title),
'type' => $expectedType,
'created_at' => trim($createdAt),
'date_from' => trim((string) ($rangeMatch[1] ?? '')),
'date_to' => trim((string) ($rangeMatch[2] ?? '')),
'text' => trim((string) ($textMatch[1] ?? '')),
];
if (!$this->isValidDate($summary['date_from']) || !$this->isValidDate($summary['date_to'])) {
return null;
}
return $summary;
}
private function toText(array $summary): string
{
$normalized = $this->normalizeSummary(
(string) $summary['summary_kind'],
(string) $summary['summary_key'],
$summary
);
return implode("\n", [
'Titel: ' . $normalized['title'],
'Typ: ' . $normalized['type'],
'Erstellt am: ' . $normalized['created_at'],
'Zeitraum: ' . $normalized['date_from'] . ' bis ' . $normalized['date_to'],
'',
trim((string) $normalized['text']),
'',
]);
}
private function normalizeSummary(string $kind, string $key, array $summary): array
{
$kind = $this->normalizeKind($kind);
if ($kind === null || !$this->isValidKey($kind, $key)) {
throw new RuntimeException('Die Zusammenfassung hat einen ungültigen Schlüssel.');
}
$dateFrom = trim((string) ($summary['date_from'] ?? ''));
$dateTo = trim((string) ($summary['date_to'] ?? ''));
$createdAt = trim((string) ($summary['created_at'] ?? date(DATE_ATOM)));
$text = trim((string) ($summary['text'] ?? ''));
if (!$this->isValidDate($dateFrom) || !$this->isValidDate($dateTo)) {
throw new RuntimeException('Die Zusammenfassung hat einen ungültigen Zeitraum.');
}
if ($text === '') {
throw new RuntimeException('Die Zusammenfassung darf nicht leer sein.');
}
return [
'summary_kind' => $kind,
'summary_key' => $key,
'title' => trim((string) ($summary['title'] ?? $this->defaultTitle($kind, $key))),
'type' => $kind === 'weekly' ? 'ki-wochenauswertung' : 'ki-monatsauswertung',
'created_at' => $createdAt,
'date_from' => $dateFrom,
'date_to' => $dateTo,
'text' => $text,
];
}
private function defaultTitle(string $kind, string $key): string
{
if ($kind === 'weekly' && preg_match('/^(\d{4})-KW-(\d{2})$/', $key, $matches)) {
return 'Wochenzusammenfassung KW ' . $matches[2] . ' / ' . $matches[1];
}
if ($kind === 'monthly' && preg_match('/^(\d{4})-(\d{2})$/', $key, $matches)) {
return 'Monatszusammenfassung ' . $matches[2] . ' / ' . $matches[1];
}
return 'KI-Zusammenfassung';
}
private function detectBackupFile(string $fileName): ?array
{
$normalized = str_replace('\\', '/', trim($fileName));
$baseName = basename($normalized);
if (preg_match('/^(\d{4}-KW-\d{2})\.txt$/', $baseName, $matches)) {
return [
'kind' => 'weekly',
'key' => (string) $matches[1],
];
}
if (preg_match('/^(\d{4}-\d{2})\.txt$/', $baseName, $matches)) {
return [
'kind' => 'monthly',
'key' => (string) $matches[1],
];
}
return null;
}
private function directoryFor(string $username, string $kind): string
{
return storage_path('users/' . normalize_username($username) . '/summaries/' . $kind);
}
private function pathFor(string $username, string $kind, string $key): string
{
return $this->directoryFor($username, $kind) . '/' . $key . '.txt';
}
private function normalizeKind(string $kind): ?string
{
$kind = trim($kind);
return in_array($kind, ['weekly', 'monthly'], true) ? $kind : null;
}
private function isValidKey(string $kind, string $key): bool
{
if ($kind === 'weekly' && preg_match('/^\d{4}-KW-(\d{2})$/', $key, $matches) === 1) {
$week = (int) ($matches[1] ?? 0);
return $week >= 1 && $week <= 53;
}
if ($kind === 'monthly' && preg_match('/^\d{4}-(\d{2})$/', $key, $matches) === 1) {
$month = (int) ($matches[1] ?? 0);
return $month >= 1 && $month <= 12;
}
return false;
}
private function isValidDate(string $date): bool
{
$parsed = DateTimeImmutable::createFromFormat('Y-m-d', $date);
return $parsed !== false && $parsed->format('Y-m-d') === $date;
}
private function extract(string $pattern, string $content): ?string
{
if (preg_match($pattern, $content, $matches) !== 1) {
return null;
}
return trim((string) ($matches[1] ?? ''));
}
}
+252 -1
View File
@@ -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
View File
@@ -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));
}
} }
+24 -1
View File
@@ -19,6 +19,22 @@ 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' => [
'pain_enabled' => false,
],
'sport_types' => [ 'sport_types' => [
[ [
'id' => 'running', 'id' => 'running',
@@ -85,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',
@@ -115,6 +131,7 @@ final class Defaults
'mood_multiplier' => 3, 'mood_multiplier' => 3,
'energy_multiplier' => 2, 'energy_multiplier' => 2,
'stress_multiplier' => 2, 'stress_multiplier' => 2,
'pain_multiplier' => 3,
'sleep_feeling_multiplier' => 2, 'sleep_feeling_multiplier' => 2,
'sleep_duration_points' => [ 'sleep_duration_points' => [
'lt4' => 0, 'lt4' => 0,
@@ -148,7 +165,13 @@ 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,
], ],
'ratings' => [ 'ratings' => [
['label' => 'Scheißtag', 'min' => 0, 'max' => 39], ['label' => 'Scheißtag', 'min' => 0, 'max' => 39],
+140
View File
@@ -0,0 +1,140 @@
<?php
declare(strict_types=1);
final class EntryCrypto
{
private const HEADER = "MOODENC1\n";
private string $fallbackKeyPath;
public function __construct()
{
$this->fallbackKeyPath = storage_path('system/entry-encryption.key');
}
public function isAvailable(): bool
{
return function_exists('openssl_encrypt')
&& function_exists('openssl_decrypt')
&& function_exists('random_bytes');
}
public function shouldEncrypt(): bool
{
return $this->isAvailable();
}
public function isEncrypted(string $content): bool
{
return str_starts_with($content, self::HEADER);
}
public function encrypt(string $plaintext): string
{
if (!$this->shouldEncrypt()) {
return $plaintext;
}
$iv = random_bytes(12);
$tag = '';
$ciphertext = openssl_encrypt(
$plaintext,
'aes-256-gcm',
$this->key(),
OPENSSL_RAW_DATA,
$iv,
$tag
);
if (!is_string($ciphertext) || $tag === '') {
throw new RuntimeException('Die Tagesdatei konnte nicht verschlüsselt werden.');
}
$payload = json_encode([
'iv' => base64_encode($iv),
'tag' => base64_encode($tag),
'data' => base64_encode($ciphertext),
], JSON_UNESCAPED_SLASHES);
if (!is_string($payload)) {
throw new RuntimeException('Die verschlüsselte Tagesdatei konnte nicht kodiert werden.');
}
return self::HEADER . $payload;
}
public function decrypt(string $content): string
{
if (!$this->isEncrypted($content) || !$this->shouldEncrypt()) {
return $content;
}
$payload = substr($content, strlen(self::HEADER));
$decoded = json_decode($payload, true);
if (
!is_array($decoded)
|| !is_string($decoded['iv'] ?? null)
|| !is_string($decoded['tag'] ?? null)
|| !is_string($decoded['data'] ?? null)
) {
throw new RuntimeException('Die verschlüsselte Tagesdatei ist ungültig.');
}
$plaintext = openssl_decrypt(
(string) base64_decode($decoded['data'], true),
'aes-256-gcm',
$this->key(),
OPENSSL_RAW_DATA,
(string) base64_decode($decoded['iv'], true),
(string) base64_decode($decoded['tag'], true)
);
if (!is_string($plaintext)) {
throw new RuntimeException('Die Tagesdatei konnte nicht entschlüsselt werden.');
}
return $plaintext;
}
private function key(): string
{
$configured = trim((string) ($_ENV['MOOD_STORAGE_KEY'] ?? getenv('MOOD_STORAGE_KEY') ?: ''));
if ($configured !== '') {
return hash('sha256', $configured, true);
}
$stored = $this->readFallbackKey();
if ($stored !== null) {
return $stored;
}
$raw = random_bytes(32);
$directory = dirname($this->fallbackKeyPath);
if (!is_dir($directory)) {
mkdir($directory, 0775, true);
}
file_put_contents($this->fallbackKeyPath, base64_encode($raw), LOCK_EX);
@chmod($this->fallbackKeyPath, 0600);
return $raw;
}
private function readFallbackKey(): ?string
{
if (!is_file($this->fallbackKeyPath)) {
return null;
}
$raw = trim((string) file_get_contents($this->fallbackKeyPath));
if ($raw === '') {
return null;
}
$decoded = base64_decode($raw, true);
return is_string($decoded) && strlen($decoded) === 32 ? $decoded : null;
}
}
+335
View File
@@ -0,0 +1,335 @@
<?php
declare(strict_types=1);
final class OpenAiSummaryService
{
private const CHAT_COMPLETIONS_ENDPOINT = 'https://api.openai.com/v1/chat/completions';
private const WEEK_SYSTEM_PROMPT = <<<'TEXT'
Du bist ein verhaltenstherapeutisch orientierter Assistent für einen persönlichen Mood-Tracker.
Deine Aufgabe ist es, aus den Einträgen einer Kalenderwoche eine ruhige, dichte und psychologisch plausible Wochenzusammenfassung zu schreiben. Du sollst Muster erkennen, Belastungen benennen, Ressourcen sichtbar machen und die Tagebuchtexte mit dem Gefühlsbild der Woche in Beziehung setzen.
Die Zusammenfassung soll nicht wie ein Tagebuch, nicht wie ein Bericht und nicht wie ein Ratgebertext klingen, sondern wie eine verdichtete persönliche Einordnung der Woche.
Verbindliche Stilregeln:
- Schreibe konsequent in der Du-Form.
- Schreibe in natürlichem, ruhigem Fließtext.
- Schreibe dicht, konkret, unaufgeregt und persönlich.
- Klinge reflektiert, aber nicht klinisch.
- Klinge verhaltenstherapeutisch orientiert, aber nicht wie ein Therapiebericht.
Inhaltliche Regeln:
- Nutze sowohl die Tagebuchtexte als auch die Stimmungs- und Belastungswerte.
- Übersetze Werte und Skalen in sprachliche Einordnungen wie „stark schwankend“, „deutlich belastet“, „wenig erholt“, „etwas stabiler“ oder „spürbar entlastet“.
- Nenne keine konkreten Zahlenwerte im Fließtext.
- Nenne keine konkreten Kalenderdaten im Fließtext.
- Wenn zeitliche Orientierung wirklich nötig ist, nutze höchstens Formulierungen wie „zu Wochenbeginn“, „zur Wochenmitte“ oder „gegen Ende der Woche“.
- Schreibe nicht chronologisch und gehe nicht Tag für Tag durch die Woche.
- Verdichte stattdessen die Woche zu Mustern, Spannungen, Auslösern, Belastungen, Gegenpolen und stabilisierenden Momenten.
- Einzelne Tage sollen nur erwähnt werden, wenn sie für das Verständnis der ganzen Woche wirklich zentral sind.
- Beschreibe nicht nur, was passiert ist, sondern ordne ein, wie es auf das Erleben gewirkt hat.
- Benenne Belastungen klar, ohne zu dramatisieren.
- Benenne Ressourcen klar, ohne sie künstlich aufzuwerten.
- Verharmlose Warnsignale nicht.
- Erfinde nichts, was nicht aus den Daten ableitbar ist.
- Wenn die Datenlage lückenhaft ist, erwähne das kurz und unaufgeregt.
Was vermieden werden soll:
- Keine Listen.
- Keine Emojis.
- Keine Kalendersprüche.
- Keine direkten Handlungsanweisungen im Befehlston.
- Keine pauschalen Beziehungstipps.
- Keine künstlich optimistischen Schlüsse über die Beziehung.
- Keine klinisch-distanzierten Formulierungen wie „deine Einträge zeigen“, „die durchschnittliche Stimmung betrug“ oder „es äußerten sich deutliche Schwankungen“.
- Keine formelhaften Sätze wie „es ist wichtig zu erkennen“, „es ist verständlich“, „es wäre hilfreich“, „könnte helfen“ oder „Zeichen von Selbstwirksamkeit“, wenn sie nicht wirklich natürlich klingen.
- Kein schulbuchhafter Ton.
- Kein Auswertungs- oder Gutachtenstil.
- Vermeide weichgespülte oder formelhafte Wendungen wie „es zeigt sich, dass“, „es bleibt zu beobachten“, „könnte hilfreich sein“, „könnte wertvoll sein“, „könnte als Belastungsfaktor wahrgenommen werden“ oder „es ist spürbar“.
- Schreibe nicht wie ein psychologischer Infotext, sondern wie eine dichte persönliche Einordnung.
- Vermeide allgemeine Schlussformeln über „Ressourcen“, „Dynamiken“ oder „Rituale“, wenn sie nicht konkret aus der Woche entstehen.
Zusätzliche Regeln:
- Formuliere klarer und direkter, ohne hart oder anklagend zu werden.
- Du kannst den Nutzer ruhig mit du ansprechen, um persönlicher zu wirken
- Wenn an weniger als 2 Tagen Alkohol eingetragen wurde, erwähne das höchstens knapp und ohne Warnung.
- Wenn an 3 oder mehr Tagen Alkohol eingetragen wurde, benenne das ruhig als möglichen Belastungsfaktor.
- Wenn in dieser Woche weniger als 2 Mal Sport gemacht wurde, darf am Ende höchstens ein kleiner, alltagsnaher und motivierender Impuls für die kommende Woche stehen.
- Dieser Impuls soll kurz bleiben und nicht wie ein Ratschlagstext klingen.
Aufbau:
- Beginne mit einer knappen Einordnung des Gesamtmusters der Woche.
- Verdichte danach die wichtigsten Belastungen und Gegenpole.
- Schließe mit einer kurzen, vorsichtigen Einordnung, was für die nächste Woche eher im Vordergrund stehen könnte, zum Beispiel Stabilisierung, Entlastung, Struktur oder Aktivierung.
- Diese Schlusspassage soll beobachtend klingen, nicht belehrend.
Länge: etwa 180 bis 280 Wörter.
TEXT;
private const WEEK_USER_TEMPLATE = <<<'TEXT'
Bitte schreibe eine Wochenzusammenfassung für den folgenden Zeitraum.
Voraussetzung:
Es liegen mindestens 3 ausgefüllte Tagebucheinträge in dieser Woche vor.
Zeitraum:
{{WEEK_LABEL}}
Wochendaten:
- Anzahl ausgefüllter Einträge: {{ENTRY_COUNT}}
- Getrackte Tage insgesamt: {{TRACKED_DAYS}}
- Durchschnittliche Stimmung: {{AVG_MOOD}}
- Durchschnittlicher Stress: {{AVG_STRESS}}
- Durchschnittliche Energie: {{AVG_ENERGY}}
- Durchschnittlicher Schlaf: {{AVG_SLEEP}}
- Anzahl Spaziergänge: {{WALK_DAYS}}
- Anzahl Sporttage: {{SPORT_DAYS}}
- Alkoholtage: {{ALCOHOL_DAYS}}
- Bester Tag: {{BEST_DAY}}
- Schwerster Tag: {{WORST_DAY}}
Tägliche Einträge:
{{DAILY_ENTRIES}}
Aufgabe:
Schreibe keine tagebuchartige oder chronologische Nacherzählung. Fasse die Woche als Gesamtbild zusammen. Arbeite heraus, welche Belastungen, Konflikte, Aktivitäten, Gedankenlagen oder kleinen Gegenpole das Erleben geprägt haben und wie sie mit dem Gefühlsbild der Woche zusammenhängen.
Wichtige Vorgaben:
- Verwandle Zahlen und Skalenwerte in sprachliche Einordnungen, statt sie direkt zu nennen.
- Verwende keine konkreten Datumsangaben.
- Schreibe nicht Tag für Tag.
- Verdichte Muster statt Abläufe.
- Wenn überhaupt zeitliche Einordnung nötig ist, nutze höchstens Formulierungen wie „zu Wochenbeginn“, „zur Wochenmitte“ oder „gegen Ende der Woche“.
- Gib keine pauschalen Beziehungstipps.
- Bleibe wohlwollend, ruhig und klar.
- Klinge nicht klinisch und nicht schulbuchhaft.
- Vermeide Floskeln und Standardformulierungen aus Ratgeber- oder Therapietexten.
- Wenn an weniger als 2 Tagen Alkohol eingetragen wurde, erwähne das höchstens knapp und ohne Warnung.
- Wenn an 3 oder mehr Tagen Alkohol eingetragen wurde, benenne das ruhig als möglichen Belastungsfaktor.
- Wenn in dieser Woche weniger als 2 Mal Sport gemacht wurde, formuliere am Ende höchstens einen kurzen, alltagsnahen Impuls für die kommende Woche.
- Schreibe klar und möglichst konkret statt vorsichtig-abstrakt.
- Verwende wenige Konjunktive.
- Vermeide therapeutische Standardformulierungen und allgemeine Lebenshilfe-Sprache.
- Wenn Alkohol nur an einem Tag vorkam und nicht zentral für die Woche war, erwähne ihn nicht.
- Der Schlussteil soll kurz sein und nicht wie ein Coaching-Impuls klingen.
- Der letzte Absatz darf höchstens 2 Sätze lang sein.
- Er soll eher eine ruhige Einordnung des nächsten Schwerpunkts geben als konkrete Tipps.
Die Zusammenfassung soll wie eine verdichtete persönliche Einordnung der Woche klingen, nicht wie ein Bericht.
Schreibe einen zusammenhängenden Fließtext mit etwa 180 bis 280 Wörtern.
TEXT;
private const MONTH_SYSTEM_PROMPT = <<<'TEXT'
Du bist ein verhaltenstherapeutisch orientierter Assistent für einen persönlichen Mood-Tracker.
Deine Aufgabe ist es, aus bereits vorhandenen KI-Wochenzusammenfassungen eine Monatszusammenfassung zu erstellen. Du sollst keine Tagesdetails neu auswerten, sondern die vorhandenen Wochenrückblicke verdichten, Muster über mehrere Wochen erkennen und einen übergeordneten Verlauf beschreiben.
Wichtige Regeln:
- Schreibe empathisch, klar, ruhig und konkret.
- Schreibe in natürlichem Fließtext.
- Arbeite nur mit den vorliegenden Wochenzusammenfassungen und den zugehörigen Wochenkennzahlen.
- Suche nach Entwicklungen über den Monat hinweg: Stabilisierung, Verschlechterung, Schwankungen, wiederkehrende Konflikte, Ressourcen, Belastungsschwerpunkte.
- Stelle keine Diagnosen.
- Kein Fachjargon-Overkill.
- Keine Listen.
- Keine Emojis.
- Keine Floskeln.
- Erfinde nichts, was nicht aus den Wochenzusammenfassungen ableitbar ist.
- Wenn die Datengrundlage schmal ist, erwähne das kurz.
Am Ende soll eine vorsichtige therapeutische Einordnung stehen, was sich im Monatsverlauf als besonders relevant gezeigt hat.
Länge: etwa 300 bis 500 Wörter.
TEXT;
private const MONTH_USER_TEMPLATE = <<<'TEXT'
Bitte schreibe eine Monatszusammenfassung für den folgenden Zeitraum.
Voraussetzung:
Es liegen mindestens 2 bereits erzeugte KI-Wochenzusammenfassungen für diesen Monat vor.
Zeitraum:
{{MONTH_LABEL}}
Monatsdaten:
- Anzahl verfügbarer KI-Wochenzusammenfassungen: {{WEEKLY_SUMMARY_COUNT}}
- Durchschnittliche Stimmung im Monat: {{AVG_MOOD_MONTH}}
- Durchschnittlicher Stress im Monat: {{AVG_STRESS_MONTH}}
- Durchschnittliche Energie im Monat: {{AVG_ENERGY_MONTH}}
- Durchschnittlicher Schlaf im Monat: {{AVG_SLEEP_MONTH}}
Vorliegende KI-Wochenzusammenfassungen:
{{WEEKLY_SUMMARIES}}
Aufgabe:
Verdichte die Wochenzusammenfassungen zu einem stimmigen Monatsrückblick. Beschreibe, welche Muster sich über mehrere Wochen zeigen, welche Belastungen besonders prägend waren, welche Ressourcen erkennbar wurden und ob sich eher Stabilisierung, Zuspitzung oder starke Schwankung zeigt.
Schreibe einen zusammenhängenden Fließtext mit etwa 300 bis 500 Wörtern.
TEXT;
private AiConfigRepository $config;
public function __construct(AiConfigRepository $config)
{
$this->config = $config;
}
public function configuration(): array
{
$config = $this->config->get();
return [
'model' => (string) ($config['model'] ?? 'gpt-4o-mini'),
'timeout' => (int) ($config['timeout'] ?? 25),
'has_api_key' => $this->apiKey() !== '',
'available' => $this->isAvailable(),
];
}
public function isAvailable(): bool
{
return function_exists('curl_init') && $this->apiKey() !== '';
}
public function generateWeekly(array $payload): string
{
return $this->requestSummary(
self::WEEK_SYSTEM_PROMPT,
$this->renderTemplate(self::WEEK_USER_TEMPLATE, $payload)
);
}
public function generateMonthly(array $payload): string
{
return $this->requestSummary(
self::MONTH_SYSTEM_PROMPT,
$this->renderTemplate(self::MONTH_USER_TEMPLATE, $payload)
);
}
private function requestSummary(string $systemPrompt, string $userPrompt): string
{
if (!function_exists('curl_init')) {
throw new RuntimeException('Die KI-Zusammenfassung ist auf diesem Server aktuell nicht verfügbar.');
}
$apiKey = $this->apiKey();
if ($apiKey === '') {
throw new RuntimeException('Für KI-Zusammenfassungen fehlt der OpenAI API-Key.');
}
$config = $this->config->get();
$body = json_encode([
'model' => (string) ($config['model'] ?? 'gpt-4o-mini'),
'messages' => [
[
'role' => 'system',
'content' => $systemPrompt,
],
[
'role' => 'user',
'content' => $userPrompt,
],
],
'temperature' => 0.8,
'max_completion_tokens' => 900,
'store' => false,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($body)) {
throw new RuntimeException('Die KI-Anfrage konnte nicht vorbereitet werden.');
}
$handle = curl_init(self::CHAT_COMPLETIONS_ENDPOINT);
if ($handle === false) {
throw new RuntimeException('Die Verbindung zur KI konnte nicht vorbereitet werden.');
}
curl_setopt_array($handle, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . $apiKey,
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HEADER => false,
CURLOPT_TIMEOUT => (int) ($config['timeout'] ?? 25),
]);
$responseBody = curl_exec($handle);
$status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
$error = curl_error($handle);
curl_close($handle);
if ($responseBody === false || $error !== '') {
throw new RuntimeException('Die KI-Anfrage ist fehlgeschlagen oder hat zu lange gedauert.');
}
$decoded = json_decode((string) $responseBody, true);
if (!is_array($decoded)) {
throw new RuntimeException('Die KI-Antwort konnte nicht gelesen werden.');
}
if ($status < 200 || $status >= 300) {
$message = trim((string) ($decoded['error']['message'] ?? ''));
if ($message === '') {
$message = 'Die KI-Anfrage konnte gerade nicht verarbeitet werden.';
}
throw new RuntimeException($message);
}
$text = trim($this->extractResponseText($decoded));
if ($text === '') {
throw new RuntimeException('Die KI hat keine verwertbare Zusammenfassung zurückgegeben.');
}
return $text;
}
private function extractResponseText(array $response): string
{
$content = $response['choices'][0]['message']['content'] ?? null;
if (is_string($content)) {
return $content;
}
if (is_array($content)) {
$parts = [];
foreach ($content as $item) {
if (is_string($item)) {
$parts[] = $item;
continue;
}
if (is_array($item) && is_string($item['text'] ?? null)) {
$parts[] = (string) $item['text'];
}
}
return trim(implode("\n\n", array_filter($parts)));
}
return '';
}
private function renderTemplate(string $template, array $payload): string
{
$replacements = [];
foreach ($payload as $key => $value) {
$replacements['{{' . strtoupper((string) $key) . '}}'] = is_scalar($value)
? (string) $value
: '';
}
return strtr($template, $replacements);
}
private function apiKey(): string
{
return trim((string) ($_ENV['OPENAI_API_KEY'] ?? getenv('OPENAI_API_KEY') ?: ''));
}
}
+4
View File
@@ -5,11 +5,15 @@ declare(strict_types=1);
require __DIR__ . '/helpers.php'; require __DIR__ . '/helpers.php';
require __DIR__ . '/Support/Defaults.php'; require __DIR__ . '/Support/Defaults.php';
require __DIR__ . '/Support/Auth.php'; require __DIR__ . '/Support/Auth.php';
require __DIR__ . '/Support/EntryCrypto.php';
require __DIR__ . '/Support/OpenAiSummaryService.php';
require __DIR__ . '/Support/View.php'; require __DIR__ . '/Support/View.php';
require __DIR__ . '/Support/WebPushService.php'; require __DIR__ . '/Support/WebPushService.php';
require __DIR__ . '/Domain/AiConfigRepository.php';
require __DIR__ . '/Domain/UserRepository.php'; require __DIR__ . '/Domain/UserRepository.php';
require __DIR__ . '/Domain/SettingsRepository.php'; require __DIR__ . '/Domain/SettingsRepository.php';
require __DIR__ . '/Domain/EntryRepository.php'; require __DIR__ . '/Domain/EntryRepository.php';
require __DIR__ . '/Domain/SummaryRepository.php';
require __DIR__ . '/Domain/LoginThrottle.php'; require __DIR__ . '/Domain/LoginThrottle.php';
require __DIR__ . '/Domain/NotificationRepository.php'; require __DIR__ . '/Domain/NotificationRepository.php';
require __DIR__ . '/Domain/ScoringService.php'; require __DIR__ . '/Domain/ScoringService.php';
+325
View File
@@ -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,109 @@ 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
{
try {
$current = new DateTimeImmutable($value);
} catch (Throwable) {
return $value;
}
$months = [
1 => 'Januar',
2 => 'Februar',
3 => 'März',
4 => 'April',
5 => 'Mai',
6 => 'Juni',
7 => 'Juli',
8 => 'August',
9 => 'September',
10 => 'Oktober',
11 => 'November',
12 => 'Dezember',
];
return $current->format('j.') . ' ' . $months[(int) $current->format('n')] . ' ' . $current->format('Y') . ' um ' . $current->format('H:i');
}
function format_compact_datetime(string $value): string
{
try {
$current = new DateTimeImmutable($value);
} catch (Throwable) {
return $value;
}
return $current->format('d.m.Y · H:i');
}
function iso_week_key(string $date): string
{
$current = DateTimeImmutable::createFromFormat('Y-m-d', $date);
if ($current === false) {
return date('o-\K\W-W');
}
return $current->format('o-\K\W-W');
}
function month_key(string $date): string
{
$current = DateTimeImmutable::createFromFormat('Y-m-d', $date);
if ($current === false) {
return date('Y-m');
}
return $current->format('Y-m');
}
function iso_week_label(string $key): string
{
if (preg_match('/^(\d{4})-KW-(\d{2})$/', $key, $matches) === 1) {
return 'KW ' . $matches[2] . ' / ' . $matches[1];
}
return $key;
}
function month_label(string $key): string
{
if (preg_match('/^(\d{4})-(\d{2})$/', $key, $matches) !== 1) {
return $key;
}
$months = [
'01' => 'Januar',
'02' => 'Februar',
'03' => 'März',
'04' => 'April',
'05' => 'Mai',
'06' => 'Juni',
'07' => 'Juli',
'08' => 'August',
'09' => 'September',
'10' => 'Oktober',
'11' => 'November',
'12' => 'Dezember',
];
return ($months[$matches[2]] ?? $matches[2]) . ' ' . $matches[1];
}
function icon_path(string $name): string function icon_path(string $name): string
{ {
return '/assets/icons/' . $name . '.svg'; return '/assets/icons/' . $name . '.svg';
@@ -232,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 [
@@ -352,6 +477,37 @@ function base64url_decode(string $data): string
return $decoded; return $decoded;
} }
function uploaded_files(string $field): array
{
$raw = $_FILES[$field] ?? null;
if (!is_array($raw) || !isset($raw['name'])) {
return [];
}
if (!is_array($raw['name'])) {
return [[
'name' => (string) ($raw['name'] ?? ''),
'type' => (string) ($raw['type'] ?? ''),
'tmp_name' => (string) ($raw['tmp_name'] ?? ''),
'error' => (int) ($raw['error'] ?? UPLOAD_ERR_NO_FILE),
'size' => (int) ($raw['size'] ?? 0),
]];
}
$files = [];
foreach ($raw['name'] as $index => $name) {
$files[] = [
'name' => (string) ($name ?? ''),
'type' => (string) ($raw['type'][$index] ?? ''),
'tmp_name' => (string) ($raw['tmp_name'][$index] ?? ''),
'error' => (int) ($raw['error'][$index] ?? UPLOAD_ERR_NO_FILE),
'size' => (int) ($raw['size'][$index] ?? 0),
];
}
return $files;
}
function normalize_sport_type_id(string $value): string function normalize_sport_type_id(string $value): string
{ {
$value = trim(strtr($value, [ $value = trim(strtr($value, [
@@ -500,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',
};
}
+38 -27
View File
@@ -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&amp;date=<?= e(rawurlencode((string) ($dashboardPrevDate ?? shift_date(today(), -1)))) ?>">
<link rel="prefetch" href="/?view=day&amp;date=<?= e(rawurlencode((string) ($dashboardNextDate ?? shift_date(today(), 1)))) ?>">
<?php endif; ?>
<link rel="stylesheet" href="/assets/css/app.css?v=<?= e($cssVersion) ?>">
<script defer src="/assets/js/app.js?v=<?= e($jsVersion) ?>"></script>
</head> </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">
@@ -112,24 +117,30 @@ $brandSubtitle = match ($page) {
<?php endforeach; ?> <?php endforeach; ?>
<?= $content ?> <?= $content ?>
</main>
<?php if (!$immersiveDashboard): ?>
<footer class="site-footer glass-panel">
<a class="site-footer__link" href="https://git.hnz.io/hnzio/mood-tracking/releases" target="_blank" rel="noreferrer">Version 1.7.0</a>
<a class="site-footer__link" href="https://hnz.io" target="_blank" rel="noreferrer">(c) 2026 @hnz.io</a>
</footer>
<?php endif; ?>
</main>
<?php if ($authUser !== null): ?> <?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&amp;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&amp;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&amp;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>
+265 -61
View File
@@ -1,94 +1,298 @@
<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>Alle gespeicherten Tage</h3>
$archiveUrl = static function (array $params = []) use ($baseParams): string {
$query = array_filter(array_merge($baseParams, $params), static fn (mixed $value): bool => $value !== null && $value !== '');
return $query === [] ? '/archive' : '/archive?' . http_build_query($query);
};
$detailType = $selectedEntry !== null
? 'day'
: ($selectedWeek !== null
? 'week'
: ($selectedMonth !== null ? 'month' : null));
$detailOpen = $detailType !== null;
?>
<section class="archive-page">
<article class="glass-panel archive-shell">
<div class="archive-toolbar archive-toolbar--compact">
<nav class="archive-switcher" aria-label="Archivansicht">
<a class="archive-switcher__item <?= $archiveView === 'days' ? 'active' : '' ?>" href="<?= e('/archive?view=days' . ($archiveFilterMonth !== '' ? '&filter_month=' . rawurlencode($archiveFilterMonth) : '')) ?>">Tage</a>
<a class="archive-switcher__item <?= $archiveView === 'weeks' ? 'active' : '' ?>" href="<?= e('/archive?view=weeks' . ($archiveFilterMonth !== '' ? '&filter_month=' . rawurlencode($archiveFilterMonth) : '')) ?>">Wochen</a>
<a class="archive-switcher__item <?= $archiveView === 'months' ? 'active' : '' ?>" href="<?= e('/archive?view=months' . ($archiveFilterMonth !== '' ? '&filter_month=' . rawurlencode($archiveFilterMonth) : '')) ?>">Monate</a>
</nav>
<form method="get" action="/archive" class="archive-filter">
<input type="hidden" name="view" value="<?= e($archiveView) ?>">
<label>
<span>Zeitraum</span>
<select name="filter_month" onchange="this.form.submit()">
<option value="">Alle Monate</option>
<?php foreach ($archiveMonthOptions as $monthOption): ?>
<option value="<?= e($monthOption) ?>" <?= $archiveFilterMonth === $monthOption ? 'selected' : '' ?>><?= e(month_label($monthOption)) ?></option>
<?php endforeach; ?>
</select>
</label>
</form>
</div> </div>
<span class="chart-chip"><?= e((string) count($entries)) ?> Einträge</span>
<div class="archive-workspace">
<section class="archive-main">
<?php if ($archiveView === 'days'): ?>
<div class="archive-list-header">
<div>
<p class="eyebrow">Tage</p>
<h4>Gespeicherte Tage</h4>
</div>
<span class="chart-chip"><?= e((string) count($entries)) ?> Tage</span>
</div> </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; ?>
</article> <?php elseif ($archiveView === 'weeks'): ?>
<div class="archive-list-header">
<div>
<p class="eyebrow">Wochen</p>
<h4>Wöchentliche KI-Rückblicke</h4>
</div>
<span class="chart-chip"><?= e((string) count($weeklyArchive)) ?> Wochen</span>
</div>
<aside class="stack-column"> <?php if ($weeklyArchive === []): ?>
<?php if ($selectedEntry !== null): ?> <p class="empty-state">Für diesen Zeitraum sind noch keine Wochen im Archiv vorhanden.</p>
<article class="glass-panel detail-card"> <?php else: ?>
<p class="eyebrow">Ausgewählt</p> <div class="archive-rows archive-rows--summary">
<h3><?= e(format_display_date($selectedEntry['date'])) ?></h3> <?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>
<?php if (!empty($settings['tracking']['pain_enabled'])): ?>
<div><dt>Schmerzen</dt><dd><?= e((string) $selectedEntry['pain']) ?>/10</dd></div>
<?php endif; ?>
<div><dt>Schlaf</dt><dd><?= e((string) $selectedEntry['sleep_hours']) ?> h</dd></div> <div><dt>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>
</dl> </dl>
<div class="note-box"> <div class="note-box">
<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 aus, um alle Werte anzuzeigen oder direkt in die Bearbeitung zu springen.</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 -69
View File
@@ -1,86 +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> }
<?php else: ?>
<div class="hero-score">-</div> if ($mode === 'percent') {
<p class="hero-label">Noch kein Eintrag für heute</p> 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; ?> <?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&amp;date=<?= e(rawurlencode(today())) ?>">Heute</a>
<a class="<?= $dashboardView === 'week' ? 'active' : '' ?>" href="/?view=week&amp;date=<?= e(rawurlencode(today())) ?>">Woche</a>
<a class="<?= $dashboardView === 'month' ? 'active' : '' ?>" href="/?view=month&amp;date=<?= e(rawurlencode(today())) ?>">Monat</a>
</nav>
<button class="dashboard-settings glass-panel" type="button" data-settings-menu-open aria-label="Optionen öffnen">
<img src="<?= e(icon_path('options')) ?>" alt="">
</button>
</header>
<?php if ($dashboardView === 'day'): ?>
<div class="dashboard-day" data-day-swipe data-prev-date="<?= e($dashboardPrevDate) ?>" data-next-date="<?= e($dashboardNextDate) ?>">
<div class="dashboard-day-slider" data-day-slider-shell>
<span class="day-slide-hint day-slide-hint--prev" data-day-slide-prev-hint><span class="day-slide-hint__arrow" aria-hidden="true"></span>Vorherigen Tag laden</span>
<span class="day-slide-hint day-slide-hint--next" data-day-slide-next-hint>Nächster Tag laden<span class="day-slide-hint__arrow" aria-hidden="true"></span></span>
<div class="dashboard-day__hero" data-day-slider>
<p class="dashboard-day__eyebrow"><?= e((string) $dayWeekday) ?></p>
<h1><?= e(format_display_date((string) $dayEntry['date'], false)) ?></h1>
<nav class="dashboard-compare-strip" aria-label="Tagesvergleich" data-day-strip>
<span class="score-scale score-scale--day" aria-hidden="true"><span>+2</span><span>+1</span><span>0</span><span>-1</span><span>-2</span></span>
<?php foreach ($dashboardCompareDays as $compareDay): ?>
<a class="compare-day offset-<?= e((string) ($compareDay['offset'] ?? 0)) ?><?= !empty($compareDay['is_current']) ? ' is-current' : '' ?><?= empty($compareDay['has_content']) ? ' is-empty' : '' ?>" href="/?view=day&amp;date=<?= e(rawurlencode((string) $compareDay['date'])) ?>">
<span class="compare-day__line<?= empty($compareDay['has_content']) ? ' is-empty' : '' ?><?= !empty($compareDay['is_current']) ? ' is-primary' : '' ?> score-<?= e((string) ($compareDay['score_level'] ?? 'empty')) ?> compare-tone-<?= e((string) ($compareDay['line_tone'] ?? 'empty')) ?>">
<span class="compare-day__marker"></span>
</span>
</a>
<?php endforeach; ?>
</nav>
</div>
</div>
<button class="day-summary-card glass-panel<?= $dashboardHasContent ? ' is-filled' : '' ?>" type="button" data-summary-overlay-open>
<span class="day-summary-card__label">Tagesbilanz</span>
<strong class="day-summary-card__title"><?= $summaryComment !== '' ? e($summaryComment) : 'Tagesbilanz' ?></strong>
<?php if ($daySteps > 0): ?>
<span class="day-summary-card__chips">
<span class="day-chip day-chip--score">Bilanz <?= e($formatBalanceValue($dayEntry)) ?></span>
<span class="day-chip"><?= e(number_format($daySteps, 0, ',', '.')) ?> Schritte</span>
<?php if ($dayStepBonus > 0): ?>
<span class="day-chip day-chip--bonus">+<?= e(format_points($dayStepBonus)) ?> Punkt</span>
<?php endif; ?>
</span>
<?php else: ?>
<span class="day-summary-card__chips"><span class="day-chip day-chip--score">Bilanz <?= e($formatBalanceValue($dayEntry)) ?></span></span>
<?php endif; ?>
</button>
<section class="dashboard-moments-block">
<div class="section-head section-head--compact section-head--dashboard">
<div>
<p class="eyebrow">Deine Momente</p>
<h2>Momente des Tages</h2>
</div>
</div>
<div class="timeline-list">
<?php if ($dashboardTimeline === []): ?>
<article class="timeline-card timeline-card--empty glass-panel">
<div class="timeline-card__body">
<h3>Noch keine Momente</h3>
<p>Du kannst auch vergangene Tage jederzeit nachtragen.</p>
</div>
</article> </article>
<?php endif; ?>
<?php foreach ($dashboardTimeline as $item): ?>
<?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>
<strong class="timeline-card__time"><?= e($item['time'] !== '' ? $item['time'] : '--:--') ?></strong>
</div>
</div>
<div class="timeline-card__body">
<h3><?= e($eventTitle) ?></h3>
<?php if ($showEventComment): ?>
<p class="timeline-card__comment"><?= e($eventComment) ?></p>
<?php endif; ?>
<?php if ((string) ($item['type'] ?? '') === 'alcohol'): ?>
<p class="timeline-card__value">
<?= !empty($item['consumed']) ? 'Heute getrunken' : 'Heute nicht getrunken' ?>
</p>
<?php elseif ($eventDetail !== ''): ?>
<p class="timeline-card__value"><?= e($eventDetail) ?></p>
<?php endif; ?>
<?php if ((string) ($item['comment'] ?? '') !== '' && (string) ($item['type'] ?? '') === 'alcohol'): ?>
<p class="timeline-card__value"><?= e((string) $item['comment']) ?></p>
<?php endif; ?>
<?php if ($eventType === 'sleep' && $sleepActualTotal > 0): ?>
<div class="sleep-phase-bar" aria-label="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>
<?php endforeach; ?>
</div>
</section> </section>
<section class="stats-grid"> <button class="dashboard-fab" type="button" data-moment-overlay-open aria-label="Neuen Moment hinzufügen">+</button>
<article class="metric-card glass-panel"> <div class="dashboard-fab-menu glass-panel" data-fab-menu hidden>
<span>Getrackte Tage</span> <?php foreach ($dashboardEventTypes as $type => $meta): ?>
<strong><?= e((string) $summary['tracked_days']) ?></strong> <?php if ($type === 'alcohol') { continue; } ?>
</article> <button type="button" data-fab-moment-choice="<?= e($type) ?>">
<article class="metric-card glass-panel"> <img src="<?= e((string) $meta['icon']) ?>" alt="">
<span>Ø Score</span> <span><?= e((string) $meta['label']) ?></span>
<strong><?= e(format_points((float) $summary['average_score'])) ?></strong> </button>
</article> <?php endforeach; ?>
<article class="metric-card glass-panel"> </div>
<span>Ø Stimmung</span> </div>
<strong><?= e(format_points((float) $summary['average_mood'])) ?>/10</strong>
</article> <div class="dashboard-overlay" data-summary-overlay hidden>
<article class="metric-card glass-panel"> <div class="dashboard-overlay__backdrop" data-summary-overlay-close></div>
<span>Ø Stress</span> <section class="dashboard-modal glass-panel dashboard-modal--summary" role="dialog" aria-modal="true">
<strong><?= e(format_points((float) $summary['average_stress'])) ?>/10</strong> <div class="dashboard-modal__controls">
</article> <button class="dashboard-modal__round" type="button" data-summary-overlay-close>×</button>
<article class="metric-card glass-panel"> <button class="dashboard-modal__round dashboard-modal__round--confirm" type="submit" form="day-summary-form">✓</button>
<span>Serie</span> </div>
<strong><?= e((string) $summary['streak']) ?> Tage</strong>
</article> <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> </section>
</div>
<section class="dashboard-grid"> <div class="dashboard-overlay" data-moment-overlay hidden>
<article class="glass-panel chart-card chart-card--calendar"> <div class="dashboard-overlay__backdrop" data-moment-overlay-close></div>
<div class="section-head"> <section class="dashboard-modal glass-panel dashboard-modal--moment" role="dialog" aria-modal="true" data-moment-modal>
<div> <div class="dashboard-modal__controls">
<p class="eyebrow">Kalender</p> <button class="dashboard-modal__round" type="button" data-moment-overlay-close>×</button>
<h3>Gesamtstimmung pro Tag</h3> <button class="dashboard-modal__round dashboard-modal__round--confirm" type="submit" form="moment-form" data-moment-submit disabled>✓</button>
</div> </div>
</div>
<div id="calendar-heatmap" class="calendar-heatmap" data-payload="<?= e($chartPayload) ?>"></div>
</article>
<article class="glass-panel chart-card"> <div data-moment-step="choose">
<div class="section-head"> <h2 class="dashboard-modal__title">Neuer Moment</h2>
<div> <div class="moment-type-grid">
<p class="eyebrow">Trend</p> <?php foreach ($dashboardEventTypes as $type => $meta): ?>
<h3>Tagesstimmung</h3> <?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>
<span class="chart-chip">letzte 30 Einträge</span>
</div> </div>
<div class="line-chart" data-chart-type="line" data-series="mood" data-payload="<?= e($chartPayload) ?>"></div>
</article>
<article class="glass-panel chart-card"> <form method="post" action="/" enctype="multipart/form-data" class="dashboard-modal__form" id="moment-form" data-moment-step="form" hidden>
<div class="section-head"> <?= csrf_field() ?>
<div> <input type="hidden" name="form_name" value="add_event" data-moment-form-name>
<p class="eyebrow">Belastung</p> <input type="hidden" name="date" value="<?= e((string) $dayEntry['date']) ?>">
<h3>Stressverlauf</h3> <input type="hidden" name="event_id" value="" data-moment-event-id>
</div> <input type="hidden" name="event_type" value="event" data-moment-type-input>
<span class="chart-chip chart-chip--warm">weniger ist besser</span> <input type="hidden" name="event_unit" value="" data-event-unit>
</div> <input type="hidden" name="event_walk_mode" value="time" data-walk-mode-input>
<div class="line-chart" data-chart-type="line" data-series="stress" data-payload="<?= e($chartPayload) ?>"></div>
</article>
<article class="glass-panel chart-card chart-card--wide"> <div class="dashboard-modal__heading-row">
<div class="section-head">
<div> <div>
<p class="eyebrow">Aktivität</p> <p class="dashboard-modal__subtitle" data-moment-type-label>Neuer Moment</p>
<h3>Sport und Spaziergang</h3> <h2 class="dashboard-modal__title">Was ist passiert?</h2>
</div>
<button class="ghost-button ghost-button--small" type="button" data-moment-back>Typ ändern</button>
</div>
<label class="dashboard-modal__textarea">
<textarea name="event_comment" rows="4" placeholder="Was hast du erlebt?" data-moment-comment></textarea>
</label>
<label>
<span>Momentbild</span>
<input type="file" name="event_image" accept="image/jpeg,image/png,image/webp">
</label>
<div class="field-grid field-grid--two">
<label>
<span>Erfasst um</span>
<input type="time" name="event_time" value="<?= e(date('H:i')) ?>" required>
</label>
<label data-moment-value-field>
<span data-moment-value-label>Wert</span>
<input type="number" name="event_value" min="0" max="50000" step="0.01" placeholder="optional" data-moment-value-input>
</label>
</div>
<fieldset data-moment-sport-field hidden>
<legend>Sportart</legend>
<input type="hidden" name="event_sport_type_id" value="">
<div class="moment-type-grid moment-type-grid--sport">
<?php foreach ($dashboardSportTypes as $sportType): ?>
<button class="moment-type-card moment-type-card--sport" type="button" data-sport-choice="<?= e((string) ($sportType['id'] ?? '')) ?>">
<img src="<?= e(sport_icon_path((string) ($sportType['icon'] ?? 'run'))) ?>" alt="">
<span><?= e((string) ($sportType['label'] ?? '')) ?></span>
</button>
<?php endforeach; ?>
</div>
</fieldset>
<fieldset class="moment-alcohol-field" data-moment-walk-field hidden>
<legend>Spaziergang als</legend>
<div class="moment-choice-row">
<label class="moment-choice-pill"><input type="radio" name="event_walk_mode" value="time" checked><span>Dauer</span></label>
<label class="moment-choice-pill"><input type="radio" name="event_walk_mode" value="steps"><span>Schritte</span></label>
</div>
</fieldset>
<fieldset class="moment-alcohol-field" data-moment-alcohol-field hidden>
<legend>Heute Alkohol getrunken?</legend>
<div class="moment-choice-row">
<label class="moment-choice-pill">
<input type="radio" name="event_consumed" value="1" checked>
<span>Ja</span>
</label>
<label class="moment-choice-pill">
<input type="radio" name="event_consumed" value="0">
<span>Nein</span>
</label>
</div>
</fieldset>
<div class="overlay-signal-grid overlay-signal-grid--summary-row overlay-signal-grid--moment">
<?php foreach (['event_mood' => ['Stimmung', 0], 'event_energy' => ['Energie', 0], 'event_stress' => ['Stress', 0]] as $field => [$label, $value]): ?>
<?php $metric = str_replace('event_', '', $field); ?>
<div class="overlay-signal-card overlay-signal-card--inline overlay-signal-card--moment" data-stepper data-stepper-metric="<?= e($metric) ?>">
<div>
<h3><?= e($label) ?></h3>
</div>
<div class="overlay-signal-card__control">
<div class="overlay-signal-card__ring tone-zero">
<span data-stepper-value><?= $value >= 0 ? '+' : '' ?><?= e((string) $value) ?></span>
</div>
<div class="overlay-signal-card__buttons">
<button type="button" data-stepper-minus>-</button>
<button type="button" data-stepper-plus>+</button>
</div>
<div class="signal-scale" aria-hidden="true"><span>-2</span><span>-1</span><span>0</span><span>+1</span><span>+2</span></div>
<input type="hidden" name="<?= e($field) ?>" value="<?= e((string) $value) ?>" data-stepper-input>
</div>
</div>
<?php endforeach; ?>
</div>
</form>
<form method="post" action="/" class="dashboard-modal__secondary-action" data-moment-delete-form hidden>
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="delete_event">
<input type="hidden" name="date" value="<?= e((string) $dayEntry['date']) ?>">
<input type="hidden" name="event_id" value="" data-moment-delete-id>
<button class="ghost-button" type="submit">Moment löschen</button>
</form>
</section>
</div>
<?php elseif ($dashboardView === 'week'): ?>
<section class="dashboard-range-view dashboard-range-view--week">
<header class="dashboard-range-view__hero">
<p class="eyebrow">Wochenansicht</p>
<h1><?= e($dashboardWeek['title']) ?></h1>
<h2><?= e($dashboardWeek['range']) ?></h2>
</header>
<?php $weekInsights = is_array($dashboardWeek['insights'] ?? null) ? $dashboardWeek['insights'] : []; ?>
<?php if ((int) ($weekInsights['average_steps'] ?? 0) > 0 || (int) ($weekInsights['daily_sport_minutes'] ?? 0) > 0): ?>
<section class="week-insight-card glass-panel">
<?php if ((int) ($weekInsights['average_steps'] ?? 0) > 0): ?>
<p>
Du bist in dieser Woche durchschnittlich <strong><?= e(number_format((int) $weekInsights['average_steps'], 0, ',', '.')) ?> Schritte</strong> gegangen.
<?php if (!empty($weekInsights['has_step_comparison'])): ?>
Das sind <strong><?= e(number_format(abs((int) $weekInsights['step_difference']), 0, ',', '.')) ?> Schritte <?= e((string) $weekInsights['step_direction']) ?></strong> als im vergangenen Monat.
<?php endif; ?>
</p>
<?php endif; ?>
<?php if ((int) ($weekInsights['daily_sport_minutes'] ?? 0) > 0): ?>
<p>Täglich hast du im Schnitt <strong><?= e((string) $weekInsights['daily_sport_minutes']) ?> Minuten Sport</strong> gemacht.</p>
<?php endif; ?>
</section>
<?php endif; ?>
<div class="range-period-rail range-period-rail--week">
<?php foreach (($dashboardWeek['periods'] ?? [$dashboardWeek]) as $week): ?>
<article class="range-period-panel<?= !empty($week['is_selected']) ? ' is-selected' : '' ?>">
<header class="range-period-panel__head">
<a href="/?view=week&amp;date=<?= e(rawurlencode((string) ($week['key'] ?? $dashboardDate))) ?>">
<h3><?= e((string) $week['title']) ?></h3>
<p><?= e((string) $week['range']) ?></p>
</a>
</header>
<nav class="range-score-strip range-score-strip--week glass-panel" aria-label="Tage dieser Woche">
<span class="score-scale score-scale--range" aria-hidden="true"><span>+2</span><span>+1</span><span>0</span><span>-1</span><span>-2</span></span>
<?php foreach ($week['days'] as $day): ?>
<a class="range-score-day<?= !empty($day['is_current']) ? ' is-current' : '' ?><?= empty($day['has_content']) ? ' is-empty' : '' ?>" href="/?view=week&amp;date=<?= e(rawurlencode((string) $day['date'])) ?>" title="<?= e((string) $day['weekday']) ?>">
<span class="compare-day__line<?= !empty($day['is_current']) ? ' is-primary' : '' ?> score-<?= e((string) ($day['score_level'] ?? 'empty')) ?> compare-tone-<?= e((string) ($day['line_tone'] ?? 'empty')) ?>">
<span class="compare-day__marker"></span>
</span>
<span class="range-score-day__label"><?= e((string) $day['day']) ?></span>
</a>
<?php endforeach; ?>
</nav>
</article>
<?php endforeach; ?>
</div>
<?php $weekDetailDays = array_values(array_reverse(array_filter($dashboardWeek['days'], static fn (array $day): bool => !empty($day['has_content'])))); ?>
<?php if ($weekDetailDays !== []): ?>
<div class="range-day-list">
<?php foreach ($weekDetailDays as $day): ?>
<?php
$entry = is_array($day['entry'] ?? null) ? $day['entry'] : null;
$events = $entry !== null && is_array($entry['events'] ?? null) ? $entry['events'] : [];
$summaryText = $entry !== null ? trim((string) ($entry['summary']['comment'] ?? $entry['summary_comment'] ?? $entry['note'] ?? '')) : '';
$dayTone = (string) ($day['line_tone'] ?? 'empty');
$dayImage = $entry !== null && is_string($entry['background_image_url'] ?? null) ? (string) $entry['background_image_url'] : null;
?>
<a class="range-day-card range-day-card--<?= e($dayTone) ?><?= empty($day['has_content']) ? ' is-empty' : '' ?> glass-panel" href="/?view=day&amp;date=<?= e(rawurlencode((string) $day['date'])) ?>">
<?php if ($dayImage !== null): ?>
<img class="range-day-card__image" src="<?= e($dayImage) ?>" alt="">
<?php endif; ?>
<div class="range-day-card__body">
<p class="eyebrow"><?= e((string) $day['weekday']) ?></p>
<p class="range-day-card__score">Bilanz <?= e($formatBalanceValue($entry)) ?></p>
<p class="range-day-card__summary"><?= $summaryText !== '' ? e($summaryText) : 'Noch keine Tagesbilanz.' ?></p>
<?php if ($events !== []): ?>
<ul class="range-moment-list">
<?php foreach ($events as $event): ?>
<?php if (!is_array($event)) { continue; } ?>
<?php
$eventType = (string) ($event['type'] ?? 'event');
$eventScore = signal_combo_score($event['mood'] ?? 0, $event['energy'] ?? 0, $event['stress'] ?? 0);
$eventTone = signal_value_class($eventScore);
$sportType = $eventType === 'sport' ? find_sport_type($settings, (string) ($event['sport_type_id'] ?? '')) : null;
$eventValue = (float) ($event['value'] ?? 0);
$eventValueText = $eventValue > 0 ? ($eventType === 'sleep' ? format_duration_hours($eventValue) : rtrim(rtrim(number_format($eventValue, 2, ',', '.'), '0'), ',') . ' ' . (string) ($event['unit'] ?? '')) : '';
$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&amp;date=<?= e(rawurlencode((string) ($month['key'] ?? $dashboardDate))) ?>">
<h3><?= e((string) $month['title']) ?></h3>
</a>
</header>
<nav class="range-score-strip range-score-strip--month glass-panel" aria-label="Tage dieses Monats">
<span class="score-scale score-scale--range score-scale--month" aria-hidden="true"><span>+2</span><span>+1</span><span>0</span><span>-1</span><span>-2</span></span>
<?php foreach ($month['days'] as $day): ?>
<a class="range-score-day<?= empty($day['has_content']) ? ' is-empty' : '' ?><?= !empty($day['is_future']) ? ' is-future' : '' ?>" href="/?view=month&amp;date=<?= e(rawurlencode((string) $day['date'])) ?>" title="<?= e((string) $day['weekday']) ?>">
<span class="compare-day__line score-<?= e((string) ($day['score_level'] ?? 'empty')) ?> compare-tone-<?= e((string) ($day['line_tone'] ?? 'empty')) ?>">
<span class="compare-day__marker"></span>
</span>
</a>
<?php endforeach; ?>
</nav>
</article>
<?php endforeach; ?>
</div>
<?php $monthDetailDays = array_values(array_filter($dashboardMonth['days'], static function (array $day): bool {
$entry = is_array($day['entry'] ?? null) ? $day['entry'] : null;
$summaryText = $entry !== null ? trim((string) ($entry['summary']['comment'] ?? $entry['summary_comment'] ?? $entry['note'] ?? '')) : '';
return !empty($day['has_content']) || $summaryText !== '';
})); ?>
<?php $monthDetailDays = array_reverse($monthDetailDays); ?>
<?php if ($monthDetailDays !== []): ?>
<div class="range-day-list range-day-list--month">
<?php foreach ($monthDetailDays as $day): ?>
<?php
$entry = is_array($day['entry'] ?? null) ? $day['entry'] : null;
$summaryText = $entry !== null ? trim((string) ($entry['summary']['comment'] ?? $entry['summary_comment'] ?? $entry['note'] ?? '')) : '';
$dayTone = (string) ($day['line_tone'] ?? 'empty');
?>
<a class="range-day-card range-day-card--summary-only range-day-card--<?= e($dayTone) ?> glass-panel" href="/?view=day&amp;date=<?= e(rawurlencode((string) $day['date'])) ?>">
<div class="range-day-card__body">
<p class="eyebrow"><?= e((string) $day['weekday']) ?></p>
<p class="range-day-card__score">Bilanz <?= e($formatBalanceValue($entry)) ?></p>
<p class="range-day-card__summary"><?= $summaryText !== '' ? e($summaryText) : 'Tagesbilanz' ?></p>
</div>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
<?php endif; ?>
<div class="dashboard-overlay" data-settings-menu-overlay hidden>
<div class="dashboard-overlay__backdrop" data-settings-menu-close></div>
<section class="dashboard-modal dashboard-modal--settings glass-panel" role="dialog" aria-modal="true">
<div class="dashboard-modal__controls">
<button class="dashboard-modal__round" type="button" data-settings-menu-close>×</button>
</div>
<h2 class="dashboard-modal__title">Einstellungen und Bereiche</h2>
<div class="settings-menu-grid">
<a class="options-menu-card" href="/options?panel=sports"><strong>Sportarten anpassen</strong><span>Eigene Sportarten und Bonuspunkte</span></a>
<a class="options-menu-card" href="/options?panel=walk"><strong>Spaziergang anpassen</strong><span>Zeit oder Schritte auswerten</span></a>
<a class="options-menu-card" href="/options?panel=sleep"><strong>Schlaf anpassen</strong><span>Optimale Schlafmenge</span></a>
<a class="options-menu-card" href="/options?panel=health"><strong>Health Import</strong><span>Apple Health automatisch übernehmen</span></a>
<a class="options-menu-card" href="/options?panel=reminders"><strong>Erinnerungen setzen</strong><span>Push und tägliche Erinnerung</span></a>
<a class="options-menu-card" href="/options?panel=ratings"><strong>Bewertungsskala ändern</strong><span>Labels und Schutzregeln</span></a>
<a class="options-menu-card" href="/options?panel=stats"><strong>Statistik</strong><span>Verlauf und Aktivität</span></a>
<?php if (!empty($authUser['is_admin'])): ?>
<a class="options-menu-card" href="/options?panel=users"><strong>Neue Nutzer anlegen</strong><span>Accounts und Adminrechte</span></a>
<?php endif; ?>
<form method="post" action="/logout" class="options-logout-form">
<?= csrf_field() ?>
<button class="options-menu-card options-menu-card--danger" type="submit"><strong>Abmelden</strong><span>Sitzung sicher beenden</span></button>
</form>
</div>
</section>
</div>
<div class="media-lightbox" data-media-lightbox hidden>
<button class="media-lightbox__backdrop" type="button" data-media-lightbox-close aria-label="Ansicht schließen"></button>
<div class="media-lightbox__panel" role="dialog" aria-modal="true" aria-label="Medienansicht">
<button class="media-lightbox__close" type="button" data-media-lightbox-close aria-label="Ansicht schließen">×</button>
<div class="media-lightbox__content" data-media-lightbox-content></div>
</div> </div>
<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>
</article>
</section> </section>
+239 -300
View File
@@ -1,348 +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">
<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 class="user-row"><strong>Letzter Import</strong><span data-health-last-import>-</span></div>
<?php endif; ?>
<div class="user-row"><strong>Statusmeldung</strong><span data-health-last-message><?= !empty($healthImportConfig['last_message']) ? e((string) $healthImportConfig['last_message']) : '-' ?></span></div>
</div> </div>
</div>
<div class="section-actions">
<button class="primary-button" type="submit">Erinnerungen speichern</button>
</div>
</div>
<div class="settings-section">
<h4>Bewertungsskala</h4>
<p class="helper-text">Diese Score-Grenzen und Schutzregeln gelten ebenfalls nur für deinen eigenen Account.</p>
<div class="band-grid">
<?php foreach ($settings['ratings'] as $index => $rating): ?>
<div class="band-card">
<label><span>Label</span><input type="text" name="settings[ratings][<?= e((string) $index) ?>][label]" value="<?= e($rating['label']) ?>"></label>
<label><span>Min</span><input type="number" name="settings[ratings][<?= e((string) $index) ?>][min]" value="<?= e((string) $rating['min']) ?>"></label>
<label><span>Max</span><input type="number" name="settings[ratings][<?= e((string) $index) ?>][max]" value="<?= e((string) $rating['max']) ?>"></label>
</div>
<?php endforeach; ?>
</div>
</div>
<div class="settings-section">
<h4>Schutzregeln</h4>
<div class="band-grid">
<?php foreach ($settings['guardrails'] as $index => $guardrail): ?>
<div class="band-card">
<label><span>Stimmung max</span><input type="number" name="settings[guardrails][<?= e((string) $index) ?>][mood_max]" value="<?= e((string) $guardrail['mood_max']) ?>" min="1" max="10"></label>
<label><span>Energie max</span><input type="number" name="settings[guardrails][<?= e((string) $index) ?>][energy_max]" value="<?= e((string) ($guardrail['energy_max'] ?? '')) ?>" min="1" max="10"></label>
<label><span>Maximales Label</span><input type="text" name="settings[guardrails][<?= e((string) $index) ?>][cap_label]" value="<?= e($guardrail['cap_label']) ?>"></label>
</div>
<?php endforeach; ?>
</div>
</div>
<label>
<span>Tagebuchpunkte bei nicht-leerer Notiz</span>
<input type="number" name="settings[scoring][journal_points]" value="<?= e((string) $settings['scoring']['journal_points']) ?>" min="0" max="20">
</label>
<button class="primary-button" type="submit">Bewertung speichern</button>
</form>
</article>
<aside class="stack-column">
<article class="glass-panel detail-card">
<p class="eyebrow">Sicherheit</p>
<h3>Passwort ändern</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="password"> <input type="hidden" name="form_name" value="health_import_token">
<label><span>Aktuelles Passwort</span><input type="password" name="current_password" required></label> <button class="primary-button" type="submit"><?= !empty($healthImportConfig['enabled']) ? 'Token neu erstellen' : 'Token erstellen' ?></button>
<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> </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> </article>
</div>
<div class="options-panel" data-options-panel="ratings" hidden>
<h2>Bewertungsskala ändern</h2>
<form method="post" action="/options" class="stack-form stack-form--spacious">
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="settings">
<div class="settings-section">
<h4>Tagesbilanz als Hauptmetrik</h4>
<p class="helper-text">Stimmung, Energie und Stress bilden die Basis. Schlaf, Schritte, Sport, Spaziergang und Notizen verschieben den Tag nur gedeckelt in eine positivere oder negativere Richtung.</p>
<div class="field-grid field-grid--three">
<label><span>Gewicht Stimmung</span><input type="number" name="settings[day_balance][mood_weight]" value="<?= e((string) ($settings['day_balance']['mood_weight'] ?? 3)) ?>" min="0" max="10"></label>
<label><span>Gewicht Energie</span><input type="number" name="settings[day_balance][energy_weight]" value="<?= e((string) ($settings['day_balance']['energy_weight'] ?? 2)) ?>" min="0" max="10"></label>
<label><span>Gewicht Stress</span><input type="number" name="settings[day_balance][stress_weight]" value="<?= e((string) ($settings['day_balance']['stress_weight'] ?? 2)) ?>" min="0" max="10"></label>
</div>
<div class="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 class="settings-section"><h4>Bewertungsskala</h4><div class="band-grid"><?php foreach ($settings['ratings'] as $index => $rating): ?><div class="band-card"><label><span>Label</span><input type="text" name="settings[ratings][<?= e((string) $index) ?>][label]" value="<?= e($rating['label']) ?>"></label><label><span>Min</span><input type="number" name="settings[ratings][<?= e((string) $index) ?>][min]" value="<?= e((string) $rating['min']) ?>"></label><label><span>Max</span><input type="number" name="settings[ratings][<?= e((string) $index) ?>][max]" value="<?= e((string) $rating['max']) ?>"></label></div><?php endforeach; ?></div></div>
<div class="settings-section"><h4>Schutzregeln</h4><div class="band-grid"><?php foreach ($settings['guardrails'] as $index => $guardrail): ?><div class="band-card"><label><span>Stimmung max</span><input type="number" name="settings[guardrails][<?= e((string) $index) ?>][mood_max]" value="<?= e((string) $guardrail['mood_max']) ?>" min="1" max="10"></label><label><span>Energie max</span><input type="number" name="settings[guardrails][<?= e((string) $index) ?>][energy_max]" value="<?= e((string) ($guardrail['energy_max'] ?? '')) ?>" min="1" max="10"></label><label><span>Maximales Label</span><input type="text" name="settings[guardrails][<?= e((string) $index) ?>][cap_label]" value="<?= e($guardrail['cap_label']) ?>"></label></div><?php endforeach; ?></div></div>
<label><span>Tagebuchpunkte bei nicht-leerer Notiz</span><input type="number" name="settings[scoring][journal_points]" value="<?= e((string) $settings['scoring']['journal_points']) ?>" min="0" max="20"></label>
<button class="primary-button" type="submit">Bewertung speichern</button>
</form>
</div>
<div class="options-panel" data-options-panel="stats" hidden>
<h2>Statistik</h2>
<form method="post" action="/options" class="stack-form stack-form--spacious stats-settings-form">
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="settings">
<div class="settings-section">
<h4>Statistik-Darstellung</h4>
<label><span>Hauptwert anzeigen als</span><select name="settings[display][score_mode]">
<?php foreach (['scale' => '5-Stufen-Bilanz', 'percent' => 'Prozentwert', 'points' => 'Punkte'] as $mode => $label): ?>
<option value="<?= e($mode) ?>" <?= ($settings['display']['score_mode'] ?? 'scale') === $mode ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select></label>
</div>
<button class="primary-button" type="submit">Statistik speichern</button>
</form>
<section class="stats-grid">
<article class="metric-card glass-panel"><span>Getrackte Tage</span><strong><?= e((string) $statsSummary['tracked_days']) ?></strong></article>
<article class="metric-card glass-panel"><span>Ø Score</span><strong><?= e(format_points((float) $statsSummary['average_score'])) ?></strong></article>
<article class="metric-card glass-panel"><span>Ø Bilanz</span><strong><?= ((float) $statsSummary['average_balance']) > 0 ? '+' : '' ?><?= e(format_points((float) $statsSummary['average_balance'])) ?></strong></article>
<article class="metric-card glass-panel"><span>Ø Stimmung</span><strong><?= e(format_points((float) $statsSummary['average_mood'])) ?>/10</strong></article>
<article class="metric-card glass-panel"><span>Ø Stress</span><strong><?= e(format_points((float) $statsSummary['average_stress'])) ?>/10</strong></article>
<article class="metric-card glass-panel"><span>Serie</span><strong><?= e((string) $statsSummary['streak']) ?> Tage</strong></article>
</section>
<section class="dashboard-grid dashboard-grid--embedded-stats">
<article class="glass-panel chart-card chart-card--calendar"><div class="section-head"><div><p class="eyebrow">Kalender</p><h3>Gesamtstimmung pro Tag</h3></div></div><div id="calendar-heatmap" class="calendar-heatmap" data-payload="<?= e($statsChartPayload) ?>"></div></article>
<article class="glass-panel chart-card"><div class="section-head"><div><p class="eyebrow">Trend</p><h3>Errechnete Tagesbilanz</h3></div></div><div class="line-chart" data-chart-type="line" data-series="balance" data-payload="<?= e($statsChartPayload) ?>"></div></article>
<article class="glass-panel chart-card"><div class="section-head"><div><p class="eyebrow">Belastung</p><h3>Stressverlauf</h3></div></div><div class="line-chart" data-chart-type="line" data-series="stress" data-payload="<?= e($statsChartPayload) ?>"></div></article>
<article class="glass-panel chart-card chart-card--wide"><div class="section-head"><div><p class="eyebrow">Aktivität</p><h3>Sport und Spaziergang</h3></div></div><div class="bar-chart" data-chart-type="bars" data-series="sport" data-payload="<?= e($statsChartPayload) ?>"></div></article>
</section>
</div>
<?php if (!empty($authUser['is_admin'])): ?> <?php if (!empty($authUser['is_admin'])): ?>
<article class="glass-panel detail-card"> <div class="options-panel" data-options-panel="users" hidden>
<p class="eyebrow">Mehrere Accounts</p> <h2>Neue Nutzer anlegen</h2>
<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">
@@ -351,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>
+39 -1
View File
@@ -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&amp;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>
@@ -37,6 +37,42 @@
</label> </label>
</div> </div>
<?php if (!empty($settings['tracking']['pain_enabled'])): ?>
<div class="field-grid field-grid--two">
<label class="range-card">
<span>Schmerzen</span>
<output data-output-for="pain"><?= e((string) $entry['pain']) ?></output>
<input type="range" min="1" max="10" step="1" name="pain" value="<?= e((string) $entry['pain']) ?>">
</label>
<div class="sport-choice-field sport-choice-field--single">
<div class="sport-choice-list sport-choice-list--single">
<label class="sport-choice">
<input type="checkbox" name="alcohol" value="1" <?= !empty($entry['alcohol']) ? 'checked' : '' ?>>
<span class="sport-choice__card sport-choice__card--toggle">
<img src="<?= e(icon_path('alcohol')) ?>" alt="">
<strong>Alkohol</strong>Heute was getrunken?
</span>
</label>
</div>
</div>
</div>
<?php else: ?>
<div class="field-grid field-grid--single">
<div class="sport-choice-field sport-choice-field--single">
<div class="sport-choice-list sport-choice-list--single">
<label class="sport-choice">
<input type="checkbox" name="alcohol" value="1" <?= !empty($entry['alcohol']) ? 'checked' : '' ?>>
<span class="sport-choice__card sport-choice__card--toggle">
<img src="<?= e(icon_path('alcohol')) ?>" alt="">
<strong>Alkohol</strong>Heute was getrunken?
</span>
</label>
</div>
</div>
</div>
<?php endif; ?>
<div class="field-grid field-grid--two"> <div class="field-grid field-grid--two">
<label> <label>
<span>Schlafdauer in Stunden</span> <span>Schlafdauer in Stunden</span>
@@ -127,11 +163,13 @@
'mood' => 'Stimmung', 'mood' => 'Stimmung',
'energy' => 'Energie', 'energy' => 'Energie',
'stress' => 'Stress', 'stress' => 'Stress',
'pain' => 'Schmerzen',
'sleep_hours' => 'Schlafdauer', 'sleep_hours' => 'Schlafdauer',
'sleep_feeling' => 'Schlafgefühl', 'sleep_feeling' => 'Schlafgefühl',
'sport_minutes' => 'Sport', 'sport_minutes' => 'Sport',
'sport_bonus' => 'Sportbonus', 'sport_bonus' => 'Sportbonus',
'walk_minutes' => 'Spaziergang', 'walk_minutes' => 'Spaziergang',
'alcohol' => 'Alkohol',
'note' => 'Notiz', 'note' => 'Notiz',
]; ];
?> ?>