Compare commits
39 Commits
V1.1.0
..
2932cbb5b2
| Author | SHA1 | Date | |
|---|---|---|---|
| 2932cbb5b2 | |||
| 9f1bb2c351 | |||
| 3a467aca38 | |||
| f5daff1a04 | |||
| a087eb508b | |||
| 2047cae61c | |||
| 1dd5339a46 | |||
| 0df5983f65 | |||
| 7c9f464686 | |||
| abcd35714f | |||
| 0fb8adbb14 | |||
| 3b2c36c849 | |||
| adaff22651 | |||
| 36a15f3ed4 | |||
| 6a5852654b | |||
| 3e497a8047 | |||
| 59c7d89e81 | |||
| 176b07f202 | |||
| d8636f6c41 | |||
| a555f552c2 | |||
| e00cd66fbe | |||
| e36f27da4a | |||
| bc6e850afb | |||
| b8a96e96ef | |||
| 48df9831fd | |||
| 83b4686b6f | |||
| e953d0fd42 | |||
| ab1d8bc677 | |||
| 297f63c7d5 | |||
| 889f5ffa8a | |||
| 41183f04db | |||
| 796e5b23d2 | |||
| af84243866 | |||
| 9e79e93724 | |||
| 0a8ccef5a7 | |||
| 4a884dd166 | |||
| 5ea1b56649 | |||
| abc0766f16 | |||
| 4e9fe2de6a |
@@ -2,6 +2,10 @@ Options -Indexes
|
||||
DirectoryIndex index.php
|
||||
AddType application/manifest+json .webmanifest
|
||||
|
||||
<IfModule mod_setenvif.c>
|
||||
SetEnvIf Authorization "(.+)" HTTP_AUTHORIZATION=$1
|
||||
</IfModule>
|
||||
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,15 @@
|
||||
Mood-Board
|
||||
Copyright (c) 2026 HNZIO
|
||||
|
||||
Licensed under the PolyForm Noncommercial License 1.0.0.
|
||||
|
||||
You may use, copy, modify, and distribute this software only for permitted
|
||||
noncommercial purposes under the terms of that license.
|
||||
|
||||
Commercial use is not allowed without a separate written agreement from the
|
||||
copyright holder.
|
||||
|
||||
Required Notice: Copyright (c) 2026 HNZIO
|
||||
|
||||
Full license text:
|
||||
https://polyformproject.org/licenses/noncommercial/1.0.0/
|
||||
@@ -7,6 +7,7 @@ Dateibasierter Stimmungstracker für LAMP/Cloudron ohne Datenbank.
|
||||
- Geschützter Login mit Session, `password_hash`, CSRF-Schutz und Security-Headern
|
||||
- Vier Bereiche: Dashboard, Tracking, Optionen, Archiv
|
||||
- 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
|
||||
- Admin kann weitere Accounts direkt in der Weboberfläche anlegen
|
||||
- Moderner, responsiver Liquid-Glass-Look mit lokalen Assets und ohne externe CDNs
|
||||
@@ -30,5 +31,27 @@ Dateibasierter Stimmungstracker für LAMP/Cloudron ohne Datenbank.
|
||||
## Hinweise
|
||||
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
- Dieses Projekt steht unter der `PolyForm Noncommercial License 1.0.0`.
|
||||
- Nicht-kommerzielle Nutzung ist erlaubt.
|
||||
- Kommerzielle Nutzung ist ohne separate schriftliche Freigabe nicht erlaubt.
|
||||
- Details siehe [LICENSE](/home/hnzio/Projekte/mood/LICENSE).
|
||||
|
||||
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 655 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 63 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="12" y="18" width="40" height="28" rx="10" stroke="#DFF7FF" stroke-width="4"/>
|
||||
<path d="M20 28H44" stroke="#90E3FF" stroke-width="4" stroke-linecap="round"/>
|
||||
<path d="M20 36H34" stroke="#DFF7FF" stroke-width="4" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 348 B |
@@ -0,0 +1,5 @@
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M21 20C15.4772 24.1806 12 30.8108 12 38C12 50.1503 21.8497 60 34 60C43.9254 60 52.3144 53.422 55 44" stroke="#DFF7FF" stroke-width="4" stroke-linecap="round"/>
|
||||
<path d="M37 14L31 24H39L33 34" stroke="#90E3FF" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="47" cy="19" r="3" fill="#8CFFD1"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 434 B |
@@ -0,0 +1,8 @@
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M23 11C23.4 9.8 24.5 9 25.8 9H38.2C39.5 9 40.6 9.8 41 11L44.4 22.7C44.8 24.2 45 25.8 45 27.3C45 33.9 39.8 39.3 33.3 39.8V51H39.5C40.9 51 42 52.1 42 53.5C42 54.9 40.9 56 39.5 56H24.5C23.1 56 22 54.9 22 53.5C22 52.1 23.1 51 24.5 51H30.7V39.8C24.2 39.3 19 33.9 19 27.3C19 25.8 19.2 24.2 19.6 22.7L23 11Z" fill="#EFF7FF"/>
|
||||
<path d="M22.4 20H41.6L41.2 22C41 22.9 41 23.8 41 24.7C41 29.8 36.8 34 31.7 34H32.3C37.2 34 41.2 30 41.2 25.1C41.2 24 41.1 23 40.8 22L40.3 20H22.4Z" fill="#8BE4FF" opacity="0.95"/>
|
||||
<path d="M22 21.5C22 20.7 22.7 20 23.5 20H40.5C41.3 20 42 20.7 42 21.5C42 27.3 37.3 32 31.5 32C25.7 32 21 27.3 21 21.5H22Z" fill="#7FF3BB" opacity="0.8"/>
|
||||
<path d="M24 15H40" stroke="#8BE4FF" stroke-width="2.8" stroke-linecap="round"/>
|
||||
<path d="M27.5 44H36.5" stroke="#8BE4FF" stroke-width="3" stroke-linecap="round"/>
|
||||
<path d="M25 53H39" stroke="#7FF3BB" stroke-width="3.2" stroke-linecap="round" opacity="0.9"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M338.8-9.9c11.9 8.6 16.3 24.2 10.9 37.8L271.3 224H416c13.5 0 25.5 8.4 30.1 21.1s.7 26.9-9.6 35.5l-288 240c-11.3 9.4-27.4 9.9-39.3 1.3s-16.3-24.2-10.9-37.8L176.7 288H32c-13.5 0-25.5-8.4-30.1-21.1s-.7-26.9 9.6-35.5l288-240c11.3-9.4 27.4-9.9 39.3-1.3z"/></svg>
|
||||
|
After Width: | Height: | Size: 349 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M256 512a256 256 0 1 0 0-512 256 256 0 1 0 0 512zM165.4 321.9c20.4 28 53.4 46.1 90.6 46.1s70.2-18.1 90.6-46.1c7.8-10.7 22.8-13.1 33.5-5.3s13.1 22.8 5.3 33.5C356.3 390 309.2 416 256 416s-100.3-26-129.4-65.9c-7.8-10.7-5.4-25.7 5.3-33.5s25.7-5.4 33.5 5.3zM144 208a32 32 0 1 1 64 0 32 32 0 1 1-64 0zm192-32a32 32 0 1 1 0 64 32 32 0 1 1 0-64z"/></svg>
|
||||
|
After Width: | Height: | Size: 438 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M120 56c0-30.9 25.1-56 56-56h24c17.7 0 32 14.3 32 32v448c0 17.7-14.3 32-32 32h-32c-29.8 0-54.9-20.4-62-48-.7 0-1.3 0-2 0-44.2 0-80-35.8-80-80 0-18 6-34.6 16-48-19.4-14.6-32-37.8-32-64 0-30.9 17.6-57.8 43.2-71.1-7.1-12-11.2-26-11.2-40.9 0-44.2 35.8-80 80-80V56zm272 0v24c44.2 0 80 35.8 80 80 0 15-4.1 29-11.2 40.9 25.7 13.3 43.2 40.1 43.2 71.1 0 26.2-12.6 49.4-32 64 10 13.4 16 30 16 48 0 44.2-35.8 80-80 80-.7 0-1.3 0-2 0-7.1 27.6-32.2 48-62 48h-32c-17.7 0-32-14.3-32-32V32c0-17.7 14.3-32 32-32h24c30.9 0 56 25.1 56 56z"/></svg>
|
||||
|
After Width: | Height: | Size: 620 B |
|
After Width: | Height: | Size: 4.2 KiB |
@@ -12,15 +12,15 @@
|
||||
"theme_color": "#0b1e2e",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/assets/branding/logo-mark.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"src": "/assets/branding/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/assets/branding/apple-touch-icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"src": "/assets/branding/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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)),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
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
|
||||
{
|
||||
$path = $this->pathFor($username, $date);
|
||||
@@ -13,7 +20,8 @@ final class EntryRepository
|
||||
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
|
||||
@@ -24,7 +32,14 @@ final class EntryRepository
|
||||
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
|
||||
@@ -41,7 +56,14 @@ final class EntryRepository
|
||||
$entries = [];
|
||||
foreach ($files as $file) {
|
||||
$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) {
|
||||
$entries[] = $parsed;
|
||||
}
|
||||
@@ -50,6 +72,18 @@ final class EntryRepository
|
||||
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
|
||||
{
|
||||
return storage_path('users/' . normalize_username($username) . '/days');
|
||||
@@ -62,6 +96,10 @@ final class EntryRepository
|
||||
|
||||
private function parse(string $content, string $fallbackDate): ?array
|
||||
{
|
||||
if (str_contains($content, '<!-- mood-tracker:v3 -->')) {
|
||||
return $this->parseV3($content, $fallbackDate);
|
||||
}
|
||||
|
||||
$sportTypes = [];
|
||||
$sportTypesRaw = (string) ($this->extract('/^- Sportarten:\s*(.*)$/mu', $content) ?? '');
|
||||
if ($sportTypesRaw !== '') {
|
||||
@@ -80,12 +118,16 @@ final class EntryRepository
|
||||
$walkModeRaw = strtolower((string) ($this->extract('/^- Spaziergang-Modus:\s*(.+)$/mu', $content) ?? 'zeit'));
|
||||
$walkMode = $walkModeRaw === 'schritte' ? 'steps' : 'time';
|
||||
$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 = [
|
||||
'date' => $this->extract('/^Datum:\s*(\d{4}-\d{2}-\d{2})$/m', $content) ?? $fallbackDate,
|
||||
'mood' => (int) ($this->extract('/^- Stimmung:\s*(.+)$/m', $content) ?? 5),
|
||||
'energy' => (int) ($this->extract('/^- Energie:\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_feeling' => (int) ($this->extract('/^- Schlaf(?:gefühl|gefuehl):\s*(.+)$/mu', $content) ?? 3),
|
||||
'sport_minutes' => (int) ($this->extract('/^- Sport:\s*(.+)$/m', $content) ?? 0),
|
||||
@@ -94,7 +136,21 @@ final class EntryRepository
|
||||
'walk_mode' => $walkMode,
|
||||
'walk_minutes' => $walkMode === 'time' ? $walkValue : 0,
|
||||
'walk_steps' => $walkMode === 'steps' ? $walkValue : 0,
|
||||
'alcohol' => in_array($alcoholRaw, ['ja', 'yes', 'true', '1'], true),
|
||||
'note' => $this->extractNote($content),
|
||||
'summary' => [
|
||||
'comment' => $this->extractNote($content),
|
||||
'mood' => legacy_to_signal_scale((int) ($this->extract('/^- Stimmung:\s*(.+)$/m', $content) ?? 5)),
|
||||
'energy' => legacy_to_signal_scale((int) ($this->extract('/^- Energie:\s*(.+)$/m', $content) ?? 5)),
|
||||
'stress' => legacy_to_signal_scale((int) ($this->extract('/^- Stress:\s*(.+)$/m', $content) ?? 5)),
|
||||
'alcohol' => in_array($alcoholRaw, ['ja', 'yes', 'true', '1'], true),
|
||||
],
|
||||
'summary_comment' => $this->extractNote($content),
|
||||
'summary_mood' => legacy_to_signal_scale((int) ($this->extract('/^- Stimmung:\s*(.+)$/m', $content) ?? 5)),
|
||||
'summary_energy' => legacy_to_signal_scale((int) ($this->extract('/^- Energie:\s*(.+)$/m', $content) ?? 5)),
|
||||
'summary_stress' => legacy_to_signal_scale((int) ($this->extract('/^- Stress:\s*(.+)$/m', $content) ?? 5)),
|
||||
'background_image' => '',
|
||||
'events' => [],
|
||||
];
|
||||
|
||||
return $entry;
|
||||
@@ -124,28 +180,85 @@ final class EntryRepository
|
||||
|
||||
private function toMarkdown(string $username, string $date, array $entry, array $evaluation): string
|
||||
{
|
||||
$summary = is_array($entry['summary'] ?? null) ? $entry['summary'] : [
|
||||
'comment' => (string) ($entry['summary_comment'] ?? $entry['note'] ?? ''),
|
||||
'mood' => normalize_signal_value($entry['summary_mood'] ?? legacy_to_signal_scale($entry['mood'] ?? 5)),
|
||||
'energy' => normalize_signal_value($entry['summary_energy'] ?? legacy_to_signal_scale($entry['energy'] ?? 5)),
|
||||
'stress' => normalize_signal_value($entry['summary_stress'] ?? legacy_to_signal_scale($entry['stress'] ?? 5)),
|
||||
];
|
||||
$events = is_array($entry['events'] ?? null) ? $entry['events'] : [];
|
||||
$health = is_array($entry['health'] ?? null) ? $entry['health'] : [];
|
||||
$sportTypes = $evaluation['sport_types'] ?? [];
|
||||
$sportTypeValues = array_map(
|
||||
static fn (array $type): string => (string) $type['label'] . ' [' . (string) $type['id'] . ']',
|
||||
array_filter($sportTypes, 'is_array')
|
||||
);
|
||||
|
||||
$eventLines = [];
|
||||
foreach ($events as $event) {
|
||||
if (!is_array($event)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$eventLines[] = '### ' . ((string) ($event['id'] ?? 'evt'));
|
||||
$eventLines[] = '- Typ: ' . day_event_type_label((string) ($event['type'] ?? 'event')) . ' [' . (string) ($event['type'] ?? 'event') . ']';
|
||||
$eventLines[] = '- Uhrzeit: ' . (string) ($event['time'] ?? '');
|
||||
$eventLines[] = '- Wert: ' . (string) ($event['value'] ?? 0);
|
||||
$eventLines[] = '- Einheit: ' . (string) ($event['unit'] ?? '');
|
||||
$eventLines[] = '- Sportart: ' . (string) ($event['sport_type_id'] ?? '');
|
||||
$eventLines[] = '- Bild: ' . (string) ($event['image'] ?? '');
|
||||
$eventLines[] = '- Getrunken: ' . (!empty($event['consumed']) ? 'ja' : 'nein');
|
||||
$eventLines[] = '- Kommentar: ' . (string) ($event['comment'] ?? '');
|
||||
$eventLines[] = '- Stimmung: ' . (string) ($event['mood'] ?? 0);
|
||||
$eventLines[] = '- Energie: ' . (string) ($event['energy'] ?? 0);
|
||||
$eventLines[] = '- Stress: ' . (string) ($event['stress'] ?? 0);
|
||||
$eventLines[] = '- Quelle: ' . (string) ($event['source'] ?? '');
|
||||
$eventLines[] = '- Import-ID: ' . (string) ($event['import_id'] ?? '');
|
||||
$eventLines[] = '- Dauer-Label: ' . (string) ($event['duration_label'] ?? '');
|
||||
$eventLines[] = '- Distanz-Label: ' . (string) ($event['distance_label'] ?? '');
|
||||
$eventLines[] = '- Energie-Label: ' . (string) ($event['energy_label'] ?? '');
|
||||
$eventLines[] = '- Puls-Label: ' . (string) ($event['heart_rate_label'] ?? '');
|
||||
$eventLines[] = '- Tiefschlaf: ' . (string) ($event['sleep_deep'] ?? 0);
|
||||
$eventLines[] = '- REM-Schlaf: ' . (string) ($event['sleep_rem'] ?? 0);
|
||||
$eventLines[] = '- Kernschlaf: ' . (string) ($event['sleep_core'] ?? 0);
|
||||
$route = is_array($event['route'] ?? null) ? $event['route'] : [];
|
||||
$eventLines[] = '- Route: ' . ($route !== [] ? base64_encode((string) json_encode($route, JSON_UNESCAPED_SLASHES)) : '');
|
||||
$eventLines[] = '';
|
||||
}
|
||||
|
||||
$lines = [
|
||||
'<!-- mood-tracker:v2 -->',
|
||||
'# Stimmungstracker',
|
||||
'<!-- mood-tracker:v3 -->',
|
||||
'# Stimmungstracker Tag',
|
||||
'Datum: ' . $date,
|
||||
'Benutzer: ' . normalize_username($username),
|
||||
'Hintergrundbild: ' . trim((string) ($entry['background_image'] ?? '')),
|
||||
'',
|
||||
'## Tagesbilanz',
|
||||
'- Kommentar: ' . trim((string) ($summary['comment'] ?? '')),
|
||||
'- Stimmung: ' . normalize_signal_value($summary['mood'] ?? 0),
|
||||
'- Energie: ' . normalize_signal_value($summary['energy'] ?? 0),
|
||||
'- Stress: ' . normalize_signal_value($summary['stress'] ?? 0),
|
||||
'- Alkohol: ' . (!empty($summary['alcohol']) ? 'ja' : 'nein'),
|
||||
'',
|
||||
'## Ereignisse',
|
||||
...($eventLines !== [] ? $eventLines : ['- Keine Ereignisse', '']),
|
||||
'## Gesundheitsdaten',
|
||||
'- Schritte: ' . (int) ($health['steps'] ?? 0),
|
||||
'- Schritte importiert am: ' . (string) ($health['steps_imported_at'] ?? ''),
|
||||
'',
|
||||
'## Tracking',
|
||||
'## Werte',
|
||||
'- Stimmung: ' . $entry['mood'],
|
||||
'- Energie: ' . $entry['energy'],
|
||||
'- Stress: ' . $entry['stress'],
|
||||
...(!empty($entry['pain_enabled']) ? ['- Schmerzen: ' . $entry['pain']] : []),
|
||||
'- Schlafdauer: ' . $entry['sleep_hours'],
|
||||
'- Schlafgefühl: ' . $entry['sleep_feeling'],
|
||||
'- Sport: ' . $entry['sport_minutes'],
|
||||
'- Sportarten: ' . implode(', ', $sportTypeValues),
|
||||
'- 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)),
|
||||
'- Alkohol: ' . (!empty($entry['alcohol']) ? 'ja' : 'nein'),
|
||||
'',
|
||||
'## Bewertung',
|
||||
'- 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']),
|
||||
'- Energie: ' . format_points((float) $evaluation['components']['energy']),
|
||||
'- 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']),
|
||||
'- Schlafgefühl: ' . format_points((float) $evaluation['components']['sleep_feeling']),
|
||||
'- Sport: ' . format_points((float) $evaluation['components']['sport_minutes']),
|
||||
'- Sportbonus: ' . format_points((float) ($evaluation['components']['sport_bonus'] ?? 0)),
|
||||
'- Spaziergang: ' . format_points((float) $evaluation['components']['walk_minutes']),
|
||||
'- Schrittebonus: ' . format_points((float) ($evaluation['components']['step_bonus'] ?? 0)),
|
||||
'- Momente: ' . format_points((float) ($evaluation['components']['events'] ?? 0)),
|
||||
'- Alkohol: ' . format_points((float) ($evaluation['components']['alcohol'] ?? 0)),
|
||||
'- Notiz: ' . format_points((float) $evaluation['components']['note']),
|
||||
'',
|
||||
'## Notiz',
|
||||
trim((string) $entry['note']),
|
||||
trim((string) ($summary['comment'] ?? $entry['note'] ?? '')),
|
||||
'',
|
||||
];
|
||||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
private function parseV3(string $content, string $fallbackDate): ?array
|
||||
{
|
||||
$date = $this->extract('/^Datum:\s*(\d{4}-\d{2}-\d{2})$/m', $content) ?? $fallbackDate;
|
||||
$backgroundImage = trim((string) ($this->extract('/^Hintergrundbild:[ \t]*([^\r\n]*)$/mu', $content) ?? ''));
|
||||
if (preg_match('/^[A-Za-z0-9._-]+\.(?:jpe?g|png|webp)$/i', $backgroundImage) !== 1) {
|
||||
$backgroundImage = '';
|
||||
}
|
||||
$summarySection = $this->extractSection($content, '## Tagesbilanz', '## Ereignisse');
|
||||
$eventsSection = $this->extractSection($content, '## Ereignisse', '## Gesundheitsdaten')
|
||||
?? $this->extractSection($content, '## Ereignisse', '## Tracking');
|
||||
$healthSection = $this->extractSection($content, '## Gesundheitsdaten', '## Tracking');
|
||||
$legacySection = $this->extractSection($content, '## Werte', '## Bewertung');
|
||||
$legacyContent = $legacySection !== null ? "## Werte\n" . $legacySection : $content;
|
||||
|
||||
$base = $this->parse(str_replace('<!-- mood-tracker:v3 -->', '<!-- mood-tracker:v2 -->', $legacyContent), $fallbackDate);
|
||||
if ($base === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$summary = [
|
||||
'comment' => (string) ($this->extract('/^- Kommentar:\s*(.*)$/mu', $summarySection ?? '') ?? ''),
|
||||
'mood' => normalize_signal_value($this->extract('/^- Stimmung:\s*(-?\d+)$/m', $summarySection ?? '') ?? 0),
|
||||
'energy' => normalize_signal_value($this->extract('/^- Energie:\s*(-?\d+)$/m', $summarySection ?? '') ?? 0),
|
||||
'stress' => normalize_signal_value($this->extract('/^- Stress:\s*(-?\d+)$/m', $summarySection ?? '') ?? 0),
|
||||
'alcohol' => in_array(strtolower((string) ($this->extract('/^- Alkohol:\s*(.*)$/mu', $summarySection ?? '') ?? 'nein')), ['ja', 'yes', 'true', '1'], true),
|
||||
];
|
||||
|
||||
$events = [];
|
||||
foreach (preg_split('/^###\s+/m', trim((string) $eventsSection)) ?: [] as $chunk) {
|
||||
$chunk = trim($chunk);
|
||||
if ($chunk === '' || str_starts_with($chunk, '- Keine Ereignisse')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$lines = preg_split('/\R/', $chunk) ?: [];
|
||||
$id = trim((string) array_shift($lines));
|
||||
$block = implode("\n", $lines);
|
||||
$typeLine = (string) ($this->extract('/^- Typ:\s*.*\[([^\]]+)\]$/mu', $block) ?? 'event');
|
||||
|
||||
$events[] = [
|
||||
'id' => $id,
|
||||
'type' => $typeLine,
|
||||
'time' => (string) ($this->extract('/^- Uhrzeit:\s*(.*)$/mu', $block) ?? ''),
|
||||
'value' => (float) ($this->extract('/^- Wert:\s*(.*)$/mu', $block) ?? 0),
|
||||
'unit' => (string) ($this->extract('/^- Einheit:\s*(.*)$/mu', $block) ?? ''),
|
||||
'sport_type_id' => (string) ($this->extract('/^- Sportart:\s*(.*)$/mu', $block) ?? ''),
|
||||
'image' => $this->normalizeImageFileName((string) ($this->extract('/^- Bild:\s*(.*)$/mu', $block) ?? '')),
|
||||
'consumed' => in_array(strtolower((string) ($this->extract('/^- Getrunken:\s*(.*)$/mu', $block) ?? 'ja')), ['ja', 'yes', 'true', '1'], true),
|
||||
'comment' => (string) ($this->extract('/^- Kommentar:\s*(.*)$/mu', $block) ?? ''),
|
||||
'mood' => normalize_signal_value($this->extract('/^- Stimmung:\s*(-?\d+)$/m', $block) ?? 0),
|
||||
'energy' => normalize_signal_value($this->extract('/^- Energie:\s*(-?\d+)$/m', $block) ?? 0),
|
||||
'stress' => normalize_signal_value($this->extract('/^- Stress:\s*(-?\d+)$/m', $block) ?? 0),
|
||||
'source' => (string) ($this->extract('/^- Quelle:\s*(.*)$/mu', $block) ?? ''),
|
||||
'import_id' => (string) ($this->extract('/^- Import-ID:\s*(.*)$/mu', $block) ?? ''),
|
||||
'duration_label' => (string) ($this->extract('/^- Dauer-Label:\s*(.*)$/mu', $block) ?? ''),
|
||||
'distance_label' => (string) ($this->extract('/^- Distanz-Label:\s*(.*)$/mu', $block) ?? ''),
|
||||
'energy_label' => (string) ($this->extract('/^- Energie-Label:\s*(.*)$/mu', $block) ?? ''),
|
||||
'heart_rate_label' => (string) ($this->extract('/^- Puls-Label:\s*(.*)$/mu', $block) ?? ''),
|
||||
'sleep_deep' => (float) ($this->extract('/^- Tiefschlaf:\s*(.*)$/mu', $block) ?? 0),
|
||||
'sleep_rem' => (float) ($this->extract('/^- REM-Schlaf:\s*(.*)$/mu', $block) ?? 0),
|
||||
'sleep_core' => (float) ($this->extract('/^- Kernschlaf:\s*(.*)$/mu', $block) ?? 0),
|
||||
'route' => $this->decodeRoute((string) ($this->extract('/^- Route:\s*(.*)$/mu', $block) ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
$health = [
|
||||
'steps' => max(0, (int) ($this->extract('/^- Schritte:\s*(\d+)$/m', $healthSection ?? '') ?? 0)),
|
||||
'steps_imported_at' => (string) ($this->extract('/^- Schritte importiert am:\s*(.*)$/mu', $healthSection ?? '') ?? ''),
|
||||
];
|
||||
|
||||
$base['date'] = $date;
|
||||
$base['background_image'] = $backgroundImage;
|
||||
$base['summary'] = $summary;
|
||||
$base['summary_comment'] = $summary['comment'];
|
||||
$base['summary_mood'] = $summary['mood'];
|
||||
$base['summary_energy'] = $summary['energy'];
|
||||
$base['summary_stress'] = $summary['stress'];
|
||||
$base['summary_alcohol'] = !empty($summary['alcohol']);
|
||||
$base['health'] = $health;
|
||||
$base['events'] = $events;
|
||||
$base['alcohol'] = !empty($summary['alcohol']);
|
||||
$base['note'] = $summary['comment'];
|
||||
|
||||
return $base;
|
||||
}
|
||||
|
||||
private function extractSection(string $content, string $startHeading, string $endHeading): ?string
|
||||
{
|
||||
$pattern = '/' . preg_quote($startHeading, '/') . '\s*(.*?)\s*' . preg_quote($endHeading, '/') . '/su';
|
||||
|
||||
if (preg_match($pattern, $content, $matches) !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trim((string) ($matches[1] ?? ''));
|
||||
}
|
||||
|
||||
private function normalizeImageFileName(string $fileName): string
|
||||
{
|
||||
$fileName = trim($fileName);
|
||||
|
||||
return preg_match('/^[A-Za-z0-9._-]+\.(?:jpe?g|png|webp)$/i', $fileName) === 1 ? $fileName : '';
|
||||
}
|
||||
|
||||
private function decodeRoute(string $encoded): array
|
||||
{
|
||||
$encoded = trim($encoded);
|
||||
if ($encoded === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$decoded = base64_decode($encoded, true);
|
||||
if (!is_string($decoded)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$route = json_decode($decoded, true);
|
||||
if (!is_array($route)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$points = [];
|
||||
foreach ($route as $point) {
|
||||
if (!is_array($point) || !is_numeric($point['lat'] ?? null) || !is_numeric($point['lon'] ?? null)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$lat = (float) $point['lat'];
|
||||
$lon = (float) $point['lon'];
|
||||
if ($lat < -90 || $lat > 90 || $lon < -180 || $lon > 180) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$points[] = [
|
||||
'lat' => round($lat, 6),
|
||||
'lon' => round($lon, 6),
|
||||
];
|
||||
}
|
||||
|
||||
return $points;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,22 +6,49 @@ final class ScoringService
|
||||
{
|
||||
public function normalize(array $input): array
|
||||
{
|
||||
$sportTypes = normalize_sport_type_selection($input['sport_types'] ?? ($input['sport_type'] ?? []));
|
||||
$hasSummaryInput = is_array($input['summary'] ?? null)
|
||||
|| array_key_exists('summary_mood', $input)
|
||||
|| array_key_exists('summary_energy', $input)
|
||||
|| array_key_exists('summary_stress', $input);
|
||||
$hasEventInput = is_array($input['events'] ?? null) && $input['events'] !== [];
|
||||
$summary = $this->normalizeSummary($input['summary'] ?? [
|
||||
'comment' => $input['summary_comment'] ?? ($input['note'] ?? ''),
|
||||
'mood' => $input['summary_mood'] ?? legacy_to_signal_scale($input['mood'] ?? 5),
|
||||
'energy' => $input['summary_energy'] ?? legacy_to_signal_scale($input['energy'] ?? 5),
|
||||
'stress' => $input['summary_stress'] ?? legacy_to_signal_scale($input['stress'] ?? 5),
|
||||
'alcohol' => !empty($input['summary_alcohol'] ?? $input['alcohol'] ?? false),
|
||||
]);
|
||||
$events = $this->normalizeEvents($input['events'] ?? []);
|
||||
$derived = $this->deriveLegacyFieldsFromEvents($events, $summary, $input);
|
||||
$sportTypes = normalize_sport_type_selection($hasEventInput ? ($derived['sport_types'] ?? []) : ($input['sport_types'] ?? ($derived['sport_types'] ?? ($input['sport_type'] ?? []))));
|
||||
$health = $this->normalizeHealth($input['health'] ?? []);
|
||||
|
||||
return [
|
||||
'date' => $input['date'] ?? today(),
|
||||
'mood' => max(1, min(10, (int) ($input['mood'] ?? 5))),
|
||||
'energy' => max(1, min(10, (int) ($input['energy'] ?? 5))),
|
||||
'stress' => max(1, min(10, (int) ($input['stress'] ?? 5))),
|
||||
'sleep_hours' => max(0, min(24, (float) ($input['sleep_hours'] ?? 0))),
|
||||
'sleep_feeling' => max(1, min(5, (int) ($input['sleep_feeling'] ?? 3))),
|
||||
'sport_minutes' => max(0, min(1440, (int) ($input['sport_minutes'] ?? 0))),
|
||||
'mood' => max(1, min(10, (int) ($hasSummaryInput ? $derived['mood'] : ($input['mood'] ?? $derived['mood'])))),
|
||||
'energy' => max(1, min(10, (int) ($hasSummaryInput ? $derived['energy'] : ($input['energy'] ?? $derived['energy'])))),
|
||||
'stress' => max(1, min(10, (int) ($hasSummaryInput ? $derived['stress'] : ($input['stress'] ?? $derived['stress'])))),
|
||||
'pain' => max(1, min(10, (int) ($input['pain'] ?? 1))),
|
||||
'pain_enabled' => $this->normalizeBoolean($input['pain_enabled'] ?? false),
|
||||
'sleep_hours' => max(0, min(24, (float) ($hasEventInput ? $derived['sleep_hours'] : ($input['sleep_hours'] ?? $derived['sleep_hours'])))),
|
||||
'sleep_feeling' => max(1, min(5, (int) ($hasSummaryInput ? $derived['sleep_feeling'] : ($input['sleep_feeling'] ?? $derived['sleep_feeling'])))),
|
||||
'sport_minutes' => max(0, min(1440, (int) ($hasEventInput ? $derived['sport_minutes'] : ($input['sport_minutes'] ?? $derived['sport_minutes'])))),
|
||||
'sport_type' => $sportTypes[0] ?? '',
|
||||
'sport_types' => $sportTypes,
|
||||
'walk_mode' => $this->normalizeWalkMode((string) ($input['walk_mode'] ?? ((isset($input['walk_steps']) && !isset($input['walk_minutes'])) ? 'steps' : 'time'))),
|
||||
'walk_minutes' => max(0, min(1440, (int) ($input['walk_minutes'] ?? 0))),
|
||||
'walk_steps' => max(0, min(50000, (int) ($input['walk_steps'] ?? 0))),
|
||||
'note' => trim((string) ($input['note'] ?? '')),
|
||||
'walk_mode' => $this->normalizeWalkMode((string) ($input['walk_mode'] ?? $derived['walk_mode'] ?? ((isset($input['walk_steps']) && !isset($input['walk_minutes'])) ? 'steps' : 'time'))),
|
||||
'walk_minutes' => max(0, min(1440, (int) ($hasEventInput ? $derived['walk_minutes'] : ($input['walk_minutes'] ?? $derived['walk_minutes'])))),
|
||||
'walk_steps' => max(0, min(50000, (int) ($hasEventInput ? $derived['walk_steps'] : ($input['walk_steps'] ?? $derived['walk_steps'])))),
|
||||
'alcohol' => $this->normalizeBoolean($hasSummaryInput ? $derived['alcohol'] : ($input['alcohol'] ?? $derived['alcohol'])),
|
||||
'note' => trim((string) ($input['note'] ?? $summary['comment'])),
|
||||
'summary' => $summary,
|
||||
'summary_comment' => $summary['comment'],
|
||||
'summary_mood' => $summary['mood'],
|
||||
'summary_energy' => $summary['energy'],
|
||||
'summary_stress' => $summary['stress'],
|
||||
'summary_alcohol' => !empty($summary['alcohol']),
|
||||
'background_image' => trim((string) ($input['background_image'] ?? '')),
|
||||
'health' => $health,
|
||||
'events' => $events,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -33,6 +60,8 @@ final class ScoringService
|
||||
$ratings = $this->sortedRatings($settings['ratings'] ?? []);
|
||||
$sportTypes = find_sport_types($settings, $entry['sport_types']);
|
||||
$sportBonus = $this->sportBonusPoints($entry, $previousEntry, $settings, $sportTypes);
|
||||
$eventSignalPoints = $this->eventSignalPoints($entry['events']);
|
||||
$painEnabled = !empty($settings['tracking']['pain_enabled']);
|
||||
|
||||
$components = [
|
||||
'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_bonus' => $sportBonus,
|
||||
'walk_minutes' => $this->walkPoints($entry, $settings),
|
||||
'step_bonus' => $this->stepBonusPoints($entry, $scoring['step_bonus'] ?? []),
|
||||
'daily_steps' => $this->stepTargetPoints((int) ($entry['health']['steps'] ?? 0), $scoring['walk_step_targets'] ?? []),
|
||||
'events' => $eventSignalPoints,
|
||||
'alcohol' => !empty($entry['alcohol']) ? ((float) abs((float) ($scoring['alcohol_penalty'] ?? 5)) * -1) : 0.0,
|
||||
'note' => $entry['note'] === '' ? 0 : (float) $scoring['journal_points'],
|
||||
];
|
||||
|
||||
if ($painEnabled) {
|
||||
$components['pain'] = (11 - $entry['pain']) * (float) $scoring['pain_multiplier'];
|
||||
}
|
||||
|
||||
$total = round(array_sum($components), 1);
|
||||
$maxTotal = round(
|
||||
(10 * (float) $scoring['mood_multiplier']) +
|
||||
(10 * (float) $scoring['energy_multiplier']) +
|
||||
(10 * (float) $scoring['stress_multiplier']) +
|
||||
($painEnabled ? (10 * (float) $scoring['pain_multiplier']) : 0.0) +
|
||||
max(array_map('floatval', $scoring['sleep_duration_points'])) +
|
||||
(5 * (float) $scoring['sleep_feeling_multiplier']) +
|
||||
$this->maxBandPoints($scoring['sport_bands']) +
|
||||
$this->maxSportBonusPoints($settings) +
|
||||
$this->maxWalkPoints($entry, $settings) +
|
||||
$this->maxStepTargetPoints($scoring['walk_step_targets'] ?? []) +
|
||||
max(0.0, (float) ($scoring['step_bonus']['points'] ?? 0)) +
|
||||
($eventSignalPoints !== 0.0 ? 8.0 : 0.0) +
|
||||
(float) $scoring['journal_points'],
|
||||
1
|
||||
);
|
||||
@@ -85,11 +126,72 @@ final class ScoringService
|
||||
'guardrail' => $guardrail,
|
||||
'sentiment' => $this->sentimentForLabel($label, $ratings),
|
||||
'percentage' => $maxTotal > 0 ? round(($total / $maxTotal) * 100, 1) : 0,
|
||||
'balance' => $this->dayBalance($entry, $components, $settings),
|
||||
'sport_type' => $sportTypes[0] ?? null,
|
||||
'sport_types' => $sportTypes,
|
||||
];
|
||||
}
|
||||
|
||||
private function dayBalance(array $entry, array $components, array $settings): array
|
||||
{
|
||||
$config = is_array($settings['day_balance'] ?? null) ? $settings['day_balance'] : [];
|
||||
$moodWeight = max(0.0, (float) ($config['mood_weight'] ?? 3));
|
||||
$energyWeight = max(0.0, (float) ($config['energy_weight'] ?? 2));
|
||||
$stressWeight = max(0.0, (float) ($config['stress_weight'] ?? 2));
|
||||
$weightTotal = max(1.0, $moodWeight + $energyWeight + $stressWeight);
|
||||
|
||||
$summary = is_array($entry['summary'] ?? null) ? $entry['summary'] : [];
|
||||
$mood = normalize_signal_value($summary['mood'] ?? $entry['summary_mood'] ?? legacy_to_signal_scale($entry['mood'] ?? 5));
|
||||
$energy = normalize_signal_value($summary['energy'] ?? $entry['summary_energy'] ?? legacy_to_signal_scale($entry['energy'] ?? 5));
|
||||
$stress = normalize_signal_value($summary['stress'] ?? $entry['summary_stress'] ?? legacy_to_signal_scale($entry['stress'] ?? 5));
|
||||
|
||||
$base = (($mood * $moodWeight) + ($energy * $energyWeight) + ((-$stress) * $stressWeight)) / $weightTotal;
|
||||
$adjustmentPoints = 0.0;
|
||||
foreach ($components as $key => $value) {
|
||||
if (in_array($key, ['mood', 'energy', 'stress', 'pain'], true)) {
|
||||
continue;
|
||||
}
|
||||
$adjustmentPoints += (float) $value;
|
||||
}
|
||||
|
||||
$pointsPerStep = max(1.0, (float) ($config['points_per_step'] ?? 12));
|
||||
$cap = max(0.0, min(2.0, (float) ($config['adjustment_cap'] ?? 1.0)));
|
||||
$adjustment = max(-$cap, min($cap, $adjustmentPoints / $pointsPerStep));
|
||||
$raw = max(-2.0, min(2.0, $base + $adjustment));
|
||||
$level = max(-2, min(2, (int) round($raw)));
|
||||
|
||||
return [
|
||||
'base' => round($base, 2),
|
||||
'adjustment' => round($adjustment, 2),
|
||||
'raw' => round($raw, 2),
|
||||
'level' => $level,
|
||||
'percentage' => round((($raw + 2.0) / 4.0) * 100, 1),
|
||||
'tone' => signal_value_class($level),
|
||||
];
|
||||
}
|
||||
|
||||
private function eventSignalPoints(array $events): float
|
||||
{
|
||||
if ($events === []) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$scores = [];
|
||||
foreach ($events as $event) {
|
||||
if (!is_array($event)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$scores[] = signal_combo_score($event['mood'] ?? 0, $event['energy'] ?? 0, $event['stress'] ?? 0);
|
||||
}
|
||||
|
||||
if ($scores === []) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return round(max(-8.0, min(8.0, (array_sum($scores) / count($scores)) * 4.0)), 1);
|
||||
}
|
||||
|
||||
private function sleepDurationPoints(float $hours, array $points): float
|
||||
{
|
||||
if ($hours < 4) {
|
||||
@@ -160,6 +262,19 @@ final class ScoringService
|
||||
return $this->bandPoints((int) $entry['walk_minutes'], $scoring['walk_bands'] ?? []);
|
||||
}
|
||||
|
||||
private function stepBonusPoints(array $entry, array $config): float
|
||||
{
|
||||
$steps = (int) ($entry['health']['steps'] ?? 0);
|
||||
$min = max(0, (int) ($config['min'] ?? 10000));
|
||||
$max = max($min, (int) ($config['max'] ?? 15000));
|
||||
|
||||
if ($steps > $min && $steps <= $max) {
|
||||
return max(0.0, (float) ($config['points'] ?? 1));
|
||||
}
|
||||
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
private function maxWalkPoints(array $entry, array $settings): float
|
||||
{
|
||||
$scoring = $settings['scoring'] ?? [];
|
||||
@@ -176,6 +291,20 @@ final class ScoringService
|
||||
return $this->maxBandPoints($scoring['walk_bands'] ?? []);
|
||||
}
|
||||
|
||||
private function maxStepTargetPoints(array $targets): float
|
||||
{
|
||||
$max = 0.0;
|
||||
foreach ($targets as $target) {
|
||||
if (!is_array($target)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$max = max($max, (float) ($target['points'] ?? 0));
|
||||
}
|
||||
|
||||
return $max;
|
||||
}
|
||||
|
||||
private function stepTargetPoints(int $steps, array $targets): float
|
||||
{
|
||||
if ($targets === []) {
|
||||
@@ -294,6 +423,205 @@ final class ScoringService
|
||||
return $total;
|
||||
}
|
||||
|
||||
private function normalizeSummary(mixed $summary): array
|
||||
{
|
||||
$summary = is_array($summary) ? $summary : [];
|
||||
|
||||
return [
|
||||
'comment' => trim((string) ($summary['comment'] ?? '')),
|
||||
'mood' => normalize_signal_value($summary['mood'] ?? 0),
|
||||
'energy' => normalize_signal_value($summary['energy'] ?? 0),
|
||||
'stress' => normalize_signal_value($summary['stress'] ?? 0),
|
||||
'alcohol' => $this->normalizeBoolean($summary['alcohol'] ?? false),
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeEvents(mixed $events): array
|
||||
{
|
||||
if (!is_array($events)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$normalized = [];
|
||||
|
||||
foreach ($events as $event) {
|
||||
if (!is_array($event)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$type = trim((string) ($event['type'] ?? 'event'));
|
||||
if (!array_key_exists($type, day_event_type_options())) {
|
||||
$type = 'event';
|
||||
}
|
||||
|
||||
$time = trim((string) ($event['time'] ?? ''));
|
||||
if (preg_match('/^(?:[01]\d|2[0-3]):[0-5]\d$/', $time) !== 1) {
|
||||
$time = '';
|
||||
}
|
||||
|
||||
$unit = trim((string) ($event['unit'] ?? day_event_type_unit($type)));
|
||||
$value = is_numeric($event['value'] ?? null) ? (float) $event['value'] : 0.0;
|
||||
|
||||
$normalized[] = [
|
||||
'id' => trim((string) ($event['id'] ?? '')) ?: ('evt-' . substr(sha1(json_encode($event) . microtime(true)), 0, 12)),
|
||||
'type' => $type,
|
||||
'time' => $time,
|
||||
'comment' => trim(preg_replace('/\s+/u', ' ', (string) ($event['comment'] ?? '')) ?? ''),
|
||||
'value' => max(0, min(50000, $value)),
|
||||
'unit' => $unit,
|
||||
'sport_type_id' => trim((string) ($event['sport_type_id'] ?? '')),
|
||||
'image' => trim((string) ($event['image'] ?? '')),
|
||||
'consumed' => $this->normalizeBoolean($event['consumed'] ?? true),
|
||||
'mood' => normalize_signal_value($event['mood'] ?? 0),
|
||||
'energy' => normalize_signal_value($event['energy'] ?? 0),
|
||||
'stress' => normalize_signal_value($event['stress'] ?? 0),
|
||||
'source' => trim((string) ($event['source'] ?? '')),
|
||||
'import_id' => trim((string) ($event['import_id'] ?? '')),
|
||||
'duration_label' => trim((string) ($event['duration_label'] ?? '')),
|
||||
'distance_label' => trim((string) ($event['distance_label'] ?? '')),
|
||||
'energy_label' => trim((string) ($event['energy_label'] ?? '')),
|
||||
'heart_rate_label' => trim((string) ($event['heart_rate_label'] ?? '')),
|
||||
'sleep_deep' => max(0.0, min(24.0, is_numeric($event['sleep_deep'] ?? null) ? (float) $event['sleep_deep'] : 0.0)),
|
||||
'sleep_rem' => max(0.0, min(24.0, is_numeric($event['sleep_rem'] ?? null) ? (float) $event['sleep_rem'] : 0.0)),
|
||||
'sleep_core' => max(0.0, min(24.0, is_numeric($event['sleep_core'] ?? null) ? (float) $event['sleep_core'] : 0.0)),
|
||||
'route' => $this->normalizeRoute($event['route'] ?? []),
|
||||
];
|
||||
}
|
||||
|
||||
usort($normalized, static function (array $left, array $right): int {
|
||||
return strcmp((string) ($left['time'] ?? ''), (string) ($right['time'] ?? ''));
|
||||
});
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
private function normalizeHealth(mixed $health): array
|
||||
{
|
||||
if (!is_array($health)) {
|
||||
return [
|
||||
'steps' => 0,
|
||||
'steps_imported_at' => '',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'steps' => max(0, min(100000, (int) ($health['steps'] ?? 0))),
|
||||
'steps_imported_at' => trim((string) ($health['steps_imported_at'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeRoute(mixed $route): array
|
||||
{
|
||||
if (!is_array($route)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$points = [];
|
||||
foreach ($route as $point) {
|
||||
if (!is_array($point)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$lat = $point['lat'] ?? $point['latitude'] ?? null;
|
||||
$lon = $point['lon'] ?? $point['longitude'] ?? null;
|
||||
|
||||
if (!is_numeric($lat) || !is_numeric($lon)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$lat = (float) $lat;
|
||||
$lon = (float) $lon;
|
||||
|
||||
if ($lat < -90 || $lat > 90 || $lon < -180 || $lon > 180) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$points[] = [
|
||||
'lat' => round($lat, 6),
|
||||
'lon' => round($lon, 6),
|
||||
];
|
||||
}
|
||||
|
||||
if (count($points) <= 180) {
|
||||
return $points;
|
||||
}
|
||||
|
||||
$step = max(1, (int) floor(count($points) / 180));
|
||||
$reduced = [];
|
||||
foreach ($points as $index => $point) {
|
||||
if ($index % $step === 0) {
|
||||
$reduced[] = $point;
|
||||
}
|
||||
}
|
||||
|
||||
$last = $points[count($points) - 1];
|
||||
if ($reduced === [] || $reduced[count($reduced) - 1] !== $last) {
|
||||
$reduced[] = $last;
|
||||
}
|
||||
|
||||
return $reduced;
|
||||
}
|
||||
|
||||
private function deriveLegacyFieldsFromEvents(array $events, array $summary, array $input): array
|
||||
{
|
||||
$sportMinutes = 0;
|
||||
$walkMinutes = 0;
|
||||
$walkSteps = 0;
|
||||
$sleepHours = 0.0;
|
||||
$alcohol = false;
|
||||
$walkMode = $this->normalizeWalkMode((string) ($input['walk_mode'] ?? 'time'));
|
||||
$sportTypes = [];
|
||||
|
||||
foreach ($events as $event) {
|
||||
$type = (string) ($event['type'] ?? 'event');
|
||||
$unit = (string) ($event['unit'] ?? '');
|
||||
$value = (float) ($event['value'] ?? 0);
|
||||
|
||||
if ($type === 'sport') {
|
||||
$sportMinutes += (int) round($value);
|
||||
$sportTypeID = trim((string) ($event['sport_type_id'] ?? ''));
|
||||
if ($sportTypeID !== '') {
|
||||
$sportTypes[$sportTypeID] = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($type === 'walk') {
|
||||
if ($unit === 'steps') {
|
||||
$walkMode = 'steps';
|
||||
$walkSteps += (int) round($value);
|
||||
} else {
|
||||
$walkMinutes += (int) round($value);
|
||||
}
|
||||
}
|
||||
|
||||
if ($type === 'sleep') {
|
||||
$sleepHours += $unit === 'min' ? ($value / 60) : $value;
|
||||
}
|
||||
|
||||
if ($type === 'alcohol') {
|
||||
$alcohol = !empty($event['consumed']);
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($summary['alcohol'])) {
|
||||
$alcohol = true;
|
||||
}
|
||||
|
||||
return [
|
||||
'mood' => signal_to_legacy_scale($summary['mood']),
|
||||
'energy' => signal_to_legacy_scale($summary['energy']),
|
||||
'stress' => signal_to_legacy_scale($summary['stress']),
|
||||
'sleep_hours' => $sleepHours,
|
||||
'sleep_feeling' => max(1, min(5, 3 + $summary['energy'])),
|
||||
'sport_minutes' => $sportMinutes,
|
||||
'walk_mode' => $walkMode,
|
||||
'walk_minutes' => $walkMinutes,
|
||||
'walk_steps' => $walkSteps,
|
||||
'alcohol' => $alcohol,
|
||||
'sport_types' => array_keys($sportTypes),
|
||||
];
|
||||
}
|
||||
|
||||
private function sortedRatings(array $ratings): array
|
||||
{
|
||||
usort($ratings, static fn (array $a, array $b): int => ((int) $a['min']) <=> ((int) $b['min']));
|
||||
@@ -353,4 +681,21 @@ final class ScoringService
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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] ?? ''));
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ final class UserRepository
|
||||
|
||||
public function verify(string $username, string $password): ?array
|
||||
{
|
||||
$user = $this->find($username);
|
||||
$user = $this->find($username) ?? [];
|
||||
|
||||
if ($user === null) {
|
||||
return null;
|
||||
@@ -51,6 +51,257 @@ final class UserRepository
|
||||
return $user;
|
||||
}
|
||||
|
||||
public function findByRememberToken(string $selector, string $validator): ?array
|
||||
{
|
||||
$validatorHash = hash('sha256', $validator);
|
||||
$now = time();
|
||||
|
||||
foreach ($this->all() as $user) {
|
||||
$token = $user['remember_token'] ?? null;
|
||||
|
||||
if (!is_array($token) || !hash_equals((string) ($token['selector'] ?? ''), $selector)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$expiresAt = strtotime((string) ($token['expires_at'] ?? '')) ?: 0;
|
||||
|
||||
if ($expiresAt < $now) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!hash_equals((string) ($token['validator_hash'] ?? ''), $validatorHash)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function storeRememberToken(string $username, string $selector, string $validatorHash, int $expiresAt): void
|
||||
{
|
||||
$normalized = normalize_username($username);
|
||||
$users = $this->all();
|
||||
$updated = false;
|
||||
|
||||
foreach ($users as &$user) {
|
||||
if (($user['username'] ?? '') !== $normalized) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$user['remember_token'] = [
|
||||
'selector' => $selector,
|
||||
'validator_hash' => $validatorHash,
|
||||
'expires_at' => date(DATE_ATOM, $expiresAt),
|
||||
'created_at' => date(DATE_ATOM),
|
||||
];
|
||||
$user['updated_at'] = date(DATE_ATOM);
|
||||
$updated = true;
|
||||
break;
|
||||
}
|
||||
unset($user);
|
||||
|
||||
if (!$updated) {
|
||||
throw new RuntimeException('Der Remember-Me-Token konnte keinem Benutzer zugeordnet werden.');
|
||||
}
|
||||
|
||||
$this->write(['users' => $users]);
|
||||
}
|
||||
|
||||
public function clearRememberToken(string $username): void
|
||||
{
|
||||
$normalized = normalize_username($username);
|
||||
$users = $this->all();
|
||||
$updated = false;
|
||||
|
||||
foreach ($users as &$user) {
|
||||
if (($user['username'] ?? '') !== $normalized || !array_key_exists('remember_token', $user)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
unset($user['remember_token']);
|
||||
$user['updated_at'] = date(DATE_ATOM);
|
||||
$updated = true;
|
||||
break;
|
||||
}
|
||||
unset($user);
|
||||
|
||||
if ($updated) {
|
||||
$this->write(['users' => $users]);
|
||||
}
|
||||
}
|
||||
|
||||
public function findByHealthImportToken(string $token): ?array
|
||||
{
|
||||
$tokenHash = hash('sha256', $token);
|
||||
|
||||
foreach ($this->all() as $user) {
|
||||
$config = $user['health_import'] ?? null;
|
||||
|
||||
if (!is_array($config) || empty($config['enabled'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (hash_equals((string) ($config['token_hash'] ?? ''), $tokenHash)) {
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function healthImportConfig(string $username): array
|
||||
{
|
||||
$user = $this->find($username);
|
||||
$config = is_array($user['health_import'] ?? null) ? $user['health_import'] : [];
|
||||
|
||||
return [
|
||||
'enabled' => !empty($config['enabled']),
|
||||
'token_prefix' => (string) ($config['token_prefix'] ?? ''),
|
||||
'created_at' => (string) ($config['created_at'] ?? ''),
|
||||
'last_import_at' => (string) ($config['last_import_at'] ?? ''),
|
||||
'last_status' => (string) ($config['last_status'] ?? ''),
|
||||
'last_message' => (string) ($config['last_message'] ?? ''),
|
||||
'progress_done' => max(0, (int) ($config['progress_done'] ?? 0)),
|
||||
'progress_total' => max(0, (int) ($config['progress_total'] ?? 0)),
|
||||
'started_at' => (string) ($config['started_at'] ?? ''),
|
||||
'updated_at' => (string) ($config['updated_at'] ?? ''),
|
||||
'finished_at' => (string) ($config['finished_at'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
public function issueHealthImportToken(string $username): string
|
||||
{
|
||||
$token = 'mood_health_' . bin2hex(random_bytes(24));
|
||||
$normalized = normalize_username($username);
|
||||
$users = $this->all();
|
||||
$updated = false;
|
||||
|
||||
foreach ($users as &$user) {
|
||||
if (($user['username'] ?? '') !== $normalized) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$currentConfig = is_array($user['health_import'] ?? null) ? $user['health_import'] : [];
|
||||
$user['health_import'] = [
|
||||
'enabled' => true,
|
||||
'token_hash' => hash('sha256', $token),
|
||||
'token_prefix' => substr($token, 0, 18),
|
||||
'created_at' => date(DATE_ATOM),
|
||||
'last_import_at' => (string) ($currentConfig['last_import_at'] ?? ''),
|
||||
'last_status' => (string) ($currentConfig['last_status'] ?? ''),
|
||||
'last_message' => (string) ($currentConfig['last_message'] ?? ''),
|
||||
'progress_done' => max(0, (int) ($currentConfig['progress_done'] ?? 0)),
|
||||
'progress_total' => max(0, (int) ($currentConfig['progress_total'] ?? 0)),
|
||||
'started_at' => (string) ($currentConfig['started_at'] ?? ''),
|
||||
'updated_at' => (string) ($currentConfig['updated_at'] ?? ''),
|
||||
'finished_at' => (string) ($currentConfig['finished_at'] ?? ''),
|
||||
];
|
||||
$user['updated_at'] = date(DATE_ATOM);
|
||||
$updated = true;
|
||||
break;
|
||||
}
|
||||
unset($user);
|
||||
|
||||
if (!$updated) {
|
||||
throw new RuntimeException('Der Health-Import-Token konnte keinem Benutzer zugeordnet werden.');
|
||||
}
|
||||
|
||||
$this->write(['users' => $users]);
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
public function revokeHealthImportToken(string $username): void
|
||||
{
|
||||
$normalized = normalize_username($username);
|
||||
$users = $this->all();
|
||||
$updated = false;
|
||||
|
||||
foreach ($users as &$user) {
|
||||
if (($user['username'] ?? '') !== $normalized || !array_key_exists('health_import', $user)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
unset($user['health_import']);
|
||||
$user['updated_at'] = date(DATE_ATOM);
|
||||
$updated = true;
|
||||
break;
|
||||
}
|
||||
unset($user);
|
||||
|
||||
if ($updated) {
|
||||
$this->write(['users' => $users]);
|
||||
}
|
||||
}
|
||||
|
||||
public function recordHealthImport(string $username, string $status, string $message): void
|
||||
{
|
||||
$normalized = normalize_username($username);
|
||||
$users = $this->all();
|
||||
$updated = false;
|
||||
|
||||
foreach ($users as &$user) {
|
||||
if (($user['username'] ?? '') !== $normalized) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$config = is_array($user['health_import'] ?? null) ? $user['health_import'] : [];
|
||||
$config['last_import_at'] = date(DATE_ATOM);
|
||||
$config['last_status'] = $status;
|
||||
$config['last_message'] = substr($message, 0, 240);
|
||||
$config['updated_at'] = date(DATE_ATOM);
|
||||
if ($status !== 'running') {
|
||||
$config['finished_at'] = date(DATE_ATOM);
|
||||
if ($status === 'ok') {
|
||||
$total = max(0, (int) ($config['progress_total'] ?? 0));
|
||||
$config['progress_done'] = $total > 0 ? $total : max(0, (int) ($config['progress_done'] ?? 0));
|
||||
}
|
||||
}
|
||||
$user['health_import'] = $config;
|
||||
$user['updated_at'] = date(DATE_ATOM);
|
||||
$updated = true;
|
||||
break;
|
||||
}
|
||||
unset($user);
|
||||
|
||||
if ($updated) {
|
||||
$this->write(['users' => $users]);
|
||||
}
|
||||
}
|
||||
|
||||
public function recordHealthImportProgress(string $username, string $message, int $done, int $total, ?string $startedAt = null): void
|
||||
{
|
||||
$normalized = normalize_username($username);
|
||||
$users = $this->all();
|
||||
$updated = false;
|
||||
|
||||
foreach ($users as &$user) {
|
||||
if (($user['username'] ?? '') !== $normalized) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$config = is_array($user['health_import'] ?? null) ? $user['health_import'] : [];
|
||||
$config['last_status'] = 'running';
|
||||
$config['last_message'] = substr($message, 0, 240);
|
||||
$config['progress_done'] = max(0, min($done, max($total, 0)));
|
||||
$config['progress_total'] = max(0, $total);
|
||||
$config['started_at'] = $startedAt ?? (string) ($config['started_at'] ?? date(DATE_ATOM));
|
||||
$config['updated_at'] = date(DATE_ATOM);
|
||||
$config['finished_at'] = '';
|
||||
$user['health_import'] = $config;
|
||||
$user['updated_at'] = date(DATE_ATOM);
|
||||
$updated = true;
|
||||
break;
|
||||
}
|
||||
unset($user);
|
||||
|
||||
if ($updated) {
|
||||
$this->write(['users' => $users]);
|
||||
}
|
||||
}
|
||||
|
||||
public function create(string $username, string $password, bool $isAdmin = false): array
|
||||
{
|
||||
$normalized = normalize_username($username);
|
||||
|
||||
@@ -11,7 +11,7 @@ final class Auth
|
||||
public function check(): bool
|
||||
{
|
||||
if (!isset($_SESSION['user']) || !is_array($_SESSION['user'])) {
|
||||
return false;
|
||||
return $this->attemptRememberedLogin();
|
||||
}
|
||||
|
||||
$username = $_SESSION['user']['username'] ?? null;
|
||||
@@ -62,17 +62,76 @@ final class Auth
|
||||
$_SESSION['remember_me'] = $remember;
|
||||
|
||||
if ($remember) {
|
||||
$this->issueRememberCookie($user['username']);
|
||||
setcookie(session_name(), session_id(), session_cookie_options_for(time() + remember_me_lifetime()));
|
||||
} else {
|
||||
$this->users->clearRememberToken($user['username']);
|
||||
$this->clearRememberCookie();
|
||||
setcookie(session_name(), session_id(), session_cookie_options_for());
|
||||
}
|
||||
}
|
||||
|
||||
public function logout(): void
|
||||
{
|
||||
$username = $_SESSION['user']['username'] ?? null;
|
||||
|
||||
if (is_string($username) && $username !== '') {
|
||||
$this->users->clearRememberToken($username);
|
||||
}
|
||||
|
||||
unset($_SESSION['user']);
|
||||
unset($_SESSION['remember_me']);
|
||||
$this->clearRememberCookie();
|
||||
setcookie(session_name(), '', session_cookie_options_for(time() - 3600));
|
||||
session_regenerate_id(true);
|
||||
}
|
||||
|
||||
private function attemptRememberedLogin(): bool
|
||||
{
|
||||
$cookie = $_COOKIE[remember_cookie_name()] ?? '';
|
||||
|
||||
if (!is_string($cookie) || $cookie === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$parts = explode(':', $cookie, 2);
|
||||
|
||||
if (count($parts) !== 2) {
|
||||
$this->clearRememberCookie();
|
||||
return false;
|
||||
}
|
||||
|
||||
[$selector, $validator] = $parts;
|
||||
|
||||
if (!ctype_xdigit($selector) || strlen($selector) !== 32 || !ctype_xdigit($validator) || strlen($validator) !== 64) {
|
||||
$this->clearRememberCookie();
|
||||
return false;
|
||||
}
|
||||
|
||||
$user = $this->users->findByRememberToken($selector, $validator);
|
||||
|
||||
if ($user === null) {
|
||||
$this->clearRememberCookie();
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->login($user, true);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function issueRememberCookie(string $username): void
|
||||
{
|
||||
$selector = bin2hex(random_bytes(16));
|
||||
$validator = bin2hex(random_bytes(32));
|
||||
$expiresAt = time() + remember_me_lifetime();
|
||||
|
||||
$this->users->storeRememberToken($username, $selector, hash('sha256', $validator), $expiresAt);
|
||||
setcookie(remember_cookie_name(), $selector . ':' . $validator, session_cookie_options_for($expiresAt));
|
||||
}
|
||||
|
||||
private function clearRememberCookie(): void
|
||||
{
|
||||
setcookie(remember_cookie_name(), '', session_cookie_options_for(time() - 3600));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,22 @@ final class Defaults
|
||||
'walk' => [
|
||||
'mode' => 'time',
|
||||
],
|
||||
'sleep' => [
|
||||
'optimal_hours' => 7.0,
|
||||
],
|
||||
'display' => [
|
||||
'score_mode' => 'scale',
|
||||
],
|
||||
'day_balance' => [
|
||||
'mood_weight' => 3,
|
||||
'energy_weight' => 2,
|
||||
'stress_weight' => 2,
|
||||
'adjustment_cap' => 1.0,
|
||||
'points_per_step' => 12,
|
||||
],
|
||||
'tracking' => [
|
||||
'pain_enabled' => false,
|
||||
],
|
||||
'sport_types' => [
|
||||
[
|
||||
'id' => 'running',
|
||||
@@ -85,7 +101,7 @@ final class Defaults
|
||||
],
|
||||
[
|
||||
'id' => 'rowing',
|
||||
'label' => 'Rudern',
|
||||
'label' => 'Rudergerät',
|
||||
'icon' => 'row',
|
||||
'location' => '',
|
||||
'recovery_group' => 'rudern',
|
||||
@@ -115,6 +131,7 @@ final class Defaults
|
||||
'mood_multiplier' => 3,
|
||||
'energy_multiplier' => 2,
|
||||
'stress_multiplier' => 2,
|
||||
'pain_multiplier' => 3,
|
||||
'sleep_feeling_multiplier' => 2,
|
||||
'sleep_duration_points' => [
|
||||
'lt4' => 0,
|
||||
@@ -148,7 +165,13 @@ final class Defaults
|
||||
['steps' => 15000, 'points' => 4],
|
||||
['steps' => 20000, 'points' => 0],
|
||||
],
|
||||
'step_bonus' => [
|
||||
'min' => 10000,
|
||||
'max' => 15000,
|
||||
'points' => 1,
|
||||
],
|
||||
'journal_points' => 2,
|
||||
'alcohol_penalty' => 5,
|
||||
],
|
||||
'ratings' => [
|
||||
['label' => 'Scheißtag', 'min' => 0, 'max' => 39],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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') ?: ''));
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,15 @@ declare(strict_types=1);
|
||||
require __DIR__ . '/helpers.php';
|
||||
require __DIR__ . '/Support/Defaults.php';
|
||||
require __DIR__ . '/Support/Auth.php';
|
||||
require __DIR__ . '/Support/EntryCrypto.php';
|
||||
require __DIR__ . '/Support/OpenAiSummaryService.php';
|
||||
require __DIR__ . '/Support/View.php';
|
||||
require __DIR__ . '/Support/WebPushService.php';
|
||||
require __DIR__ . '/Domain/AiConfigRepository.php';
|
||||
require __DIR__ . '/Domain/UserRepository.php';
|
||||
require __DIR__ . '/Domain/SettingsRepository.php';
|
||||
require __DIR__ . '/Domain/EntryRepository.php';
|
||||
require __DIR__ . '/Domain/SummaryRepository.php';
|
||||
require __DIR__ . '/Domain/LoginThrottle.php';
|
||||
require __DIR__ . '/Domain/NotificationRepository.php';
|
||||
require __DIR__ . '/Domain/ScoringService.php';
|
||||
|
||||
@@ -115,6 +115,23 @@ function format_points(float $value): string
|
||||
return number_format($rounded, 1, ',', '.');
|
||||
}
|
||||
|
||||
function format_duration_hours(float $hours): string
|
||||
{
|
||||
$minutes = max(0, (int) round($hours * 60));
|
||||
$wholeHours = intdiv($minutes, 60);
|
||||
$remainingMinutes = $minutes % 60;
|
||||
|
||||
if ($wholeHours <= 0) {
|
||||
return $remainingMinutes . ' min';
|
||||
}
|
||||
|
||||
if ($remainingMinutes === 0) {
|
||||
return $wholeHours . ' h';
|
||||
}
|
||||
|
||||
return $wholeHours . ' h ' . $remainingMinutes . ' min';
|
||||
}
|
||||
|
||||
function normalize_username(string $username): string
|
||||
{
|
||||
return strtolower(trim($username));
|
||||
@@ -197,6 +214,109 @@ function format_display_date(string $date, bool $withWeekday = true): string
|
||||
return $weekdays[(int) $current->format('w')] . ', ' . $label;
|
||||
}
|
||||
|
||||
function format_compact_date(string $date): string
|
||||
{
|
||||
$current = DateTimeImmutable::createFromFormat('Y-m-d', $date);
|
||||
|
||||
if ($current === false) {
|
||||
return $date;
|
||||
}
|
||||
|
||||
return $current->format('d.m.Y');
|
||||
}
|
||||
|
||||
function format_display_datetime(string $value): string
|
||||
{
|
||||
try {
|
||||
$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
|
||||
{
|
||||
return '/assets/icons/' . $name . '.svg';
|
||||
@@ -232,6 +352,11 @@ function remember_me_lifetime(): int
|
||||
return 60 * 60 * 24 * 30;
|
||||
}
|
||||
|
||||
function remember_cookie_name(): string
|
||||
{
|
||||
return 'mood_remember';
|
||||
}
|
||||
|
||||
function session_cookie_params_for(int $lifetime = 0): array
|
||||
{
|
||||
return [
|
||||
@@ -352,6 +477,37 @@ function base64url_decode(string $data): string
|
||||
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
|
||||
{
|
||||
$value = trim(strtr($value, [
|
||||
@@ -500,3 +656,172 @@ function find_sport_types(array $settings, array $ids): array
|
||||
|
||||
return $types;
|
||||
}
|
||||
|
||||
function signal_scale_options(): array
|
||||
{
|
||||
return [
|
||||
-2 => 'sehr niedrig',
|
||||
-1 => 'niedrig',
|
||||
0 => 'neutral',
|
||||
1 => 'hoch',
|
||||
2 => 'sehr hoch',
|
||||
];
|
||||
}
|
||||
|
||||
function signal_labels_for_metric(string $metric): array
|
||||
{
|
||||
return match ($metric) {
|
||||
'stress' => [
|
||||
-2 => 'sehr ruhig',
|
||||
-1 => 'ruhig',
|
||||
0 => 'neutral',
|
||||
1 => 'angespannt',
|
||||
2 => 'sehr angespannt',
|
||||
],
|
||||
'energy' => [
|
||||
-2 => 'leer',
|
||||
-1 => 'matt',
|
||||
0 => 'okay',
|
||||
1 => 'wach',
|
||||
2 => 'kraftvoll',
|
||||
],
|
||||
default => [
|
||||
-2 => 'sehr niedrig',
|
||||
-1 => 'niedrig',
|
||||
0 => 'neutral',
|
||||
1 => 'hoch',
|
||||
2 => 'sehr hoch',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function normalize_signal_value(mixed $value): int
|
||||
{
|
||||
return max(-2, min(2, (int) $value));
|
||||
}
|
||||
|
||||
function signal_to_legacy_scale(mixed $value): int
|
||||
{
|
||||
return match (normalize_signal_value($value)) {
|
||||
-2 => 1,
|
||||
-1 => 3,
|
||||
0 => 5,
|
||||
1 => 7,
|
||||
2 => 9,
|
||||
};
|
||||
}
|
||||
|
||||
function legacy_to_signal_scale(mixed $value): int
|
||||
{
|
||||
$legacy = max(1, min(10, (int) $value));
|
||||
|
||||
return match (true) {
|
||||
$legacy <= 2 => -2,
|
||||
$legacy <= 4 => -1,
|
||||
$legacy <= 6 => 0,
|
||||
$legacy <= 8 => 1,
|
||||
default => 2,
|
||||
};
|
||||
}
|
||||
|
||||
function day_event_type_options(): array
|
||||
{
|
||||
return [
|
||||
'event' => [
|
||||
'label' => 'Moment',
|
||||
'icon' => '/assets/icons/activity-event.svg',
|
||||
'unit' => '',
|
||||
],
|
||||
'walk' => [
|
||||
'label' => 'Spaziergang',
|
||||
'icon' => sport_icon_path('hike'),
|
||||
'unit' => 'min',
|
||||
],
|
||||
'sport' => [
|
||||
'label' => 'Sport',
|
||||
'icon' => sport_icon_path('run'),
|
||||
'unit' => 'min',
|
||||
],
|
||||
'sleep' => [
|
||||
'label' => 'Schlaf',
|
||||
'icon' => '/assets/icons/activity-sleep.svg',
|
||||
'unit' => 'h',
|
||||
],
|
||||
'alcohol' => [
|
||||
'label' => 'Alkohol',
|
||||
'icon' => icon_path('alcohol'),
|
||||
'unit' => '',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
function day_event_type_label(string $type): string
|
||||
{
|
||||
return day_event_type_options()[$type]['label'] ?? day_event_type_options()['event']['label'];
|
||||
}
|
||||
|
||||
function day_event_type_icon(string $type): string
|
||||
{
|
||||
return day_event_type_options()[$type]['icon'] ?? day_event_type_options()['event']['icon'];
|
||||
}
|
||||
|
||||
function day_event_type_unit(string $type): string
|
||||
{
|
||||
return day_event_type_options()[$type]['unit'] ?? '';
|
||||
}
|
||||
|
||||
function signal_badge_tone(int $value, string $metric): string
|
||||
{
|
||||
$value = normalize_signal_value($value);
|
||||
|
||||
if ($metric === 'stress') {
|
||||
return match (true) {
|
||||
$value <= -1 => 'good',
|
||||
$value === 0 => 'neutral',
|
||||
default => 'warn',
|
||||
};
|
||||
}
|
||||
|
||||
return match (true) {
|
||||
$value <= -1 => 'warn',
|
||||
$value === 0 => 'neutral',
|
||||
default => 'good',
|
||||
};
|
||||
}
|
||||
|
||||
function signal_combo_score(mixed $mood, mixed $energy, mixed $stress): int
|
||||
{
|
||||
return max(-2, min(2, (int) round((
|
||||
normalize_signal_value($mood) +
|
||||
normalize_signal_value($energy) -
|
||||
normalize_signal_value($stress)
|
||||
) / 3)));
|
||||
}
|
||||
|
||||
function day_entry_has_content(array $entry): bool
|
||||
{
|
||||
if (trim((string) ($entry['summary']['comment'] ?? '')) !== '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (trim((string) ($entry['background_image'] ?? '')) !== '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ((int) ($entry['health']['steps'] ?? 0) > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return count(array_filter(is_array($entry['events'] ?? null) ? $entry['events'] : [], 'is_array')) > 0;
|
||||
}
|
||||
|
||||
function signal_value_class(int $value): string
|
||||
{
|
||||
return match (normalize_signal_value($value)) {
|
||||
-2 => 'neg2',
|
||||
-1 => 'neg1',
|
||||
0 => 'zero',
|
||||
1 => 'pos1',
|
||||
2 => 'pos2',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,20 +3,23 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
$brandSubtitle = match ($page) {
|
||||
'dashboard' => 'Statistiken und Verlauf',
|
||||
'dashboard' => '',
|
||||
'track' => 'Tag erfassen und bewerten',
|
||||
'archive' => 'Rückblick auf vergangene Tage',
|
||||
'archive' => '',
|
||||
'options' => 'Logik, Erinnerungen, Sicherheit und Accounts',
|
||||
'login' => 'Geschützter Zugang',
|
||||
'setup' => 'Erstkonfiguration',
|
||||
default => 'Stimmungstracker',
|
||||
};
|
||||
$immersiveDashboard = in_array($page, ['dashboard', 'options'], true);
|
||||
$cssVersion = is_file(base_path('assets/css/app.css')) ? (string) filemtime(base_path('assets/css/app.css')) : '1';
|
||||
$jsVersion = is_file(base_path('assets/js/app.js')) ? (string) filemtime(base_path('assets/js/app.js')) : '1';
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#0b1e2e">
|
||||
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
@@ -27,18 +30,25 @@ $brandSubtitle = match ($page) {
|
||||
<meta name="mood-push-public-key" content="<?= e((string) $pushPublicKey) ?>">
|
||||
<?php endif; ?>
|
||||
<title><?= e($pageTitle) ?> · Mood</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/branding/favicon.svg">
|
||||
<link rel="shortcut icon" href="/assets/branding/favicon.svg">
|
||||
<link rel="apple-touch-icon" href="/assets/branding/apple-touch-icon.svg">
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/branding/favicon.svg?v=20260412">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/assets/branding/favicon-32.png?v=20260412">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/assets/branding/favicon-16.png?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="manifest" href="/manifest.webmanifest">
|
||||
<link rel="stylesheet" href="/assets/css/app.css">
|
||||
<script defer src="/assets/js/app.js"></script>
|
||||
<?php if (($page ?? '') === 'dashboard' && ($dashboardView ?? '') === 'day'): ?>
|
||||
<link rel="prefetch" href="/?view=day&date=<?= e(rawurlencode((string) ($dashboardPrevDate ?? shift_date(today(), -1)))) ?>">
|
||||
<link rel="prefetch" href="/?view=day&date=<?= e(rawurlencode((string) ($dashboardNextDate ?? shift_date(today(), 1)))) ?>">
|
||||
<?php endif; ?>
|
||||
<link rel="stylesheet" href="/assets/css/app.css?v=<?= e($cssVersion) ?>">
|
||||
<script defer src="/assets/js/app.js?v=<?= e($jsVersion) ?>"></script>
|
||||
</head>
|
||||
<body class="app-body page-<?= e($page) ?><?= $authUser !== null ? ' is-authenticated' : '' ?>" data-authenticated="<?= $authUser !== null ? '1' : '0' ?>"<?= isset($trackMood) ? ' data-track-mood="' . e($trackMood) . '"' : '' ?>>
|
||||
<body class="app-body page-<?= e($page) ?><?= $authUser !== null ? ' is-authenticated' : '' ?><?= !empty($pageBodyClass) ? ' ' . e((string) $pageBodyClass) : '' ?>" data-authenticated="<?= $authUser !== null ? '1' : '0' ?>"<?= isset($trackMood) ? ' data-track-mood="' . e($trackMood) . '"' : '' ?><?= isset($dashboardWalkMode) ? ' data-walk-mode="' . e((string) $dashboardWalkMode) . '"' : '' ?>>
|
||||
<div class="aurora aurora-one"></div>
|
||||
<div class="aurora aurora-two"></div>
|
||||
<div class="shell">
|
||||
<?php if ($authUser !== null): ?>
|
||||
<div class="pull-refresh-indicator glass-panel" data-pull-refresh-indicator aria-hidden="true">Zum Aktualisieren ziehen</div>
|
||||
<div class="shell<?= $immersiveDashboard ? ' shell--dashboard' : '' ?>">
|
||||
<?php if ($authUser !== null && !$immersiveDashboard): ?>
|
||||
<aside class="sidebar glass-panel">
|
||||
<div class="brand-block">
|
||||
<div class="brand-mark">
|
||||
@@ -53,11 +63,7 @@ $brandSubtitle = match ($page) {
|
||||
<nav class="main-nav" aria-label="Hauptnavigation">
|
||||
<a class="<?= is_active_path('/') ? 'active' : '' ?>" href="/">
|
||||
<img class="nav-icon" src="<?= e(icon_path('dashboard')) ?>" alt="">
|
||||
<span>Dashboard</span>
|
||||
</a>
|
||||
<a class="<?= is_active_path('/track') ? 'active' : '' ?>" href="/track">
|
||||
<img class="nav-icon" src="<?= e(icon_path('track')) ?>" alt="">
|
||||
<span>Tracken</span>
|
||||
<span>Start</span>
|
||||
</a>
|
||||
<a class="<?= is_active_path('/archive') ? 'active' : '' ?>" href="/archive">
|
||||
<img class="nav-icon" src="<?= e(icon_path('archive')) ?>" alt="">
|
||||
@@ -83,10 +89,12 @@ $brandSubtitle = match ($page) {
|
||||
<?php endif; ?>
|
||||
|
||||
<main class="content">
|
||||
<?php if ($authUser !== null): ?>
|
||||
<?php if ($authUser !== null && !$immersiveDashboard): ?>
|
||||
<header class="topbar glass-panel">
|
||||
<div>
|
||||
<p class="eyebrow"><?= e($brandSubtitle) ?></p>
|
||||
<?php if ($brandSubtitle !== ''): ?>
|
||||
<p class="eyebrow"><?= e($brandSubtitle) ?></p>
|
||||
<?php endif; ?>
|
||||
<h2><?= e($pageTitle) ?></h2>
|
||||
</div>
|
||||
<div class="topbar__meta">
|
||||
@@ -109,24 +117,30 @@ $brandSubtitle = match ($page) {
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?= $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): ?>
|
||||
<nav class="mobile-nav glass-panel" aria-label="Mobile Hauptnavigation">
|
||||
<a class="<?= is_active_path('/') ? 'active' : '' ?>" href="/">
|
||||
<img class="nav-icon" src="<?= e(icon_path('dashboard')) ?>" alt="">
|
||||
<span>Dashboard</span>
|
||||
<nav class="ios-tabbar" aria-label="Mobile Navigation">
|
||||
<a class="<?= $page === 'dashboard' && ($dashboardView ?? 'day') === 'day' ? 'active' : '' ?>" href="/?view=day&date=<?= e(rawurlencode(today())) ?>">
|
||||
<span class="ios-tabbar__icon" aria-hidden="true"></span>
|
||||
<span>Heute</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 class="<?= $page === 'dashboard' && ($dashboardView ?? '') === 'week' ? 'active' : '' ?>" href="/?view=week&date=<?= e(rawurlencode(today())) ?>">
|
||||
<span class="ios-tabbar__icon" aria-hidden="true"></span>
|
||||
<span>Woche</span>
|
||||
</a>
|
||||
<a class="<?= is_active_path('/archive') ? 'active' : '' ?>" href="/archive">
|
||||
<img class="nav-icon" src="<?= e(icon_path('archive')) ?>" alt="">
|
||||
<span>Archiv</span>
|
||||
<a class="<?= $page === 'dashboard' && ($dashboardView ?? '') === 'month' ? 'active' : '' ?>" href="/?view=month&date=<?= e(rawurlencode(today())) ?>">
|
||||
<span class="ios-tabbar__icon" aria-hidden="true"></span>
|
||||
<span>Monat</span>
|
||||
</a>
|
||||
<a class="<?= is_active_path('/options') ? 'active' : '' ?>" href="/options">
|
||||
<img class="nav-icon" src="<?= e(icon_path('options')) ?>" alt="">
|
||||
<a class="<?= $page === 'options' ? 'active' : '' ?>" href="/options">
|
||||
<span class="ios-tabbar__icon" aria-hidden="true"></span>
|
||||
<span>Optionen</span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
@@ -1,94 +1,298 @@
|
||||
<section class="page-grid">
|
||||
<article class="glass-panel archive-list">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<p class="eyebrow">Archiv</p>
|
||||
<h3>Alle gespeicherten Tage</h3>
|
||||
</div>
|
||||
<span class="chart-chip"><?= e((string) count($entries)) ?> Einträge</span>
|
||||
<?php
|
||||
$baseParams = ['view' => $archiveView];
|
||||
if ($archiveFilterMonth !== '') {
|
||||
$baseParams['filter_month'] = $archiveFilterMonth;
|
||||
}
|
||||
|
||||
$archiveUrl = static function (array $params = []) use ($baseParams): string {
|
||||
$query = array_filter(array_merge($baseParams, $params), static fn (mixed $value): bool => $value !== null && $value !== '');
|
||||
|
||||
return $query === [] ? '/archive' : '/archive?' . http_build_query($query);
|
||||
};
|
||||
|
||||
$detailType = $selectedEntry !== null
|
||||
? 'day'
|
||||
: ($selectedWeek !== null
|
||||
? 'week'
|
||||
: ($selectedMonth !== null ? 'month' : null));
|
||||
|
||||
$detailOpen = $detailType !== null;
|
||||
?>
|
||||
|
||||
<section class="archive-page">
|
||||
<article class="glass-panel archive-shell">
|
||||
<div class="archive-toolbar archive-toolbar--compact">
|
||||
<nav class="archive-switcher" aria-label="Archivansicht">
|
||||
<a class="archive-switcher__item <?= $archiveView === 'days' ? 'active' : '' ?>" href="<?= e('/archive?view=days' . ($archiveFilterMonth !== '' ? '&filter_month=' . rawurlencode($archiveFilterMonth) : '')) ?>">Tage</a>
|
||||
<a class="archive-switcher__item <?= $archiveView === 'weeks' ? 'active' : '' ?>" href="<?= e('/archive?view=weeks' . ($archiveFilterMonth !== '' ? '&filter_month=' . rawurlencode($archiveFilterMonth) : '')) ?>">Wochen</a>
|
||||
<a class="archive-switcher__item <?= $archiveView === 'months' ? 'active' : '' ?>" href="<?= e('/archive?view=months' . ($archiveFilterMonth !== '' ? '&filter_month=' . rawurlencode($archiveFilterMonth) : '')) ?>">Monate</a>
|
||||
</nav>
|
||||
|
||||
<form method="get" action="/archive" class="archive-filter">
|
||||
<input type="hidden" name="view" value="<?= e($archiveView) ?>">
|
||||
<label>
|
||||
<span>Zeitraum</span>
|
||||
<select name="filter_month" onchange="this.form.submit()">
|
||||
<option value="">Alle Monate</option>
|
||||
<?php foreach ($archiveMonthOptions as $monthOption): ?>
|
||||
<option value="<?= e($monthOption) ?>" <?= $archiveFilterMonth === $monthOption ? 'selected' : '' ?>><?= e(month_label($monthOption)) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<?php if ($entries === []): ?>
|
||||
<p class="empty-state">Noch keine Einträge vorhanden. Auf der Tracking-Seite kannst du den ersten Tag anlegen.</p>
|
||||
<?php else: ?>
|
||||
<div class="archive-items">
|
||||
<?php foreach ($entries as $entry): ?>
|
||||
<article class="archive-item <?= $selectedEntry !== null && $selectedEntry['date'] === $entry['date'] ? 'active' : '' ?>">
|
||||
<div class="archive-workspace">
|
||||
<section class="archive-main">
|
||||
<?php if ($archiveView === 'days'): ?>
|
||||
<div class="archive-list-header">
|
||||
<div>
|
||||
<strong><?= e(format_display_date($entry['date'], false)) ?></strong>
|
||||
<span><?= e($entry['evaluation']['label']) ?></span>
|
||||
<?php if ((int) $entry['sport_minutes'] > 0 && !empty($entry['sport_type_meta'])): ?>
|
||||
<span class="sport-pill-group">
|
||||
<?php foreach ($entry['sport_type_meta'] as $sportType): ?>
|
||||
<span class="sport-pill">
|
||||
<img src="<?= e(sport_icon_path($sportType['icon'])) ?>" alt="">
|
||||
<span><?= e($sportType['label']) ?><?= !empty($sportType['location']) ? ' · ' . e(sport_location_label((string) $sportType['location'])) : '' ?></span>
|
||||
</span>
|
||||
<?php endforeach; ?>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
<p class="eyebrow">Tage</p>
|
||||
<h4>Gespeicherte Tage</h4>
|
||||
</div>
|
||||
<div class="archive-item__meta">
|
||||
<span><?= e(format_points((float) $entry['evaluation']['total'])) ?></span>
|
||||
<span>Stimmung <?= e((string) $entry['mood']) ?>/10</span>
|
||||
</div>
|
||||
<div class="archive-item__actions">
|
||||
<a class="ghost-link archive-action" href="/archive?date=<?= e(rawurlencode($entry['date'])) ?>">Ansehen</a>
|
||||
<a class="ghost-link archive-action" href="/track?date=<?= e(rawurlencode($entry['date'])) ?>">Bearbeiten</a>
|
||||
</div>
|
||||
</article>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</article>
|
||||
|
||||
<aside class="stack-column">
|
||||
<?php if ($selectedEntry !== null): ?>
|
||||
<article class="glass-panel detail-card">
|
||||
<p class="eyebrow">Ausgewählt</p>
|
||||
<h3><?= e(format_display_date($selectedEntry['date'])) ?></h3>
|
||||
<p class="hero-label"><?= e($selectedEntry['evaluation']['label']) ?> · <?= e(format_points((float) $selectedEntry['evaluation']['total'])) ?> Punkte</p>
|
||||
<a class="primary-button button-link" href="/track?date=<?= e(rawurlencode($selectedEntry['date'])) ?>">Diesen Tag bearbeiten</a>
|
||||
|
||||
<dl class="detail-grid">
|
||||
<div><dt>Stimmung</dt><dd><?= e((string) $selectedEntry['mood']) ?>/10</dd></div>
|
||||
<div><dt>Energie</dt><dd><?= e((string) $selectedEntry['energy']) ?>/10</dd></div>
|
||||
<div><dt>Stress</dt><dd><?= e((string) $selectedEntry['stress']) ?>/10</dd></div>
|
||||
<div><dt>Schlaf</dt><dd><?= e((string) $selectedEntry['sleep_hours']) ?> h</dd></div>
|
||||
<div><dt>Schlafgefühl</dt><dd><?= e((string) $selectedEntry['sleep_feeling']) ?>/5</dd></div>
|
||||
<div><dt>Sport</dt><dd><?= e((string) $selectedEntry['sport_minutes']) ?> min</dd></div>
|
||||
<div>
|
||||
<dt>Sportarten</dt>
|
||||
<dd>
|
||||
<?php if ((int) $selectedEntry['sport_minutes'] > 0 && !empty($selectedEntry['sport_type_meta'])): ?>
|
||||
<span class="sport-pill-group sport-pill-group--inline">
|
||||
<?php foreach ($selectedEntry['sport_type_meta'] as $sportType): ?>
|
||||
<span class="sport-pill sport-pill--inline">
|
||||
<img src="<?= e(sport_icon_path($sportType['icon'])) ?>" alt="">
|
||||
<span><?= e($sportType['label']) ?><?= !empty($sportType['location']) ? ' · ' . e(sport_location_label((string) $sportType['location'])) : '' ?></span>
|
||||
</span>
|
||||
<?php endforeach; ?>
|
||||
</span>
|
||||
<?php else: ?>
|
||||
keine
|
||||
<?php endif; ?>
|
||||
</dd>
|
||||
<span class="chart-chip"><?= e((string) count($entries)) ?> Tage</span>
|
||||
</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>
|
||||
</dl>
|
||||
|
||||
<div class="note-box">
|
||||
<h4>Notiz</h4>
|
||||
<p><?= nl2br(e($selectedEntry['note'] !== '' ? $selectedEntry['note'] : 'Keine Notiz hinterlegt.')) ?></p>
|
||||
<?php if ($entries === []): ?>
|
||||
<p class="empty-state">Für diesen Zeitraum gibt es noch keine getrackten Tage.</p>
|
||||
<?php else: ?>
|
||||
<div class="archive-rows">
|
||||
<?php foreach ($entries as $entry): ?>
|
||||
<a class="archive-row archive-row--day <?= $selectedEntry !== null && $selectedEntry['date'] === $entry['date'] ? 'active' : '' ?>" href="<?= e($archiveUrl(['date' => $entry['date'], 'week' => null, 'month_key' => null])) ?>">
|
||||
<div class="archive-row__main">
|
||||
<strong><?= e(format_compact_date($entry['date'])) ?></strong>
|
||||
<span><?= e($entry['evaluation']['label']) ?></span>
|
||||
</div>
|
||||
<div class="archive-row__meta">
|
||||
<span><?= e(format_points((float) $entry['evaluation']['total'])) ?> Punkte</span>
|
||||
<span>Stimmung <?= e((string) $entry['mood']) ?>/10</span>
|
||||
</div>
|
||||
<span class="archive-row__hint">Ansehen</span>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php elseif ($archiveView === 'weeks'): ?>
|
||||
<div class="archive-list-header">
|
||||
<div>
|
||||
<p class="eyebrow">Wochen</p>
|
||||
<h4>Wöchentliche KI-Rückblicke</h4>
|
||||
</div>
|
||||
<span class="chart-chip"><?= e((string) count($weeklyArchive)) ?> Wochen</span>
|
||||
</div>
|
||||
|
||||
<?php if ($weeklyArchive === []): ?>
|
||||
<p class="empty-state">Für diesen Zeitraum sind noch keine Wochen im Archiv vorhanden.</p>
|
||||
<?php else: ?>
|
||||
<div class="archive-rows archive-rows--summary">
|
||||
<?php foreach ($weeklyArchive as $week): ?>
|
||||
<a class="archive-row archive-row--summary archive-row--week <?= $selectedWeek !== null && $selectedWeek['summary_key'] === $week['summary_key'] ? 'active' : '' ?>" href="<?= e($archiveUrl(['week' => $week['summary_key'], 'date' => null, 'month_key' => null])) ?>">
|
||||
<div class="archive-row__main archive-row__main--week">
|
||||
<div class="archive-row__title-group">
|
||||
<strong><?= e($week['label']) ?></strong>
|
||||
<span><?= e(format_compact_date((string) $week['date_from'])) ?> bis <?= e(format_compact_date((string) $week['date_to'])) ?></span>
|
||||
</div>
|
||||
<span class="status-badge status-badge--<?= e($week['status_tone']) ?>"><?= e($week['status_label']) ?></span>
|
||||
</div>
|
||||
<div class="archive-row__meta archive-row__meta--stack">
|
||||
<span><?= e((string) $week['note_entries_count']) ?> Texteinträge</span>
|
||||
<span><?= e((string) $week['tracked_days']) ?> getrackte Tage</span>
|
||||
<span><?= e($week['trend_label']) ?></span>
|
||||
</div>
|
||||
<span class="archive-row__hint"><?= !empty($week['has_summary']) ? 'Öffnen' : 'Details' ?></span>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php else: ?>
|
||||
<div class="archive-list-header">
|
||||
<div>
|
||||
<p class="eyebrow">Monate</p>
|
||||
<h4>Monatliche KI-Rückblicke</h4>
|
||||
</div>
|
||||
<span class="chart-chip"><?= e((string) count($monthlyArchive)) ?> Monate</span>
|
||||
</div>
|
||||
|
||||
<?php if ($monthlyArchive === []): ?>
|
||||
<p class="empty-state">Für diesen Zeitraum sind noch keine Monatsobjekte im Archiv vorhanden.</p>
|
||||
<?php else: ?>
|
||||
<div class="archive-rows archive-rows--summary">
|
||||
<?php foreach ($monthlyArchive as $month): ?>
|
||||
<a class="archive-row archive-row--summary archive-row--month <?= $selectedMonth !== null && $selectedMonth['summary_key'] === $month['summary_key'] ? 'active' : '' ?>" href="<?= e($archiveUrl(['month_key' => $month['summary_key'], 'date' => null, 'week' => null])) ?>">
|
||||
<div class="archive-row__main archive-row__main--month">
|
||||
<div class="archive-row__title-group">
|
||||
<strong><?= e($month['label']) ?></strong>
|
||||
<span><?= e(format_compact_date((string) $month['date_from'])) ?> bis <?= e(format_compact_date((string) $month['date_to'])) ?></span>
|
||||
</div>
|
||||
<span class="status-badge status-badge--<?= e($month['status_tone']) ?>"><?= e($month['status_label']) ?></span>
|
||||
</div>
|
||||
<div class="archive-row__meta archive-row__meta--stack">
|
||||
<span><?= e($month['weekly_progress_label']) ?></span>
|
||||
<span><?= e((string) $month['tracked_days']) ?> getrackte Tage</span>
|
||||
</div>
|
||||
<span class="archive-row__hint"><?= !empty($month['has_summary']) ? 'Öffnen' : 'Details' ?></span>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</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>
|
||||
<a class="primary-button button-link" href="/track?date=<?= e(rawurlencode($selectedEntry['date'])) ?>">Diesen Tag bearbeiten</a>
|
||||
|
||||
<dl class="detail-grid detail-grid--archive-day">
|
||||
<div><dt>Stimmung</dt><dd><?= e((string) $selectedEntry['mood']) ?>/10</dd></div>
|
||||
<div><dt>Energie</dt><dd><?= e((string) $selectedEntry['energy']) ?>/10</dd></div>
|
||||
<div><dt>Stress</dt><dd><?= e((string) $selectedEntry['stress']) ?>/10</dd></div>
|
||||
<?php if (!empty($settings['tracking']['pain_enabled'])): ?>
|
||||
<div><dt>Schmerzen</dt><dd><?= e((string) $selectedEntry['pain']) ?>/10</dd></div>
|
||||
<?php endif; ?>
|
||||
<div><dt>Schlaf</dt><dd><?= e((string) $selectedEntry['sleep_hours']) ?> h</dd></div>
|
||||
<div><dt>Schlafgefühl</dt><dd><?= e((string) $selectedEntry['sleep_feeling']) ?>/5</dd></div>
|
||||
<div><dt>Sport</dt><dd><?= e((string) $selectedEntry['sport_minutes']) ?> min</dd></div>
|
||||
<div><dt>Spaziergang</dt><dd><?= e(format_walk_value($selectedEntry)) ?></dd></div>
|
||||
<div><dt>Alkohol</dt><dd><?= !empty($selectedEntry['alcohol']) ? 'ja' : 'nein' ?></dd></div>
|
||||
</dl>
|
||||
|
||||
<div class="note-box">
|
||||
<h4>Notiz</h4>
|
||||
<p><?= nl2br(e($selectedEntry['note'] !== '' ? $selectedEntry['note'] : 'Keine Notiz hinterlegt.')) ?></p>
|
||||
</div>
|
||||
<?php elseif ($detailType === 'week'): ?>
|
||||
<p class="hero-label"><?= e(format_compact_date((string) $selectedWeek['date_from'])) ?> bis <?= e(format_compact_date((string) $selectedWeek['date_to'])) ?></p>
|
||||
|
||||
<div class="archive-detail__status-row">
|
||||
<span class="status-badge status-badge--<?= e($selectedWeek['status_tone']) ?>"><?= e($selectedWeek['status_label']) ?></span>
|
||||
<span class="chart-chip"><?= e($selectedWeek['trend_label']) ?></span>
|
||||
</div>
|
||||
|
||||
<dl class="detail-grid detail-grid--archive">
|
||||
<div><dt>Texteinträge</dt><dd><?= e((string) $selectedWeek['note_entries_count']) ?></dd></div>
|
||||
<div><dt>Getrackte Tage</dt><dd><?= e((string) $selectedWeek['tracked_days']) ?></dd></div>
|
||||
<?php if (!empty($selectedWeek['summary'])): ?>
|
||||
<div><dt>Erstellt am</dt><dd><?= e(format_compact_datetime((string) $selectedWeek['summary']['created_at'])) ?></dd></div>
|
||||
<?php endif; ?>
|
||||
</dl>
|
||||
|
||||
<div class="note-box archive-detail__status-note">
|
||||
<h4>KI-Status</h4>
|
||||
<p><?= e($selectedWeek['status_hint']) ?></p>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($selectedWeek['summary'])): ?>
|
||||
<div class="archive-detail__actions">
|
||||
<a class="ghost-link archive-action" href="<?= e($archiveUrl(['week' => $selectedWeek['summary_key'], 'date' => null, 'month_key' => null])) ?>">Öffnen</a>
|
||||
<form method="post" action="/archive">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="form_name" value="generate_weekly_summary">
|
||||
<input type="hidden" name="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>
|
||||
</article>
|
||||
<?php else: ?>
|
||||
<article class="glass-panel detail-card">
|
||||
<p class="eyebrow">Details</p>
|
||||
<h3>Archivansicht</h3>
|
||||
<p>Wähle links einen Tag aus, um alle Werte anzuzeigen oder direkt in die Bearbeitung zu springen.</p>
|
||||
</article>
|
||||
<?php endif; ?>
|
||||
</aside>
|
||||
</aside>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
@@ -1,86 +1,712 @@
|
||||
<section class="hero-grid">
|
||||
<article class="hero-card hero-card--wide glass-panel">
|
||||
<p class="eyebrow">Stimmung im Blick</p>
|
||||
<h3>Dein Dashboard verbindet Verlauf, Score und Bewegungsdaten in einer schnellen Übersicht.</h3>
|
||||
<p class="hero-copy">Die Bewertung basiert auf deiner konfigurierbaren Logik. Sobald du die Regeln in den Optionen änderst, spiegeln Archiv und Statistiken die neue Gewichtung wider.</p>
|
||||
</article>
|
||||
<?php
|
||||
$dayDateLabel = format_display_date((string) $dayEntry['date']);
|
||||
$dayWeekday = strtok($dayDateLabel, ',');
|
||||
$dayBackground = is_string($dayEntry['background_image_url'] ?? null) ? (string) $dayEntry['background_image_url'] : null;
|
||||
$summaryComment = trim((string) ($dayEntry['summary']['comment'] ?? $dayEntry['summary_comment'] ?? ''));
|
||||
if (preg_match('/^\s*-?\s*(?:Stimmung|Energie|Stress)\s*:\s*0\s*$/iu', $summaryComment) === 1) {
|
||||
$summaryComment = '';
|
||||
}
|
||||
$summaryMood = normalize_signal_value($dayEntry['summary']['mood'] ?? $dayEntry['summary_mood'] ?? 0);
|
||||
$summaryEnergy = normalize_signal_value($dayEntry['summary']['energy'] ?? $dayEntry['summary_energy'] ?? 0);
|
||||
$summaryStress = normalize_signal_value($dayEntry['summary']['stress'] ?? $dayEntry['summary_stress'] ?? 0);
|
||||
$summaryAlcohol = !empty($dayEntry['summary']['alcohol'] ?? $dayEntry['summary_alcohol'] ?? false);
|
||||
$dayHealth = is_array($dayEntry['health'] ?? null) ? $dayEntry['health'] : [];
|
||||
$daySteps = (int) ($dayHealth['steps'] ?? 0);
|
||||
$dayStepBonus = (float) ($dayEntry['evaluation']['components']['step_bonus'] ?? 0);
|
||||
$optimalSleepHours = max(1.0, min(16.0, (float) ($settings['sleep']['optimal_hours'] ?? 7.0)));
|
||||
$formatBalanceValue = static function (?array $entry) use ($settings): string {
|
||||
if ($entry === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$mode = (string) ($settings['display']['score_mode'] ?? 'scale');
|
||||
$balance = is_array($entry['evaluation']['balance'] ?? null) ? $entry['evaluation']['balance'] : [];
|
||||
if ($mode === 'points') {
|
||||
return format_points((float) ($entry['evaluation']['total'] ?? 0)) . ' Punkte';
|
||||
}
|
||||
|
||||
if ($mode === 'percent') {
|
||||
return format_points((float) ($balance['percentage'] ?? $entry['evaluation']['percentage'] ?? 0)) . ' %';
|
||||
}
|
||||
|
||||
$level = max(-2, min(2, (int) ($balance['level'] ?? 0)));
|
||||
return ($level > 0 ? '+' : '') . (string) $level;
|
||||
};
|
||||
?>
|
||||
|
||||
<section class="dashboard-shell<?= $dayBackground !== null ? ' dashboard-shell--with-image' : '' ?>" data-dashboard-root>
|
||||
<?php if ($dayBackground !== null): ?>
|
||||
<div class="dashboard-shell__background" aria-hidden="true">
|
||||
<img src="<?= e($dayBackground) ?>" alt="">
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<header class="dashboard-topbar">
|
||||
<nav class="dashboard-switcher glass-panel" aria-label="Ansicht wechseln">
|
||||
<a class="<?= $dashboardView === 'day' && $dashboardDate === today() ? 'active' : '' ?>" href="/?view=day&date=<?= e(rawurlencode(today())) ?>">Heute</a>
|
||||
<a class="<?= $dashboardView === 'week' ? 'active' : '' ?>" href="/?view=week&date=<?= e(rawurlencode(today())) ?>">Woche</a>
|
||||
<a class="<?= $dashboardView === 'month' ? 'active' : '' ?>" href="/?view=month&date=<?= e(rawurlencode(today())) ?>">Monat</a>
|
||||
</nav>
|
||||
|
||||
<button class="dashboard-settings glass-panel" type="button" data-settings-menu-open aria-label="Optionen öffnen">
|
||||
<img src="<?= e(icon_path('options')) ?>" alt="">
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<?php if ($dashboardView === 'day'): ?>
|
||||
<div class="dashboard-day" data-day-swipe data-prev-date="<?= e($dashboardPrevDate) ?>" data-next-date="<?= e($dashboardNextDate) ?>">
|
||||
<div class="dashboard-day-slider" data-day-slider-shell>
|
||||
<span class="day-slide-hint day-slide-hint--prev" data-day-slide-prev-hint><span class="day-slide-hint__arrow" aria-hidden="true"></span>Vorherigen Tag laden</span>
|
||||
<span class="day-slide-hint day-slide-hint--next" data-day-slide-next-hint>Nächster Tag laden<span class="day-slide-hint__arrow" aria-hidden="true"></span></span>
|
||||
<div class="dashboard-day__hero" data-day-slider>
|
||||
<p class="dashboard-day__eyebrow"><?= e((string) $dayWeekday) ?></p>
|
||||
<h1><?= e(format_display_date((string) $dayEntry['date'], false)) ?></h1>
|
||||
|
||||
<nav class="dashboard-compare-strip" aria-label="Tagesvergleich" data-day-strip>
|
||||
<span class="score-scale score-scale--day" aria-hidden="true"><span>+2</span><span>+1</span><span>0</span><span>-1</span><span>-2</span></span>
|
||||
<?php foreach ($dashboardCompareDays as $compareDay): ?>
|
||||
<a class="compare-day offset-<?= e((string) ($compareDay['offset'] ?? 0)) ?><?= !empty($compareDay['is_current']) ? ' is-current' : '' ?><?= empty($compareDay['has_content']) ? ' is-empty' : '' ?>" href="/?view=day&date=<?= e(rawurlencode((string) $compareDay['date'])) ?>">
|
||||
<span class="compare-day__line<?= empty($compareDay['has_content']) ? ' is-empty' : '' ?><?= !empty($compareDay['is_current']) ? ' is-primary' : '' ?> score-<?= e((string) ($compareDay['score_level'] ?? 'empty')) ?> compare-tone-<?= e((string) ($compareDay['line_tone'] ?? 'empty')) ?>">
|
||||
<span class="compare-day__marker"></span>
|
||||
</span>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="day-summary-card glass-panel<?= $dashboardHasContent ? ' is-filled' : '' ?>" type="button" data-summary-overlay-open>
|
||||
<span class="day-summary-card__label">Tagesbilanz</span>
|
||||
<strong class="day-summary-card__title"><?= $summaryComment !== '' ? e($summaryComment) : 'Tagesbilanz' ?></strong>
|
||||
<?php if ($daySteps > 0): ?>
|
||||
<span class="day-summary-card__chips">
|
||||
<span class="day-chip day-chip--score">Bilanz <?= e($formatBalanceValue($dayEntry)) ?></span>
|
||||
<span class="day-chip"><?= e(number_format($daySteps, 0, ',', '.')) ?> Schritte</span>
|
||||
<?php if ($dayStepBonus > 0): ?>
|
||||
<span class="day-chip day-chip--bonus">+<?= e(format_points($dayStepBonus)) ?> Punkt</span>
|
||||
<?php endif; ?>
|
||||
</span>
|
||||
<?php else: ?>
|
||||
<span class="day-summary-card__chips"><span class="day-chip day-chip--score">Bilanz <?= e($formatBalanceValue($dayEntry)) ?></span></span>
|
||||
<?php endif; ?>
|
||||
</button>
|
||||
|
||||
<section class="dashboard-moments-block">
|
||||
<div class="section-head section-head--compact section-head--dashboard">
|
||||
<div>
|
||||
<p class="eyebrow">Deine Momente</p>
|
||||
<h2>Momente des Tages</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="timeline-list">
|
||||
<?php if ($dashboardTimeline === []): ?>
|
||||
<article class="timeline-card timeline-card--empty glass-panel">
|
||||
<div class="timeline-card__body">
|
||||
<h3>Noch keine Momente</h3>
|
||||
<p>Du kannst auch vergangene Tage jederzeit nachtragen.</p>
|
||||
</div>
|
||||
</article>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php foreach ($dashboardTimeline as $item): ?>
|
||||
<?php $eventType = (string) ($item['type'] ?? 'event'); ?>
|
||||
<?php $eventComment = trim((string) ($item['comment'] ?? '')); ?>
|
||||
<?php 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>
|
||||
|
||||
<button class="dashboard-fab" type="button" data-moment-overlay-open aria-label="Neuen Moment hinzufügen">+</button>
|
||||
<div class="dashboard-fab-menu glass-panel" data-fab-menu hidden>
|
||||
<?php foreach ($dashboardEventTypes as $type => $meta): ?>
|
||||
<?php if ($type === 'alcohol') { continue; } ?>
|
||||
<button type="button" data-fab-moment-choice="<?= e($type) ?>">
|
||||
<img src="<?= e((string) $meta['icon']) ?>" alt="">
|
||||
<span><?= e((string) $meta['label']) ?></span>
|
||||
</button>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-overlay" data-summary-overlay hidden>
|
||||
<div class="dashboard-overlay__backdrop" data-summary-overlay-close></div>
|
||||
<section class="dashboard-modal glass-panel dashboard-modal--summary" role="dialog" aria-modal="true">
|
||||
<div class="dashboard-modal__controls">
|
||||
<button class="dashboard-modal__round" type="button" data-summary-overlay-close>×</button>
|
||||
<button class="dashboard-modal__round dashboard-modal__round--confirm" type="submit" form="day-summary-form">✓</button>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/" enctype="multipart/form-data" class="dashboard-modal__form" id="day-summary-form">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="form_name" value="save_day_summary">
|
||||
<input type="hidden" name="date" value="<?= e((string) $dayEntry['date']) ?>">
|
||||
|
||||
<h2 class="dashboard-modal__title"><?= e(format_display_date((string) $dayEntry['date'], false)) ?></h2>
|
||||
<p class="dashboard-modal__subtitle">Deine Tagesbilanz</p>
|
||||
|
||||
<label class="dashboard-modal__textarea">
|
||||
<textarea name="summary_comment" rows="5" placeholder="Fasse deinen Tag zusammen"><?= e($summaryComment) ?></textarea>
|
||||
</label>
|
||||
|
||||
<div class="overlay-signal-grid overlay-signal-grid--summary-row">
|
||||
<?php foreach (['summary_mood' => ['Stimmung', $summaryMood], 'summary_energy' => ['Energie', $summaryEnergy], 'summary_stress' => ['Stress', $summaryStress]] as $field => [$label, $value]): ?>
|
||||
<?php $metric = str_replace('summary_', '', $field); ?>
|
||||
<div class="overlay-signal-card overlay-signal-card--inline" data-stepper data-stepper-metric="<?= e($metric) ?>">
|
||||
<div>
|
||||
<h3><?= e($label) ?></h3>
|
||||
<p data-stepper-label>
|
||||
<?= e(signal_labels_for_metric($metric)[$value]) ?>
|
||||
</p>
|
||||
</div>
|
||||
<div class="overlay-signal-card__control">
|
||||
<div class="overlay-signal-card__ring tone-<?= e(signal_value_class($value)) ?>">
|
||||
<span data-stepper-value><?= $value >= 0 ? '+' : '' ?><?= e((string) $value) ?></span>
|
||||
</div>
|
||||
<div class="overlay-signal-card__buttons">
|
||||
<button type="button" data-stepper-minus>-</button>
|
||||
<button type="button" data-stepper-plus>+</button>
|
||||
</div>
|
||||
<div class="signal-scale" aria-hidden="true"><span>-2</span><span>-1</span><span>0</span><span>+1</span><span>+2</span></div>
|
||||
<input type="hidden" name="<?= e($field) ?>" value="<?= e((string) $value) ?>" data-stepper-input>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<fieldset class="moment-alcohol-field moment-alcohol-field--summary">
|
||||
<legend>Alkohol</legend>
|
||||
<div class="moment-choice-row">
|
||||
<label class="moment-choice-pill">
|
||||
<input type="radio" name="summary_alcohol" value="1" <?= $summaryAlcohol ? 'checked' : '' ?>>
|
||||
<span>Ja</span>
|
||||
</label>
|
||||
<label class="moment-choice-pill">
|
||||
<input type="radio" name="summary_alcohol" value="0" <?= !$summaryAlcohol ? 'checked' : '' ?>>
|
||||
<span>Nein</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<label>
|
||||
<span>Tagesbild</span>
|
||||
<input type="file" name="background_image" accept="image/jpeg,image/png,image/webp">
|
||||
</label>
|
||||
</form>
|
||||
|
||||
<?php if ($dayBackground !== null): ?>
|
||||
<form method="post" action="/" class="dashboard-modal__secondary-action">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="form_name" value="remove_background">
|
||||
<input type="hidden" name="date" value="<?= e((string) $dayEntry['date']) ?>">
|
||||
<button class="ghost-button" type="submit">Bild entfernen</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-overlay" data-moment-overlay hidden>
|
||||
<div class="dashboard-overlay__backdrop" data-moment-overlay-close></div>
|
||||
<section class="dashboard-modal glass-panel dashboard-modal--moment" role="dialog" aria-modal="true" data-moment-modal>
|
||||
<div class="dashboard-modal__controls">
|
||||
<button class="dashboard-modal__round" type="button" data-moment-overlay-close>×</button>
|
||||
<button class="dashboard-modal__round dashboard-modal__round--confirm" type="submit" form="moment-form" data-moment-submit disabled>✓</button>
|
||||
</div>
|
||||
|
||||
<div data-moment-step="choose">
|
||||
<h2 class="dashboard-modal__title">Neuer Moment</h2>
|
||||
<div class="moment-type-grid">
|
||||
<?php foreach ($dashboardEventTypes as $type => $meta): ?>
|
||||
<?php if ($type === 'alcohol') { continue; } ?>
|
||||
<button class="moment-type-card" type="button" data-moment-type-choice="<?= e($type) ?>">
|
||||
<img src="<?= e((string) $meta['icon']) ?>" alt="">
|
||||
<span><?= e((string) $meta['label']) ?></span>
|
||||
</button>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/" enctype="multipart/form-data" class="dashboard-modal__form" id="moment-form" data-moment-step="form" hidden>
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="form_name" value="add_event" data-moment-form-name>
|
||||
<input type="hidden" name="date" value="<?= e((string) $dayEntry['date']) ?>">
|
||||
<input type="hidden" name="event_id" value="" data-moment-event-id>
|
||||
<input type="hidden" name="event_type" value="event" data-moment-type-input>
|
||||
<input type="hidden" name="event_unit" value="" data-event-unit>
|
||||
<input type="hidden" name="event_walk_mode" value="time" data-walk-mode-input>
|
||||
|
||||
<div class="dashboard-modal__heading-row">
|
||||
<div>
|
||||
<p class="dashboard-modal__subtitle" data-moment-type-label>Neuer Moment</p>
|
||||
<h2 class="dashboard-modal__title">Was ist passiert?</h2>
|
||||
</div>
|
||||
<button class="ghost-button ghost-button--small" type="button" data-moment-back>Typ ändern</button>
|
||||
</div>
|
||||
|
||||
<label class="dashboard-modal__textarea">
|
||||
<textarea name="event_comment" rows="4" placeholder="Was hast du erlebt?" data-moment-comment></textarea>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Momentbild</span>
|
||||
<input type="file" name="event_image" accept="image/jpeg,image/png,image/webp">
|
||||
</label>
|
||||
|
||||
<div class="field-grid field-grid--two">
|
||||
<label>
|
||||
<span>Erfasst um</span>
|
||||
<input type="time" name="event_time" value="<?= e(date('H:i')) ?>" required>
|
||||
</label>
|
||||
<label data-moment-value-field>
|
||||
<span data-moment-value-label>Wert</span>
|
||||
<input type="number" name="event_value" min="0" max="50000" step="0.01" placeholder="optional" data-moment-value-input>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<fieldset data-moment-sport-field hidden>
|
||||
<legend>Sportart</legend>
|
||||
<input type="hidden" name="event_sport_type_id" value="">
|
||||
<div class="moment-type-grid moment-type-grid--sport">
|
||||
<?php foreach ($dashboardSportTypes as $sportType): ?>
|
||||
<button class="moment-type-card moment-type-card--sport" type="button" data-sport-choice="<?= e((string) ($sportType['id'] ?? '')) ?>">
|
||||
<img src="<?= e(sport_icon_path((string) ($sportType['icon'] ?? 'run'))) ?>" alt="">
|
||||
<span><?= e((string) ($sportType['label'] ?? '')) ?></span>
|
||||
</button>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="moment-alcohol-field" data-moment-walk-field hidden>
|
||||
<legend>Spaziergang als</legend>
|
||||
<div class="moment-choice-row">
|
||||
<label class="moment-choice-pill"><input type="radio" name="event_walk_mode" value="time" checked><span>Dauer</span></label>
|
||||
<label class="moment-choice-pill"><input type="radio" name="event_walk_mode" value="steps"><span>Schritte</span></label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="moment-alcohol-field" data-moment-alcohol-field hidden>
|
||||
<legend>Heute Alkohol getrunken?</legend>
|
||||
<div class="moment-choice-row">
|
||||
<label class="moment-choice-pill">
|
||||
<input type="radio" name="event_consumed" value="1" checked>
|
||||
<span>Ja</span>
|
||||
</label>
|
||||
<label class="moment-choice-pill">
|
||||
<input type="radio" name="event_consumed" value="0">
|
||||
<span>Nein</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="overlay-signal-grid overlay-signal-grid--summary-row overlay-signal-grid--moment">
|
||||
<?php foreach (['event_mood' => ['Stimmung', 0], 'event_energy' => ['Energie', 0], 'event_stress' => ['Stress', 0]] as $field => [$label, $value]): ?>
|
||||
<?php $metric = str_replace('event_', '', $field); ?>
|
||||
<div class="overlay-signal-card overlay-signal-card--inline overlay-signal-card--moment" data-stepper data-stepper-metric="<?= e($metric) ?>">
|
||||
<div>
|
||||
<h3><?= e($label) ?></h3>
|
||||
</div>
|
||||
<div class="overlay-signal-card__control">
|
||||
<div class="overlay-signal-card__ring tone-zero">
|
||||
<span data-stepper-value><?= $value >= 0 ? '+' : '' ?><?= e((string) $value) ?></span>
|
||||
</div>
|
||||
<div class="overlay-signal-card__buttons">
|
||||
<button type="button" data-stepper-minus>-</button>
|
||||
<button type="button" data-stepper-plus>+</button>
|
||||
</div>
|
||||
<div class="signal-scale" aria-hidden="true"><span>-2</span><span>-1</span><span>0</span><span>+1</span><span>+2</span></div>
|
||||
<input type="hidden" name="<?= e($field) ?>" value="<?= e((string) $value) ?>" data-stepper-input>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form method="post" action="/" class="dashboard-modal__secondary-action" data-moment-delete-form hidden>
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="form_name" value="delete_event">
|
||||
<input type="hidden" name="date" value="<?= e((string) $dayEntry['date']) ?>">
|
||||
<input type="hidden" name="event_id" value="" data-moment-delete-id>
|
||||
<button class="ghost-button" type="submit">Moment löschen</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
<?php elseif ($dashboardView === 'week'): ?>
|
||||
<section class="dashboard-range-view dashboard-range-view--week">
|
||||
<header class="dashboard-range-view__hero">
|
||||
<p class="eyebrow">Wochenansicht</p>
|
||||
<h1><?= e($dashboardWeek['title']) ?></h1>
|
||||
<h2><?= e($dashboardWeek['range']) ?></h2>
|
||||
</header>
|
||||
|
||||
<?php $weekInsights = is_array($dashboardWeek['insights'] ?? null) ? $dashboardWeek['insights'] : []; ?>
|
||||
<?php if ((int) ($weekInsights['average_steps'] ?? 0) > 0 || (int) ($weekInsights['daily_sport_minutes'] ?? 0) > 0): ?>
|
||||
<section class="week-insight-card glass-panel">
|
||||
<?php if ((int) ($weekInsights['average_steps'] ?? 0) > 0): ?>
|
||||
<p>
|
||||
Du bist in dieser Woche durchschnittlich <strong><?= e(number_format((int) $weekInsights['average_steps'], 0, ',', '.')) ?> Schritte</strong> gegangen.
|
||||
<?php if (!empty($weekInsights['has_step_comparison'])): ?>
|
||||
Das sind <strong><?= e(number_format(abs((int) $weekInsights['step_difference']), 0, ',', '.')) ?> Schritte <?= e((string) $weekInsights['step_direction']) ?></strong> als im vergangenen Monat.
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
<?php if ((int) ($weekInsights['daily_sport_minutes'] ?? 0) > 0): ?>
|
||||
<p>Täglich hast du im Schnitt <strong><?= e((string) $weekInsights['daily_sport_minutes']) ?> Minuten Sport</strong> gemacht.</p>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="range-period-rail range-period-rail--week">
|
||||
<?php foreach (($dashboardWeek['periods'] ?? [$dashboardWeek]) as $week): ?>
|
||||
<article class="range-period-panel<?= !empty($week['is_selected']) ? ' is-selected' : '' ?>">
|
||||
<header class="range-period-panel__head">
|
||||
<a href="/?view=week&date=<?= e(rawurlencode((string) ($week['key'] ?? $dashboardDate))) ?>">
|
||||
<h3><?= e((string) $week['title']) ?></h3>
|
||||
<p><?= e((string) $week['range']) ?></p>
|
||||
</a>
|
||||
</header>
|
||||
|
||||
<nav class="range-score-strip range-score-strip--week glass-panel" aria-label="Tage dieser Woche">
|
||||
<span class="score-scale score-scale--range" aria-hidden="true"><span>+2</span><span>+1</span><span>0</span><span>-1</span><span>-2</span></span>
|
||||
<?php foreach ($week['days'] as $day): ?>
|
||||
<a class="range-score-day<?= !empty($day['is_current']) ? ' is-current' : '' ?><?= empty($day['has_content']) ? ' is-empty' : '' ?>" href="/?view=week&date=<?= e(rawurlencode((string) $day['date'])) ?>" title="<?= e((string) $day['weekday']) ?>">
|
||||
<span class="compare-day__line<?= !empty($day['is_current']) ? ' is-primary' : '' ?> score-<?= e((string) ($day['score_level'] ?? 'empty')) ?> compare-tone-<?= e((string) ($day['line_tone'] ?? 'empty')) ?>">
|
||||
<span class="compare-day__marker"></span>
|
||||
</span>
|
||||
<span class="range-score-day__label"><?= e((string) $day['day']) ?></span>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</nav>
|
||||
</article>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<?php $weekDetailDays = array_values(array_reverse(array_filter($dashboardWeek['days'], static fn (array $day): bool => !empty($day['has_content'])))); ?>
|
||||
<?php if ($weekDetailDays !== []): ?>
|
||||
<div class="range-day-list">
|
||||
<?php foreach ($weekDetailDays as $day): ?>
|
||||
<?php
|
||||
$entry = is_array($day['entry'] ?? null) ? $day['entry'] : null;
|
||||
$events = $entry !== null && is_array($entry['events'] ?? null) ? $entry['events'] : [];
|
||||
$summaryText = $entry !== null ? trim((string) ($entry['summary']['comment'] ?? $entry['summary_comment'] ?? $entry['note'] ?? '')) : '';
|
||||
$dayTone = (string) ($day['line_tone'] ?? 'empty');
|
||||
$dayImage = $entry !== null && is_string($entry['background_image_url'] ?? null) ? (string) $entry['background_image_url'] : null;
|
||||
?>
|
||||
<a class="range-day-card range-day-card--<?= e($dayTone) ?><?= empty($day['has_content']) ? ' is-empty' : '' ?> glass-panel" href="/?view=day&date=<?= e(rawurlencode((string) $day['date'])) ?>">
|
||||
<?php if ($dayImage !== null): ?>
|
||||
<img class="range-day-card__image" src="<?= e($dayImage) ?>" alt="">
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="range-day-card__body">
|
||||
<p class="eyebrow"><?= e((string) $day['weekday']) ?></p>
|
||||
<p class="range-day-card__score">Bilanz <?= e($formatBalanceValue($entry)) ?></p>
|
||||
<p class="range-day-card__summary"><?= $summaryText !== '' ? e($summaryText) : 'Noch keine Tagesbilanz.' ?></p>
|
||||
|
||||
<?php if ($events !== []): ?>
|
||||
<ul class="range-moment-list">
|
||||
<?php foreach ($events as $event): ?>
|
||||
<?php if (!is_array($event)) { continue; } ?>
|
||||
<?php
|
||||
$eventType = (string) ($event['type'] ?? 'event');
|
||||
$eventScore = signal_combo_score($event['mood'] ?? 0, $event['energy'] ?? 0, $event['stress'] ?? 0);
|
||||
$eventTone = signal_value_class($eventScore);
|
||||
$sportType = $eventType === 'sport' ? find_sport_type($settings, (string) ($event['sport_type_id'] ?? '')) : null;
|
||||
$eventValue = (float) ($event['value'] ?? 0);
|
||||
$eventValueText = $eventValue > 0 ? ($eventType === 'sleep' ? format_duration_hours($eventValue) : rtrim(rtrim(number_format($eventValue, 2, ',', '.'), '0'), ',') . ' ' . (string) ($event['unit'] ?? '')) : '';
|
||||
$eventComment = trim((string) ($event['comment'] ?? ''));
|
||||
if (preg_match('/^\s*-\s*(?:Stimmung|Energie|Stress)\s*:\s*[+-]?\d+\s*$/u', $eventComment) === 1) {
|
||||
$eventComment = '';
|
||||
}
|
||||
$eventTitle = day_event_type_label($eventType);
|
||||
$eventDetails = array_values(array_filter([$eventValueText, $eventComment], static fn (string $value): bool => trim($value) !== ''));
|
||||
|
||||
if ($eventType === 'sport') {
|
||||
$eventTitle = (string) ($sportType['label'] ?? 'Sport');
|
||||
}
|
||||
|
||||
if ($eventType === 'sleep') {
|
||||
$eventTitle = 'Schlaf';
|
||||
} elseif ($eventType === 'walk') {
|
||||
$eventTitle = 'Spaziergang';
|
||||
}
|
||||
?>
|
||||
<li class="range-moment-list__item range-moment-list__item--<?= e($eventTone) ?>">
|
||||
<span class="range-moment-list__bullet" aria-hidden="true"></span>
|
||||
<span>
|
||||
<strong><?= e($eventTitle) ?></strong>
|
||||
<?php foreach ($eventDetails as $eventDetail): ?>
|
||||
<span><?= e($eventDetail) ?></span>
|
||||
<?php endforeach; ?>
|
||||
</span>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<article class="hero-card glass-panel">
|
||||
<p class="eyebrow">Heute</p>
|
||||
<?php if ($summary['today'] !== null): ?>
|
||||
<div class="hero-score"><?= e(format_points((float) $summary['today']['evaluation']['total'])) ?></div>
|
||||
<p class="hero-label"><?= e($summary['today']['evaluation']['label']) ?></p>
|
||||
<?php else: ?>
|
||||
<div class="hero-score">-</div>
|
||||
<p class="hero-label">Noch kein Eintrag für heute</p>
|
||||
<?php endif; ?>
|
||||
</article>
|
||||
</section>
|
||||
<?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>
|
||||
|
||||
<section class="stats-grid">
|
||||
<article class="metric-card glass-panel">
|
||||
<span>Getrackte Tage</span>
|
||||
<strong><?= e((string) $summary['tracked_days']) ?></strong>
|
||||
</article>
|
||||
<article class="metric-card glass-panel">
|
||||
<span>Ø Score</span>
|
||||
<strong><?= e(format_points((float) $summary['average_score'])) ?></strong>
|
||||
</article>
|
||||
<article class="metric-card glass-panel">
|
||||
<span>Ø Stimmung</span>
|
||||
<strong><?= e(format_points((float) $summary['average_mood'])) ?>/10</strong>
|
||||
</article>
|
||||
<article class="metric-card glass-panel">
|
||||
<span>Ø Stress</span>
|
||||
<strong><?= e(format_points((float) $summary['average_stress'])) ?>/10</strong>
|
||||
</article>
|
||||
<article class="metric-card glass-panel">
|
||||
<span>Serie</span>
|
||||
<strong><?= e((string) $summary['streak']) ?> Tage</strong>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="dashboard-grid">
|
||||
<article class="glass-panel chart-card chart-card--calendar">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<p class="eyebrow">Kalender</p>
|
||||
<h3>Gesamtstimmung pro Tag</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div id="calendar-heatmap" class="calendar-heatmap" data-payload="<?= e($chartPayload) ?>"></div>
|
||||
</article>
|
||||
|
||||
<article class="glass-panel chart-card">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<p class="eyebrow">Trend</p>
|
||||
<h3>Tagesstimmung</h3>
|
||||
</div>
|
||||
<span class="chart-chip">letzte 30 Einträge</span>
|
||||
</div>
|
||||
<div class="line-chart" data-chart-type="line" data-series="mood" data-payload="<?= e($chartPayload) ?>"></div>
|
||||
</article>
|
||||
|
||||
<article class="glass-panel chart-card">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<p class="eyebrow">Belastung</p>
|
||||
<h3>Stressverlauf</h3>
|
||||
</div>
|
||||
<span class="chart-chip chart-chip--warm">weniger ist besser</span>
|
||||
</div>
|
||||
<div class="line-chart" data-chart-type="line" data-series="stress" data-payload="<?= e($chartPayload) ?>"></div>
|
||||
</article>
|
||||
|
||||
<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>
|
||||
<span class="chart-chip chart-chip--cool">Aktivität pro Tag</span>
|
||||
</div>
|
||||
<div class="bar-chart" data-chart-type="bars" data-series="sport" data-payload="<?= e($chartPayload) ?>"></div>
|
||||
</article>
|
||||
<div class="range-period-rail range-period-rail--month">
|
||||
<?php foreach (($dashboardMonth['periods'] ?? [$dashboardMonth]) as $month): ?>
|
||||
<article class="range-period-panel<?= !empty($month['is_selected']) ? ' is-selected' : '' ?>">
|
||||
<header class="range-period-panel__head">
|
||||
<a href="/?view=month&date=<?= e(rawurlencode((string) ($month['key'] ?? $dashboardDate))) ?>">
|
||||
<h3><?= e((string) $month['title']) ?></h3>
|
||||
</a>
|
||||
</header>
|
||||
|
||||
<nav class="range-score-strip range-score-strip--month glass-panel" aria-label="Tage dieses Monats">
|
||||
<span class="score-scale score-scale--range score-scale--month" aria-hidden="true"><span>+2</span><span>+1</span><span>0</span><span>-1</span><span>-2</span></span>
|
||||
<?php foreach ($month['days'] as $day): ?>
|
||||
<a class="range-score-day<?= empty($day['has_content']) ? ' is-empty' : '' ?><?= !empty($day['is_future']) ? ' is-future' : '' ?>" href="/?view=month&date=<?= e(rawurlencode((string) $day['date'])) ?>" title="<?= e((string) $day['weekday']) ?>">
|
||||
<span class="compare-day__line score-<?= e((string) ($day['score_level'] ?? 'empty')) ?> compare-tone-<?= e((string) ($day['line_tone'] ?? 'empty')) ?>">
|
||||
<span class="compare-day__marker"></span>
|
||||
</span>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</nav>
|
||||
</article>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<?php $monthDetailDays = array_values(array_filter($dashboardMonth['days'], static function (array $day): bool {
|
||||
$entry = is_array($day['entry'] ?? null) ? $day['entry'] : null;
|
||||
$summaryText = $entry !== null ? trim((string) ($entry['summary']['comment'] ?? $entry['summary_comment'] ?? $entry['note'] ?? '')) : '';
|
||||
|
||||
return !empty($day['has_content']) || $summaryText !== '';
|
||||
})); ?>
|
||||
<?php $monthDetailDays = array_reverse($monthDetailDays); ?>
|
||||
<?php if ($monthDetailDays !== []): ?>
|
||||
<div class="range-day-list range-day-list--month">
|
||||
<?php foreach ($monthDetailDays as $day): ?>
|
||||
<?php
|
||||
$entry = is_array($day['entry'] ?? null) ? $day['entry'] : null;
|
||||
$summaryText = $entry !== null ? trim((string) ($entry['summary']['comment'] ?? $entry['summary_comment'] ?? $entry['note'] ?? '')) : '';
|
||||
$dayTone = (string) ($day['line_tone'] ?? 'empty');
|
||||
?>
|
||||
<a class="range-day-card range-day-card--summary-only range-day-card--<?= e($dayTone) ?> glass-panel" href="/?view=day&date=<?= e(rawurlencode((string) $day['date'])) ?>">
|
||||
<div class="range-day-card__body">
|
||||
<p class="eyebrow"><?= e((string) $day['weekday']) ?></p>
|
||||
<p class="range-day-card__score">Bilanz <?= e($formatBalanceValue($entry)) ?></p>
|
||||
<p class="range-day-card__summary"><?= $summaryText !== '' ? e($summaryText) : 'Tagesbilanz' ?></p>
|
||||
</div>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="dashboard-overlay" data-settings-menu-overlay hidden>
|
||||
<div class="dashboard-overlay__backdrop" data-settings-menu-close></div>
|
||||
<section class="dashboard-modal dashboard-modal--settings glass-panel" role="dialog" aria-modal="true">
|
||||
<div class="dashboard-modal__controls">
|
||||
<button class="dashboard-modal__round" type="button" data-settings-menu-close>×</button>
|
||||
</div>
|
||||
|
||||
<h2 class="dashboard-modal__title">Einstellungen und Bereiche</h2>
|
||||
<div class="settings-menu-grid">
|
||||
<a class="options-menu-card" href="/options?panel=sports"><strong>Sportarten anpassen</strong><span>Eigene Sportarten und Bonuspunkte</span></a>
|
||||
<a class="options-menu-card" href="/options?panel=walk"><strong>Spaziergang anpassen</strong><span>Zeit oder Schritte auswerten</span></a>
|
||||
<a class="options-menu-card" href="/options?panel=sleep"><strong>Schlaf anpassen</strong><span>Optimale Schlafmenge</span></a>
|
||||
<a class="options-menu-card" href="/options?panel=health"><strong>Health Import</strong><span>Apple Health automatisch übernehmen</span></a>
|
||||
<a class="options-menu-card" href="/options?panel=reminders"><strong>Erinnerungen setzen</strong><span>Push und tägliche Erinnerung</span></a>
|
||||
<a class="options-menu-card" href="/options?panel=ratings"><strong>Bewertungsskala ändern</strong><span>Labels und Schutzregeln</span></a>
|
||||
<a class="options-menu-card" href="/options?panel=stats"><strong>Statistik</strong><span>Verlauf und Aktivität</span></a>
|
||||
<?php if (!empty($authUser['is_admin'])): ?>
|
||||
<a class="options-menu-card" href="/options?panel=users"><strong>Neue Nutzer anlegen</strong><span>Accounts und Adminrechte</span></a>
|
||||
<?php endif; ?>
|
||||
<form method="post" action="/logout" class="options-logout-form">
|
||||
<?= csrf_field() ?>
|
||||
<button class="options-menu-card options-menu-card--danger" type="submit"><strong>Abmelden</strong><span>Sitzung sicher beenden</span></button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="media-lightbox" data-media-lightbox hidden>
|
||||
<button class="media-lightbox__backdrop" type="button" data-media-lightbox-close aria-label="Ansicht schließen"></button>
|
||||
<div class="media-lightbox__panel" role="dialog" aria-modal="true" aria-label="Medienansicht">
|
||||
<button class="media-lightbox__close" type="button" data-media-lightbox-close aria-label="Ansicht schließen">×</button>
|
||||
<div class="media-lightbox__content" data-media-lightbox-content></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,368 +1,307 @@
|
||||
<section class="page-grid">
|
||||
<article class="glass-panel form-panel form-panel--wide">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<p class="eyebrow">Dein Account</p>
|
||||
<h3>Score und Sportarten persönlich anpassen</h3>
|
||||
<p class="helper-text">Alle Einstellungen auf dieser Seite gelten nur für deinen eigenen Account und beeinflussen keine anderen Nutzer.</p>
|
||||
</div>
|
||||
<span class="chart-chip">Maximal <?= e(format_points((float) $maxScore)) ?> Punkte</span>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/options" class="stack-form stack-form--spacious">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="form_name" value="settings">
|
||||
|
||||
<div class="settings-section">
|
||||
<h4>Multiplikatoren</h4>
|
||||
<div class="field-grid field-grid--four">
|
||||
<label><span>Stimmung</span><input type="number" name="settings[scoring][mood_multiplier]" value="<?= e((string) $settings['scoring']['mood_multiplier']) ?>" min="0" max="10"></label>
|
||||
<label><span>Energie</span><input type="number" name="settings[scoring][energy_multiplier]" value="<?= e((string) $settings['scoring']['energy_multiplier']) ?>" min="0" max="10"></label>
|
||||
<label><span>Stress</span><input type="number" name="settings[scoring][stress_multiplier]" value="<?= e((string) $settings['scoring']['stress_multiplier']) ?>" min="0" max="10"></label>
|
||||
<label><span>Schlafgefühl</span><input type="number" name="settings[scoring][sleep_feeling_multiplier]" value="<?= e((string) $settings['scoring']['sleep_feeling_multiplier']) ?>" min="0" max="10"></label>
|
||||
</div>
|
||||
<section class="options-shell">
|
||||
<div class="options-overlay" data-options-overlay data-options-standalone="1" data-open-panel="<?= e((string) ($optionsOpenPanel ?? '')) ?>">
|
||||
<div class="options-overlay__backdrop" data-options-close></div>
|
||||
<section class="options-modal glass-panel" role="dialog" aria-modal="true">
|
||||
<div class="options-modal__controls">
|
||||
<button class="dashboard-modal__round" type="button" data-options-back>‹</button>
|
||||
<button class="dashboard-modal__round" type="button" data-options-close>×</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<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 class="options-menu-panel" data-options-menu>
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h4>Spaziergang</h4>
|
||||
<p class="helper-text">Du kannst Spaziergänge pro Account entweder über Zeit oder über Schritte bewerten lassen.</p>
|
||||
<p class="eyebrow">Optionen</p>
|
||||
<h3>Einstellungen und Bereiche</h3>
|
||||
</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 class="options-menu-grid">
|
||||
<button class="options-menu-card" type="button" data-options-open="sports"><strong>Sportarten anpassen</strong><span>Eigene Sportarten und Bonuspunkte</span></button>
|
||||
<button class="options-menu-card" type="button" data-options-open="walk"><strong>Spaziergang anpassen</strong><span>Zeit oder Schritte auswerten</span></button>
|
||||
<button class="options-menu-card" type="button" data-options-open="sleep"><strong>Schlaf anpassen</strong><span>Optimale Schlafmenge markieren</span></button>
|
||||
<button class="options-menu-card" type="button" data-options-open="reminders"><strong>Erinnerungen setzen</strong><span>Push und tägliche Erinnerung</span></button>
|
||||
<button class="options-menu-card" type="button" data-options-open="health"><strong>Health Import</strong><span>Apple Health automatisch übernehmen</span></button>
|
||||
<button class="options-menu-card" type="button" data-options-open="ratings"><strong>Bewertungsskala ändern</strong><span>Labels und Schutzregeln</span></button>
|
||||
<button class="options-menu-card" type="button" data-options-open="stats"><strong>Statistik</strong><span>Verlauf und Aktivität</span></button>
|
||||
<?php if (!empty($authUser['is_admin'])): ?>
|
||||
<button class="options-menu-card" type="button" data-options-open="users"><strong>Neue Nutzer anlegen</strong><span>Accounts und Adminrechte</span></button>
|
||||
<?php endif; ?>
|
||||
<button class="options-menu-card" type="button" data-options-open="security"><strong>Sicherheit</strong><span>Passwort und Backup</span></button>
|
||||
<?php if (!empty($authUser['is_admin'])): ?>
|
||||
<button class="options-menu-card" type="button" data-options-open="ai"><strong>KI</strong><span>OpenAI und Zusammenfassungen</span></button>
|
||||
<?php endif; ?>
|
||||
<form method="post" action="/logout" class="options-logout-form">
|
||||
<?= csrf_field() ?>
|
||||
<button class="options-menu-card options-menu-card--danger" type="submit"><strong>Abmelden</strong><span>Sitzung sicher beenden</span></button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<div class="section-head section-head--compact">
|
||||
<div>
|
||||
<h4>Sportarten und Bonuspunkte</h4>
|
||||
<p class="helper-text">Lege fest, welche Sportarten nur in deinem eigenen Tracking auswählbar sind. Entfernte Sportarten kannst du darunter jederzeit wieder für deinen Account hinzufügen.</p>
|
||||
</div>
|
||||
<button class="ghost-button" type="button" data-add-sport-type>Sportart hinzufügen</button>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="settings[sport_types_present]" value="1">
|
||||
|
||||
<?php if (!empty($sportTypePresets)): ?>
|
||||
<div class="preset-list">
|
||||
<?php foreach ($sportTypePresets as $preset): ?>
|
||||
<button
|
||||
class="preset-pill"
|
||||
type="button"
|
||||
data-sport-preset
|
||||
data-id="<?= e($preset['id']) ?>"
|
||||
data-label="<?= e($preset['label']) ?>"
|
||||
data-icon="<?= e($preset['icon']) ?>"
|
||||
data-location="<?= e($preset['location'] ?? '') ?>"
|
||||
data-recovery-group="<?= e($preset['recovery_group']) ?>"
|
||||
data-bonus-points="<?= e((string) $preset['bonus_points']) ?>"
|
||||
data-allow-consecutive="<?= !empty($preset['allow_consecutive']) ? '1' : '0' ?>"
|
||||
>
|
||||
<img src="<?= e(sport_icon_path($preset['icon'])) ?>" alt="">
|
||||
<span><?= e($preset['label']) ?></span>
|
||||
</button>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="sport-type-list" data-sport-type-list>
|
||||
<?php foreach ($settings['sport_types'] as $index => $sportType): ?>
|
||||
<div class="sport-type-card band-card" data-sport-type-row>
|
||||
<input type="hidden" name="settings[sport_types][<?= e((string) $index) ?>][id]" value="<?= e($sportType['id']) ?>" data-name-template="settings[sport_types][__INDEX__][id]">
|
||||
|
||||
<div class="field-grid field-grid--four">
|
||||
<label>
|
||||
<span>Bezeichnung</span>
|
||||
<input type="text" name="settings[sport_types][<?= e((string) $index) ?>][label]" value="<?= e($sportType['label']) ?>" data-name-template="settings[sport_types][__INDEX__][label]">
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Icon</span>
|
||||
<select name="settings[sport_types][<?= e((string) $index) ?>][icon]" data-name-template="settings[sport_types][__INDEX__][icon]">
|
||||
<?php foreach (sport_icon_options() as $iconValue => $iconLabel): ?>
|
||||
<option value="<?= e($iconValue) ?>" <?= $sportType['icon'] === $iconValue ? 'selected' : '' ?>><?= e($iconLabel) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Ort</span>
|
||||
<select name="settings[sport_types][<?= e((string) $index) ?>][location]" data-name-template="settings[sport_types][__INDEX__][location]">
|
||||
<?php foreach ($sportLocationOptions as $locationValue => $locationLabel): ?>
|
||||
<option value="<?= e($locationValue) ?>" <?= ($sportType['location'] ?? '') === $locationValue ? 'selected' : '' ?>><?= e($locationLabel) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Erholungsgruppe optional</span>
|
||||
<input type="text" name="settings[sport_types][<?= e((string) $index) ?>][recovery_group]" value="<?= e($sportType['recovery_group']) ?>" data-name-template="settings[sport_types][__INDEX__][recovery_group]">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="field-grid field-grid--four">
|
||||
<label>
|
||||
<span>Bonuspunkte</span>
|
||||
<input type="number" name="settings[sport_types][<?= e((string) $index) ?>][bonus_points]" value="<?= e((string) $sportType['bonus_points']) ?>" min="0" max="20" data-name-template="settings[sport_types][__INDEX__][bonus_points]">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="checkbox-row">
|
||||
<input type="checkbox" name="settings[sport_types][<?= e((string) $index) ?>][allow_consecutive]" value="1" <?= !empty($sportType['allow_consecutive']) ? 'checked' : '' ?> data-name-template="settings[sport_types][__INDEX__][allow_consecutive]">
|
||||
<span>Darf an aufeinanderfolgenden Tagen Bonus geben</span>
|
||||
</label>
|
||||
|
||||
<p class="helper-text">Die Erholungsgruppe brauchst du nur, wenn mehrere Varianten dieselbe Belastung teilen sollen, zum Beispiel Krafttraining Zuhause und Krafttraining Auswärts. Wenn du nichts Besonderes einträgst, reicht die Sportart selbst.</p>
|
||||
|
||||
<div class="sport-type-card__actions">
|
||||
<span class="sport-pill sport-pill--soft">
|
||||
<img src="<?= e(sport_icon_path($sportType['icon'])) ?>" alt="">
|
||||
<span><?= e($sportType['label']) ?><?= !empty($sportType['location']) ? ' · ' . e(sport_location_label((string) $sportType['location'])) : '' ?></span>
|
||||
</span>
|
||||
<button class="ghost-button ghost-button--small" type="button" data-remove-sport-type>Entfernen</button>
|
||||
<div class="options-panel" data-options-panel="sports" hidden>
|
||||
<h2>Sportarten anpassen</h2>
|
||||
<form method="post" action="/options" class="stack-form stack-form--spacious">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="form_name" value="settings">
|
||||
<div class="settings-section">
|
||||
<div class="section-head section-head--compact">
|
||||
<div>
|
||||
<h4>Sportarten und Bonuspunkte</h4>
|
||||
<p class="helper-text">Diese Sportarten stehen in deinen Momenten zur Auswahl.</p>
|
||||
</div>
|
||||
<button class="ghost-button" type="button" data-add-sport-type>Sportart hinzufügen</button>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<div class="section-actions">
|
||||
<input type="hidden" name="settings[sport_types_present]" value="1">
|
||||
<?php if (!empty($sportTypePresets)): ?>
|
||||
<div class="preset-list">
|
||||
<?php foreach ($sportTypePresets as $preset): ?>
|
||||
<button class="preset-pill" type="button" data-sport-preset data-id="<?= e($preset['id']) ?>" data-label="<?= e($preset['label']) ?>" data-icon="<?= e($preset['icon']) ?>" data-location="<?= e($preset['location'] ?? '') ?>" data-recovery-group="<?= e($preset['recovery_group']) ?>" data-bonus-points="<?= e((string) $preset['bonus_points']) ?>" data-allow-consecutive="<?= !empty($preset['allow_consecutive']) ? '1' : '0' ?>">
|
||||
<img src="<?= e(sport_icon_path($preset['icon'])) ?>" alt=""><span><?= e($preset['label']) ?></span>
|
||||
</button>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="sport-type-list" data-sport-type-list>
|
||||
<?php foreach ($settings['sport_types'] as $index => $sportType): ?>
|
||||
<div class="sport-type-card band-card" data-sport-type-row>
|
||||
<input type="hidden" name="settings[sport_types][<?= e((string) $index) ?>][id]" value="<?= e($sportType['id']) ?>" data-name-template="settings[sport_types][__INDEX__][id]">
|
||||
<div class="field-grid field-grid--four">
|
||||
<label><span>Bezeichnung</span><input type="text" name="settings[sport_types][<?= e((string) $index) ?>][label]" value="<?= e($sportType['label']) ?>" data-name-template="settings[sport_types][__INDEX__][label]"></label>
|
||||
<label><span>Icon</span><select name="settings[sport_types][<?= e((string) $index) ?>][icon]" data-name-template="settings[sport_types][__INDEX__][icon]"><?php foreach (sport_icon_options() as $iconValue => $iconLabel): ?><option value="<?= e($iconValue) ?>" <?= $sportType['icon'] === $iconValue ? 'selected' : '' ?>><?= e($iconLabel) ?></option><?php endforeach; ?></select></label>
|
||||
<label><span>Ort</span><select name="settings[sport_types][<?= e((string) $index) ?>][location]" data-name-template="settings[sport_types][__INDEX__][location]"><?php foreach ($sportLocationOptions as $locationValue => $locationLabel): ?><option value="<?= e($locationValue) ?>" <?= ($sportType['location'] ?? '') === $locationValue ? 'selected' : '' ?>><?= e($locationLabel) ?></option><?php endforeach; ?></select></label>
|
||||
<label><span>Erholungsgruppe</span><input type="text" name="settings[sport_types][<?= e((string) $index) ?>][recovery_group]" value="<?= e($sportType['recovery_group']) ?>" data-name-template="settings[sport_types][__INDEX__][recovery_group]"></label>
|
||||
</div>
|
||||
<div class="field-grid field-grid--four"><label><span>Bonuspunkte</span><input type="number" name="settings[sport_types][<?= e((string) $index) ?>][bonus_points]" value="<?= e((string) $sportType['bonus_points']) ?>" min="0" max="20" data-name-template="settings[sport_types][__INDEX__][bonus_points]"></label></div>
|
||||
<label class="checkbox-row"><input type="checkbox" name="settings[sport_types][<?= e((string) $index) ?>][allow_consecutive]" value="1" <?= !empty($sportType['allow_consecutive']) ? 'checked' : '' ?> data-name-template="settings[sport_types][__INDEX__][allow_consecutive]"><span>Darf an Folgetagen Bonus geben</span></label>
|
||||
<div class="sport-type-card__actions"><button class="ghost-button ghost-button--small" type="button" data-remove-sport-type>Entfernen</button></div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<template id="sport-type-row-template">
|
||||
<div class="sport-type-card band-card" data-sport-type-row>
|
||||
<input type="hidden" value="" data-name-template="settings[sport_types][__INDEX__][id]">
|
||||
<div class="field-grid field-grid--four">
|
||||
<label><span>Bezeichnung</span><input type="text" value="" data-name-template="settings[sport_types][__INDEX__][label]"></label>
|
||||
<label><span>Icon</span><select data-name-template="settings[sport_types][__INDEX__][icon]"><?php foreach (sport_icon_options() as $iconValue => $iconLabel): ?><option value="<?= e($iconValue) ?>"><?= e($iconLabel) ?></option><?php endforeach; ?></select></label>
|
||||
<label><span>Ort</span><select data-name-template="settings[sport_types][__INDEX__][location]"><?php foreach ($sportLocationOptions as $locationValue => $locationLabel): ?><option value="<?= e($locationValue) ?>"><?= e($locationLabel) ?></option><?php endforeach; ?></select></label>
|
||||
<label><span>Erholungsgruppe</span><input type="text" value="" data-name-template="settings[sport_types][__INDEX__][recovery_group]"></label>
|
||||
</div>
|
||||
<div class="field-grid field-grid--four"><label><span>Bonuspunkte</span><input type="number" value="2" min="0" max="20" data-name-template="settings[sport_types][__INDEX__][bonus_points]"></label></div>
|
||||
<label class="checkbox-row"><input type="checkbox" value="1" data-name-template="settings[sport_types][__INDEX__][allow_consecutive]"><span>Darf an Folgetagen Bonus geben</span></label>
|
||||
<div class="sport-type-card__actions"><button class="ghost-button ghost-button--small" type="button" data-remove-sport-type>Entfernen</button></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<button class="primary-button" type="submit">Sportarten speichern</button>
|
||||
</div>
|
||||
|
||||
<template id="sport-type-row-template">
|
||||
<div class="sport-type-card band-card" data-sport-type-row>
|
||||
<input type="hidden" value="" data-name-template="settings[sport_types][__INDEX__][id]">
|
||||
|
||||
<div class="field-grid field-grid--four">
|
||||
<label>
|
||||
<span>Bezeichnung</span>
|
||||
<input type="text" value="" placeholder="z. B. Krafttraining Zuhause" data-name-template="settings[sport_types][__INDEX__][label]">
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Icon</span>
|
||||
<select data-name-template="settings[sport_types][__INDEX__][icon]">
|
||||
<?php foreach (sport_icon_options() as $iconValue => $iconLabel): ?>
|
||||
<option value="<?= e($iconValue) ?>"><?= e($iconLabel) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Ort</span>
|
||||
<select data-name-template="settings[sport_types][__INDEX__][location]">
|
||||
<?php foreach ($sportLocationOptions as $locationValue => $locationLabel): ?>
|
||||
<option value="<?= e($locationValue) ?>"><?= e($locationLabel) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Erholungsgruppe optional</span>
|
||||
<input type="text" value="" placeholder="z. B. krafttraining" data-name-template="settings[sport_types][__INDEX__][recovery_group]">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="field-grid field-grid--four">
|
||||
<label>
|
||||
<span>Bonuspunkte</span>
|
||||
<input type="number" value="2" min="0" max="20" data-name-template="settings[sport_types][__INDEX__][bonus_points]">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="checkbox-row">
|
||||
<input type="checkbox" value="1" data-name-template="settings[sport_types][__INDEX__][allow_consecutive]">
|
||||
<span>Darf an aufeinanderfolgenden Tagen Bonus geben</span>
|
||||
</label>
|
||||
|
||||
<p class="helper-text">Die Erholungsgruppe ist nur dann nützlich, wenn mehrere Varianten sportlich gleich belastend sind und denselben Bonusrhythmus teilen sollen.</p>
|
||||
|
||||
<div class="sport-type-card__actions">
|
||||
<span class="sport-pill sport-pill--soft">
|
||||
<img src="<?= e(sport_icon_path('run')) ?>" alt="">
|
||||
<span>Neue Sportart</span>
|
||||
</span>
|
||||
<button class="ghost-button ghost-button--small" type="button" data-remove-sport-type>Entfernen</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<div class="section-head section-head--compact">
|
||||
<div>
|
||||
<h4>Erinnerungen</h4>
|
||||
<p class="helper-text">Diese Erinnerungen gelten nur für deinen Account. Auf dem iPhone funktioniert Push nach dem Hinzufügen zum Home-Bildschirm.</p>
|
||||
<div class="options-panel" data-options-panel="walk" hidden>
|
||||
<h2>Spaziergang und Schritte</h2>
|
||||
<form method="post" action="/options" class="stack-form">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="form_name" value="settings">
|
||||
<p class="helper-text">Spaziergänge werden als Momente angezeigt. Punkte kommen nicht mehr aus einzelnen Spaziergängen, sondern aus der täglichen Gesamtschrittzahl.</p>
|
||||
<label><span>Spaziergang anzeigen nach</span><select name="settings[walk][mode]"><?php foreach ($walkModeOptions as $modeValue => $modeLabel): ?><option value="<?= e($modeValue) ?>" <?= ($settings['walk']['mode'] ?? 'time') === $modeValue ? 'selected' : '' ?>><?= e($modeLabel) ?></option><?php endforeach; ?></select></label>
|
||||
<div class="settings-section">
|
||||
<h4>Schritte-Bonus</h4>
|
||||
<div class="field-grid field-grid--three">
|
||||
<label><span>Mehr als</span><input type="number" name="settings[scoring][step_bonus][min]" value="<?= e((string) ($settings['scoring']['step_bonus']['min'] ?? 10000)) ?>" min="0" max="100000"></label>
|
||||
<label><span>Bis einschließlich</span><input type="number" name="settings[scoring][step_bonus][max]" value="<?= e((string) ($settings['scoring']['step_bonus']['max'] ?? 15000)) ?>" min="0" max="100000"></label>
|
||||
<label><span>Bonuspunkte</span><input type="number" name="settings[scoring][step_bonus][points]" value="<?= e((string) ($settings['scoring']['step_bonus']['points'] ?? 1)) ?>" min="0" max="20"></label>
|
||||
</div>
|
||||
</div>
|
||||
<span class="chart-chip"><?= e((string) $pushSubscriptionCount) ?> Gerät<?= $pushSubscriptionCount === 1 ? '' : 'e' ?></span>
|
||||
</div>
|
||||
<div class="settings-section">
|
||||
<h4>Schritte-Zielkurve</h4>
|
||||
<p class="helper-text">Diese Punkte fließen als kleiner Bonus oder Malus in die Tagesbilanz ein. Positive Werte motivieren, negative Werte markieren zu wenig oder zu viel Bewegung.</p>
|
||||
<div class="band-grid">
|
||||
<?php foreach (($settings['scoring']['walk_step_targets'] ?? []) as $index => $target): ?>
|
||||
<div class="band-card">
|
||||
<label><span>Schritte</span><input type="number" name="settings[scoring][walk_step_targets][<?= e((string) $index) ?>][steps]" value="<?= e((string) ($target['steps'] ?? 0)) ?>" min="0" max="100000"></label>
|
||||
<label><span>Punkte</span><input type="number" name="settings[scoring][walk_step_targets][<?= e((string) $index) ?>][points]" value="<?= e((string) ($target['points'] ?? 0)) ?>" min="-20" max="20"></label>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
<button class="primary-button" type="submit">Schritte speichern</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="field-grid field-grid--two">
|
||||
<label class="checkbox-row checkbox-row--panel">
|
||||
<input type="checkbox" name="settings[notifications][enabled]" value="1" <?= !empty($settings['notifications']['enabled']) ? 'checked' : '' ?>>
|
||||
<span>Tägliche Push-Erinnerung aktivieren</span>
|
||||
</label>
|
||||
<div class="options-panel" data-options-panel="sleep" hidden>
|
||||
<h2>Schlaf anpassen</h2>
|
||||
<form method="post" action="/options" class="stack-form">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="form_name" value="settings">
|
||||
<p class="helper-text">Diese Zielmenge wird im importierten Schlafbalken als horizontale Markierung angezeigt und fließt in die automatische Stimmung/Energie/Stress-Einschätzung ein.</p>
|
||||
<label><span>Optimale Schlafdauer</span><input type="number" name="settings[sleep][optimal_hours]" value="<?= e((string) ($settings['sleep']['optimal_hours'] ?? 7.0)) ?>" min="1" max="16" step="0.1"></label>
|
||||
<button class="primary-button" type="submit">Schlaf speichern</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
<span>Uhrzeit der Erinnerung</span>
|
||||
<input type="time" name="settings[notifications][time]" value="<?= e((string) ($settings['notifications']['time'] ?? '20:30')) ?>">
|
||||
</label>
|
||||
</div>
|
||||
<div class="options-panel" data-options-panel="reminders" hidden>
|
||||
<h2>Erinnerungen setzen</h2>
|
||||
<form method="post" action="/options" class="stack-form">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="form_name" value="settings">
|
||||
<div class="field-grid field-grid--two">
|
||||
<label class="checkbox-row checkbox-row--panel"><input type="checkbox" name="settings[notifications][enabled]" value="1" <?= !empty($settings['notifications']['enabled']) ? 'checked' : '' ?>><span>Tägliche Push-Erinnerung aktivieren</span></label>
|
||||
<label><span>Uhrzeit der Erinnerung</span><input type="time" name="settings[notifications][time]" value="<?= e((string) ($settings['notifications']['time'] ?? '20:30')) ?>"></label>
|
||||
</div>
|
||||
<div class="push-panel band-card" data-push-panel data-push-ready="<?= !empty($pushAvailable) && !empty($pushPublicKey) ? '1' : '0' ?>">
|
||||
<div><h5>Push auf diesem Gerät</h5><p class="helper-text" data-push-status><?php if (!empty($pushAvailable) && !empty($pushPublicKey)): ?>Installiere die App auf Wunsch als PWA und aktiviere dann Push direkt auf diesem Gerät.<?php else: ?>Push ist auf diesem Server gerade noch nicht verfügbar.<?php endif; ?></p></div>
|
||||
<div class="push-actions"><button class="ghost-button" type="button" data-push-enable <?= empty($pushAvailable) || empty($pushPublicKey) ? 'disabled' : '' ?>>Push aktivieren</button><button class="ghost-button" type="button" data-push-disable <?= empty($pushAvailable) || empty($pushPublicKey) ? 'disabled' : '' ?>>Auf diesem Gerät entfernen</button><button class="ghost-button" type="button" data-push-test <?= empty($pushAvailable) || empty($pushPublicKey) ? 'disabled' : '' ?>>Test senden</button></div>
|
||||
</div>
|
||||
<button class="primary-button" type="submit">Erinnerungen speichern</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="push-panel band-card"
|
||||
data-push-panel
|
||||
data-push-ready="<?= !empty($pushAvailable) && !empty($pushPublicKey) ? '1' : '0' ?>"
|
||||
>
|
||||
<div>
|
||||
<h5>Push auf diesem Gerät</h5>
|
||||
<p class="helper-text" data-push-status>
|
||||
<?php if (!empty($pushAvailable) && !empty($pushPublicKey)): ?>
|
||||
Installiere die App auf Wunsch als PWA und aktiviere dann Push direkt auf diesem Gerät.
|
||||
<div class="options-panel" data-options-panel="health" hidden>
|
||||
<h2>Health Import</h2>
|
||||
<article class="detail-card detail-card--overlay">
|
||||
<p class="eyebrow">REST-Endpunkt</p>
|
||||
<div class="stack-form">
|
||||
<label><span>URL in Health Auto Export</span><input type="text" value="<?= e((string) $healthImportUrl) ?>" readonly></label>
|
||||
<label><span>HTTP-Header</span><input type="text" value="Authorization: Bearer <?= !empty($healthImportConfig['token_prefix']) ? e((string) $healthImportConfig['token_prefix']) . '…' : 'TOKEN' ?>" readonly></label>
|
||||
</div>
|
||||
<p class="helper-text">Lege in Health Auto Export zwei REST-API-Automationen an: Gesundheitsmetriken mit <code>step_count</code> und <code>sleep_analysis</code>, sowie Workouts mit JSON Version 2 und Routendaten.</p>
|
||||
</article>
|
||||
|
||||
<?php if (!empty($healthImportToken)): ?>
|
||||
<article class="detail-card detail-card--overlay health-token-card">
|
||||
<p class="eyebrow">Neuer Token</p>
|
||||
<label><span>Nur jetzt sichtbar</span><input type="text" value="<?= e((string) $healthImportToken) ?>" readonly></label>
|
||||
<p class="helper-text">Kopiere diesen Token als Bearer-Token in Health Auto Export. Danach wird nur noch der Anfang angezeigt.</p>
|
||||
</article>
|
||||
<?php endif; ?>
|
||||
|
||||
<article class="detail-card detail-card--overlay" data-health-import-status>
|
||||
<p class="eyebrow">Status</p>
|
||||
<div class="health-import-progress" data-health-progress-wrap data-progress-done="<?= e((string) ($healthImportConfig['progress_done'] ?? 0)) ?>" data-progress-total="<?= e((string) ($healthImportConfig['progress_total'] ?? 0)) ?>">
|
||||
<progress class="health-import-progress__bar" data-health-progress-bar max="100" value="0">0%</progress>
|
||||
<p class="helper-text" data-health-progress-text>
|
||||
<?php if (($healthImportConfig['last_status'] ?? '') === 'running'): ?>
|
||||
Import läuft: <?= e((string) ($healthImportConfig['progress_done'] ?? 0)) ?> von <?= e((string) ($healthImportConfig['progress_total'] ?? 0)) ?> verarbeitet.
|
||||
<?php elseif (!empty($healthImportConfig['last_message'])): ?>
|
||||
<?= e((string) $healthImportConfig['last_message']) ?>
|
||||
<?php else: ?>
|
||||
Push ist auf diesem Server gerade noch nicht verfügbar.
|
||||
Noch kein Import gelaufen.
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="push-actions">
|
||||
<button class="ghost-button" type="button" data-push-enable <?= empty($pushAvailable) || empty($pushPublicKey) ? 'disabled' : '' ?>>Push aktivieren</button>
|
||||
<button class="ghost-button" type="button" data-push-disable <?= empty($pushAvailable) || empty($pushPublicKey) ? 'disabled' : '' ?>>Auf diesem Gerät entfernen</button>
|
||||
<button class="ghost-button" type="button" data-push-test <?= empty($pushAvailable) || empty($pushPublicKey) ? 'disabled' : '' ?>>Test senden</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-actions">
|
||||
<button class="primary-button" type="submit">Erinnerungen speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h4>Bewertungsskala</h4>
|
||||
<p class="helper-text">Diese Score-Grenzen und Schutzregeln gelten ebenfalls nur für deinen eigenen Account.</p>
|
||||
<div class="band-grid">
|
||||
<?php foreach ($settings['ratings'] as $index => $rating): ?>
|
||||
<div class="band-card">
|
||||
<label><span>Label</span><input type="text" name="settings[ratings][<?= e((string) $index) ?>][label]" value="<?= e($rating['label']) ?>"></label>
|
||||
<label><span>Min</span><input type="number" name="settings[ratings][<?= e((string) $index) ?>][min]" value="<?= e((string) $rating['min']) ?>"></label>
|
||||
<label><span>Max</span><input type="number" name="settings[ratings][<?= e((string) $index) ?>][max]" value="<?= e((string) $rating['max']) ?>"></label>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h4>Schutzregeln</h4>
|
||||
<div class="band-grid">
|
||||
<?php foreach ($settings['guardrails'] as $index => $guardrail): ?>
|
||||
<div class="band-card">
|
||||
<label><span>Stimmung max</span><input type="number" name="settings[guardrails][<?= e((string) $index) ?>][mood_max]" value="<?= e((string) $guardrail['mood_max']) ?>" min="1" max="10"></label>
|
||||
<label><span>Energie max</span><input type="number" name="settings[guardrails][<?= e((string) $index) ?>][energy_max]" value="<?= e((string) ($guardrail['energy_max'] ?? '')) ?>" min="1" max="10"></label>
|
||||
<label><span>Maximales Label</span><input type="text" name="settings[guardrails][<?= e((string) $index) ?>][cap_label]" value="<?= e($guardrail['cap_label']) ?>"></label>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
<span>Tagebuchpunkte bei nicht-leerer Notiz</span>
|
||||
<input type="number" name="settings[scoring][journal_points]" value="<?= e((string) $settings['scoring']['journal_points']) ?>" min="0" max="20">
|
||||
</label>
|
||||
|
||||
<button class="primary-button" type="submit">Bewertung speichern</button>
|
||||
</form>
|
||||
</article>
|
||||
|
||||
<aside class="stack-column">
|
||||
<article class="glass-panel detail-card">
|
||||
<p class="eyebrow">Sicherheit</p>
|
||||
<h3>Passwort ändern</h3>
|
||||
<form method="post" action="/options" class="stack-form">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="form_name" value="password">
|
||||
<label><span>Aktuelles Passwort</span><input type="password" name="current_password" required></label>
|
||||
<label><span>Neues Passwort</span><input type="password" name="new_password" minlength="10" required></label>
|
||||
<label><span>Neues Passwort wiederholen</span><input type="password" name="new_password_confirm" minlength="10" required></label>
|
||||
<button class="primary-button" type="submit">Passwort aktualisieren</button>
|
||||
</form>
|
||||
</article>
|
||||
|
||||
<?php if (!empty($authUser['is_admin'])): ?>
|
||||
<article class="glass-panel detail-card">
|
||||
<p class="eyebrow">Mehrere Accounts</p>
|
||||
<h3>Neuen Nutzer anlegen</h3>
|
||||
<form method="post" action="/options" class="stack-form">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="form_name" value="create_user">
|
||||
<label><span>Benutzername</span><input type="text" name="username" required></label>
|
||||
<label><span>Startpasswort</span><input type="password" name="password" minlength="10" required></label>
|
||||
<label class="checkbox-row"><input type="checkbox" name="is_admin" value="1"><span>Als Admin anlegen</span></label>
|
||||
<button class="primary-button" type="submit">Account erstellen</button>
|
||||
</form>
|
||||
|
||||
<?php if ($users !== []): ?>
|
||||
<div class="user-list">
|
||||
<?php foreach ($users as $account): ?>
|
||||
<div class="user-row">
|
||||
<strong><?= e($account['username']) ?></strong>
|
||||
<span><?= !empty($account['is_admin']) ? 'Admin' : 'Nutzer' ?></span>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<div class="user-row"><strong>Token</strong><span data-health-token-state><?= !empty($healthImportConfig['enabled']) ? 'aktiv' : 'nicht eingerichtet' ?></span></div>
|
||||
<?php if (!empty($healthImportConfig['last_import_at'])): ?>
|
||||
<div class="user-row"><strong>Letzter Import</strong><span data-health-last-import><?= e(format_display_datetime((string) $healthImportConfig['last_import_at'])) ?></span></div>
|
||||
<?php else: ?>
|
||||
<div class="user-row"><strong>Letzter Import</strong><span data-health-last-import>-</span></div>
|
||||
<?php endif; ?>
|
||||
<div class="user-row"><strong>Statusmeldung</strong><span data-health-last-message><?= !empty($healthImportConfig['last_message']) ? e((string) $healthImportConfig['last_message']) : '-' ?></span></div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</article>
|
||||
<?php endif; ?>
|
||||
</aside>
|
||||
<form method="post" action="/options" class="stack-form">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="form_name" value="health_import_token">
|
||||
<button class="primary-button" type="submit"><?= !empty($healthImportConfig['enabled']) ? 'Token neu erstellen' : 'Token erstellen' ?></button>
|
||||
</form>
|
||||
<?php if (!empty($healthImportConfig['enabled'])): ?>
|
||||
<form method="post" action="/options" class="stack-form">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="form_name" value="health_import_revoke">
|
||||
<button class="ghost-button" type="submit">Token deaktivieren</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="options-panel" data-options-panel="ratings" hidden>
|
||||
<h2>Bewertungsskala ändern</h2>
|
||||
<form method="post" action="/options" class="stack-form stack-form--spacious">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="form_name" value="settings">
|
||||
<div class="settings-section">
|
||||
<h4>Tagesbilanz als Hauptmetrik</h4>
|
||||
<p class="helper-text">Stimmung, Energie und Stress bilden die Basis. Schlaf, Schritte, Sport, Spaziergang und Notizen verschieben den Tag nur gedeckelt in eine positivere oder negativere Richtung.</p>
|
||||
<div class="field-grid field-grid--three">
|
||||
<label><span>Gewicht Stimmung</span><input type="number" name="settings[day_balance][mood_weight]" value="<?= e((string) ($settings['day_balance']['mood_weight'] ?? 3)) ?>" min="0" max="10"></label>
|
||||
<label><span>Gewicht Energie</span><input type="number" name="settings[day_balance][energy_weight]" value="<?= e((string) ($settings['day_balance']['energy_weight'] ?? 2)) ?>" min="0" max="10"></label>
|
||||
<label><span>Gewicht Stress</span><input type="number" name="settings[day_balance][stress_weight]" value="<?= e((string) ($settings['day_balance']['stress_weight'] ?? 2)) ?>" min="0" max="10"></label>
|
||||
</div>
|
||||
<div class="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'])): ?>
|
||||
<div class="options-panel" data-options-panel="users" hidden>
|
||||
<h2>Neue Nutzer anlegen</h2>
|
||||
<form method="post" action="/options" class="stack-form">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="form_name" value="create_user">
|
||||
<label><span>Benutzername</span><input type="text" name="username" required></label>
|
||||
<label><span>Startpasswort</span><input type="password" name="password" minlength="10" required></label>
|
||||
<label class="checkbox-row"><input type="checkbox" name="is_admin" value="1"><span>Als Admin anlegen</span></label>
|
||||
<button class="primary-button" type="submit">Account erstellen</button>
|
||||
</form>
|
||||
<?php if ($users !== []): ?><div class="user-list"><?php foreach ($users as $account): ?><div class="user-row"><strong><?= e($account['username']) ?></strong><span><?= !empty($account['is_admin']) ? 'Admin' : 'Nutzer' ?></span></div><?php endforeach; ?></div><?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="options-panel" data-options-panel="security" hidden>
|
||||
<h2>Sicherheit</h2>
|
||||
<article class="detail-card detail-card--overlay">
|
||||
<p class="eyebrow">Backup</p>
|
||||
<form method="post" action="/options" class="stack-form"><?= csrf_field() ?><input type="hidden" name="form_name" value="export_backup"><button class="primary-button" type="submit" <?= empty($backupAvailable) ? 'disabled' : '' ?>>Backup herunterladen</button></form>
|
||||
<form method="post" action="/options" enctype="multipart/form-data" class="stack-form"><?= csrf_field() ?><input type="hidden" name="form_name" value="import_backup"><label><span>Backup importieren</span><input type="file" name="backup_files[]" accept=".zip,.txt" multiple></label><button class="ghost-button" type="submit">Backup importieren</button></form>
|
||||
</article>
|
||||
<article class="detail-card detail-card--overlay">
|
||||
<p class="eyebrow">Passwort</p>
|
||||
<form method="post" action="/options" class="stack-form"><?= csrf_field() ?><input type="hidden" name="form_name" value="password"><label><span>Aktuelles Passwort</span><input type="password" name="current_password" required></label><label><span>Neues Passwort</span><input type="password" name="new_password" minlength="10" required></label><label><span>Neues Passwort wiederholen</span><input type="password" name="new_password_confirm" minlength="10" required></label><button class="primary-button" type="submit">Passwort aktualisieren</button></form>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($authUser['is_admin'])): ?>
|
||||
<div class="options-panel" data-options-panel="ai" hidden>
|
||||
<h2>KI</h2>
|
||||
<?php if (!empty($aiStatus)): ?><div class="user-list"><div class="user-row"><strong>API-Key</strong><span><?= !empty($aiStatus['has_api_key']) ? 'vorhanden' : 'fehlt' ?></span></div><div class="user-row"><strong>Aktuelles Modell</strong><span><?= e((string) ($aiStatus['model'] ?? '')) ?></span></div><div class="user-row"><strong>Timeout</strong><span><?= e((string) ($aiStatus['timeout'] ?? '')) ?> s</span></div></div><?php endif; ?>
|
||||
<form method="post" action="/options" class="stack-form"><?= csrf_field() ?><input type="hidden" name="form_name" value="ai_config"><label><span>OpenAI-Modell</span><input type="text" name="ai[model]" value="<?= e((string) ($aiConfig['model'] ?? 'gpt-4o-mini')) ?>" required></label><label><span>Timeout in Sekunden</span><input type="number" name="ai[timeout]" value="<?= e((string) ($aiConfig['timeout'] ?? 25)) ?>" min="5" max="120" required></label><button class="primary-button" type="submit">KI-Konfiguration speichern</button></form>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<h3><?= e(format_display_date($entry['date'])) ?></h3>
|
||||
</div>
|
||||
<div class="section-head__actions">
|
||||
<a class="ghost-link" href="/archive?date=<?= e(rawurlencode($entry['date'])) ?>">Im Archiv ansehen</a>
|
||||
<a class="ghost-link" href="/archive?view=days&date=<?= e(rawurlencode($entry['date'])) ?>">Im Archiv ansehen</a>
|
||||
<a class="ghost-link" href="/track?date=<?= e(today()) ?>">Heute</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -37,6 +37,42 @@
|
||||
</label>
|
||||
</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">
|
||||
<label>
|
||||
<span>Schlafdauer in Stunden</span>
|
||||
@@ -127,11 +163,13 @@
|
||||
'mood' => 'Stimmung',
|
||||
'energy' => 'Energie',
|
||||
'stress' => 'Stress',
|
||||
'pain' => 'Schmerzen',
|
||||
'sleep_hours' => 'Schlafdauer',
|
||||
'sleep_feeling' => 'Schlafgefühl',
|
||||
'sport_minutes' => 'Sport',
|
||||
'sport_bonus' => 'Sportbonus',
|
||||
'walk_minutes' => 'Spaziergang',
|
||||
'alcohol' => 'Alkohol',
|
||||
'note' => 'Notiz',
|
||||
];
|
||||
?>
|
||||
|
||||