diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..879fcf5 --- /dev/null +++ b/AGENTS.md @@ -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//`: 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//settings.json`. +- Tagesdaten in `storage/users//days/YYYY-MM-DD.txt`. +- Wochen- und Monatszusammenfassungen unter `storage/users//summaries/`. +- Push-Subscriptions und Reminder-State liegen ebenfalls pro Nutzer unter `storage/users//`. + +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 ` +- 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. diff --git a/assets/css/app.css b/assets/css/app.css index f3354ab..7ac8ae9 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -201,6 +201,18 @@ body { color: var(--text); } +body.page-dashboard { + background: + linear-gradient(180deg, rgba(8, 18, 34, 0.28), rgba(8, 18, 34, 0.42)), + var(--body-radial-one), + var(--body-radial-two), + var(--body-gradient); +} + +body.is-dashboard-overlay-open { + overflow: hidden; +} + .pull-refresh-indicator { position: fixed; top: max(0.85rem, env(safe-area-inset-top)); @@ -287,6 +299,11 @@ button:disabled { padding: 1.25rem; } +.shell--dashboard { + display: block; + padding: 0; +} + .sidebar, .topbar, .glass-panel { @@ -317,6 +334,1250 @@ button:disabled { gap: 1rem; } +body.page-dashboard .content { + min-height: 100vh; + padding: 0; +} + +.dashboard-shell { + position: relative; + min-height: 100vh; + padding: max(1.1rem, env(safe-area-inset-top)) 1rem calc(4rem + env(safe-area-inset-bottom)); + background: + linear-gradient(180deg, rgba(7, 18, 34, 0.12), rgba(7, 18, 34, 0.42)), + radial-gradient(circle at 20% 18%, rgba(132, 168, 255, 0.16), transparent 34%), + linear-gradient(180deg, #102543 0%, #15284a 34%, #465c79 64%, #18263e 100%); + overflow: hidden; +} + +.dashboard-shell__background { + position: absolute; + inset: 0; + z-index: 0; +} + +.dashboard-shell__background img { + width: 100%; + height: 100%; + object-fit: cover; + object-position: center; + opacity: 0.92; +} + +.dashboard-shell::before { + content: ""; + position: absolute; + inset: 0; + background: + linear-gradient(180deg, rgba(8, 20, 37, 0.34), rgba(8, 20, 37, 0.76)), + radial-gradient(circle at center, rgba(7, 18, 34, 0.08), rgba(7, 18, 34, 0.28)); + pointer-events: none; +} + +.dashboard-shell > :not(.dashboard-shell__background) { + position: relative; + z-index: 1; +} + +.dashboard-topbar { + position: fixed; + top: max(0.85rem, env(safe-area-inset-top)); + left: 50%; + transform: translateX(-50%); + z-index: 25; + display: flex; + justify-content: center; + align-items: center; + gap: 1rem; + width: min(calc(100% - 2rem), 920px); + margin: 0; +} + +.dashboard-switcher { + display: inline-flex; + gap: 0.4rem; + padding: 0.38rem; + border-radius: 999px; +} + +.dashboard-switcher a, +.dashboard-settings { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 3.2rem; + min-height: 3.2rem; + padding: 0.75rem 1.15rem; + border-radius: 999px; + color: rgba(255, 255, 255, 0.92); + transition: background 180ms ease, transform 180ms ease, color 180ms ease; +} + +.dashboard-switcher a.active { + background: rgba(255, 255, 255, 0.18); +} + +.dashboard-settings { + margin-left: auto; + padding: 0; + width: 3.6rem; + height: 3.6rem; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.08); +} + +.dashboard-settings img { + width: 1.5rem; + height: 1.5rem; +} + +.dashboard-day, +.dashboard-range-view { + width: min(100%, 920px); + margin: 0 auto; + padding-top: 6rem; +} + +.dashboard-compare-strip { + position: relative; + width: min(100%, 20rem); + height: 11rem; + margin-top: 0.95rem; +} + +.compare-day { + position: absolute; + left: 50%; + top: 0; + display: block; + width: 2rem; + height: 11rem; + transform: translateX(-50%); +} + +.compare-day.offset--3 { left: calc(50% - 7.2rem); } +.compare-day.offset--2 { left: calc(50% - 4.8rem); } +.compare-day.offset--1 { left: calc(50% - 2.4rem); } +.compare-day.offset-0 { left: 50%; } +.compare-day.offset-1 { left: calc(50% + 2.4rem); } + +.compare-day.is-current { + width: 4.8rem; + z-index: 2; +} + +.compare-day.is-empty { + opacity: 0.82; +} + +.compare-day__short, +.compare-day__date { + display: none; +} + +.compare-day__line { + display: block; + position: relative; + width: 100%; + height: 100%; + background: transparent; +} + +.compare-day__line.is-primary { + width: 100%; + height: 100%; +} + +.compare-day__track { + display: none; +} + +.compare-day__marker { + position: absolute; + left: 50%; + width: 1.2rem; + height: 0.3rem; + border-radius: 999px; + transform: translate(-50%, -50%); + top: 55%; + background: var(--primary); + box-shadow: 0 0 10px rgba(139, 228, 255, 0.18); +} + +.compare-day__line.is-primary .compare-day__marker { + width: 3.8rem; + height: 0.38rem; + box-shadow: 0 0 18px rgba(139, 228, 255, 0.24); +} + +.compare-day__line.compare-tone-neg2 .compare-day__marker { background: var(--danger); box-shadow: 0 0 12px color-mix(in srgb, var(--danger) 32%, transparent); } +.compare-day__line.compare-tone-neg1 .compare-day__marker { background: var(--warm); box-shadow: 0 0 12px color-mix(in srgb, var(--warm) 30%, transparent); } +.compare-day__line.compare-tone-zero .compare-day__marker { background: var(--primary); box-shadow: 0 0 12px color-mix(in srgb, var(--primary) 30%, transparent); } +.compare-day__line.compare-tone-pos1 .compare-day__marker { background: var(--accent); box-shadow: 0 0 12px color-mix(in srgb, var(--accent) 28%, transparent); } +.compare-day__line.compare-tone-pos2 .compare-day__marker { background: var(--good); box-shadow: 0 0 12px color-mix(in srgb, var(--good) 32%, transparent); } + +.compare-day__line.score--2 .compare-day__marker { top: 88%; } +.compare-day__line.score--1 .compare-day__marker { top: 69%; } +.compare-day__line.score-0 .compare-day__marker { top: 50%; } +.compare-day__line.score-1 .compare-day__marker { top: 31%; } +.compare-day__line.score-2 .compare-day__marker { top: 12%; } +.compare-day__line.score-empty .compare-day__marker { top: 50%; } + +.tone-neg2 { background: #ff8f8f !important; border-color: rgba(255, 143, 143, 0.6) !important; } +.tone-neg1 { background: #ffbf8d !important; border-color: rgba(255, 191, 141, 0.6) !important; } +.tone-zero { background: #8be4ff !important; border-color: rgba(139, 228, 255, 0.6) !important; } +.tone-pos1 { background: #8bffcf !important; border-color: rgba(139, 255, 207, 0.6) !important; } +.tone-pos2 { background: #7ff3bb !important; border-color: rgba(127, 243, 187, 0.6) !important; } + +.compare-day.is-empty .compare-day__marker { + background: rgba(255, 255, 255, 0.3); + box-shadow: none; +} + +.dashboard-day__hero, +.dashboard-range-view__hero { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: 0.5rem; + margin-bottom: 2rem; + padding: 1rem 1.25rem; + border-radius: 1.8rem; + background: linear-gradient(180deg, rgba(9, 23, 38, 0.24), rgba(9, 23, 38, 0.12)); + backdrop-filter: blur(10px); +} + +.dashboard-day__eyebrow, +.dashboard-range-view__hero .eyebrow { + margin: 0; + color: rgba(255, 255, 255, 0.84); + font-size: 1rem; +} + +.dashboard-day__hero h1, +.dashboard-range-view__hero h1 { + margin: 0; + font-family: Georgia, "Times New Roman", serif; + font-size: clamp(2.6rem, 7vw, 5rem); + line-height: 0.95; + letter-spacing: -0.04em; + text-shadow: 0 8px 26px rgba(5, 14, 24, 0.44); +} + +.dashboard-range-view__hero h2 { + margin: 0; + font-family: Georgia, "Times New Roman", serif; + font-size: clamp(1.4rem, 4vw, 2.6rem); + line-height: 1.02; +} + +.day-summary-card, +.timeline-card, +.range-card, +.dashboard-composer { + background: + linear-gradient(180deg, rgba(25, 36, 56, 0.74), rgba(16, 25, 40, 0.62)), + radial-gradient(circle at top left, rgba(255, 255, 255, 0.16), transparent 44%); + border-color: rgba(143, 191, 255, 0.24); +} + +.day-summary-card { + display: block; + width: 100%; + text-align: left; + padding: 1.15rem; + border-radius: 1.7rem; + margin-bottom: 1rem; + border: 1px solid rgba(143, 191, 255, 0.24); +} + +.day-summary-card__label { + display: block; + margin-bottom: 0.45rem; + color: rgba(255, 255, 255, 0.7); + font-size: 0.95rem; +} + +.day-summary-card__title { + display: block; + font-size: clamp(1.05rem, 2.5vw, 1.5rem); + font-weight: 400; + line-height: 1.2; +} + +.day-summary-card__head { + display: flex; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1rem; +} + +.day-summary-card__head h2, +.timeline-card__body h3 { + margin: 0; + font-size: clamp(1.1rem, 2.6vw, 1.7rem); + font-weight: 400; + line-height: 1.18; +} + +.day-summary-card__remove-image { + margin-top: 0.75rem; +} + +.field-grid--signals select { + min-height: 3.2rem; +} + +.day-summary-form__media { + align-items: end; +} + +.day-summary-form__actions { + display: flex; + justify-content: flex-end; + align-items: center; + height: 100%; +} + +.timeline-list { + display: flex; + flex-direction: column; + gap: 0.9rem; + padding-bottom: 8rem; +} + +.dashboard-moments-block { + margin-top: 1.2rem; + padding-left: clamp(0.7rem, 2vw, 1.35rem); +} + +.section-head--dashboard h2 { + margin: 0; + font-size: 1.45rem; +} + +.timeline-card { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 0.9rem; + padding: 1rem 1.1rem; + border-radius: 1.65rem; +} + +.timeline-card--empty { + display: block; +} + +.timeline-card--empty p { + margin: 0.35rem 0 0; + color: var(--muted); +} + +.timeline-card--neg2 { + background: + linear-gradient(180deg, rgba(91, 28, 34, 0.74), rgba(61, 18, 22, 0.68)), + radial-gradient(circle at top left, rgba(255, 143, 143, 0.14), transparent 42%); +} + +.timeline-card--neg1 { + background: + linear-gradient(180deg, rgba(84, 46, 22, 0.72), rgba(61, 34, 17, 0.68)), + radial-gradient(circle at top left, rgba(255, 191, 141, 0.14), transparent 42%); +} + +.timeline-card--pos1 { + background: + linear-gradient(180deg, rgba(20, 63, 54, 0.72), rgba(14, 46, 39, 0.68)), + radial-gradient(circle at top left, rgba(139, 255, 207, 0.14), transparent 42%); +} + +.timeline-card--pos2 { + background: + linear-gradient(180deg, rgba(16, 78, 55, 0.74), rgba(10, 55, 39, 0.7)), + radial-gradient(circle at top left, rgba(127, 243, 187, 0.16), transparent 42%); +} + +.timeline-card--summary { + display: block; +} + +.timeline-card__meta { + display: flex; + align-items: center; + gap: 0.8rem; +} + +.timeline-card__meta p, +.timeline-card__value { + margin: 0.2rem 0 0; + color: var(--muted); +} + +.timeline-card__meta strong { + font-size: 0.92rem; + color: rgba(239, 247, 255, 0.68); + font-weight: 500; +} + +.timeline-card__meta p { + display: none; +} + +.timeline-card__icon-wrap { + width: 3rem; + height: 3rem; + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + display: inline-flex; + align-items: center; + justify-content: center; +} + +.timeline-card__icon { + width: 1.3rem; + height: 1.3rem; +} + +.signal-row { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; + margin-top: 0.85rem; +} + +.signal-row--compact { + margin-top: 0; +} + +.signal-pill { + display: inline-flex; + align-items: center; + gap: 0.45rem; + padding: 0.4rem 0.7rem; + border-radius: 999px; + font-size: 0.84rem; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.signal-pill--good { + background: rgba(144, 214, 108, 0.2); + border-color: rgba(180, 255, 120, 0.34); +} + +.signal-pill--warn { + background: rgba(221, 128, 54, 0.22); + border-color: rgba(255, 162, 93, 0.34); +} + +.signal-pill--neutral { + background: rgba(255, 209, 94, 0.18); + border-color: rgba(255, 209, 94, 0.28); +} + +.signal-dot { + width: 0.85rem; + height: 0.85rem; + border-radius: 999px; + display: inline-block; + border: 1px solid rgba(255, 255, 255, 0.2); +} + +.signal-dot--good { background: #9cdf63; } +.signal-dot--warn { background: #e57c32; } +.signal-dot--neutral { background: #ffd04b; } + +.dashboard-fab { + position: fixed; + right: max(1rem, env(safe-area-inset-right)); + bottom: calc(1.2rem + env(safe-area-inset-bottom)); + width: 4rem; + height: 4rem; + border: 0; + border-radius: 999px; + color: var(--text); + background: linear-gradient(180deg, rgba(197, 235, 255, 0.26), rgba(153, 202, 255, 0.18)); + backdrop-filter: blur(24px) saturate(180%); + box-shadow: 0 16px 44px rgba(8, 18, 34, 0.35); + font-size: 2rem; +} + +.dashboard-composer { + position: fixed; + left: 1rem; + right: 1rem; + bottom: calc(6rem + env(safe-area-inset-bottom)); + max-width: 920px; + margin: 0 auto; + padding: 1rem; + border-radius: 1.6rem; +} + +.dashboard-overlay[hidden] { + display: none; +} + +.dashboard-overlay { + position: fixed; + inset: 0; + width: 100vw; + min-height: 100vh; + z-index: 60; + display: flex; + align-items: stretch; + justify-content: stretch; + padding: 0; + overflow: auto; +} + +.dashboard-overlay__backdrop { + position: absolute; + inset: 0; + background: rgba(3, 9, 17, 0.64); + backdrop-filter: blur(10px); +} + +.dashboard-modal { + position: relative; + flex: 1 1 auto; + width: 100%; + min-height: 100vh; + max-height: none; + margin: 0; + padding: max(1rem, env(safe-area-inset-top)) 1rem max(1rem, env(safe-area-inset-bottom)); + border-radius: 0; + overflow: auto; + background: linear-gradient(180deg, rgba(26, 26, 29, 0.96), rgba(38, 38, 42, 0.92)); + border-color: rgba(255, 255, 255, 0.08); + z-index: 1; +} + +.dashboard-modal--settings { + background: linear-gradient(180deg, rgba(17, 29, 46, 0.98), rgba(18, 29, 45, 0.94)); +} + +.dashboard-modal__controls { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.dashboard-modal__round { + width: 4rem; + height: 4rem; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 999px; + background: rgba(255, 255, 255, 0.06); + color: var(--text); + font-size: 2rem; + line-height: 1; +} + +.dashboard-modal__round--confirm { + background: linear-gradient(180deg, rgba(139, 228, 255, 0.3), rgba(127, 243, 187, 0.22)); + color: var(--text); +} + +.dashboard-modal__title { + margin: 0 0 0.5rem; + font-size: clamp(2.1rem, 6vw, 4rem); + line-height: 0.98; + font-weight: 700; +} + +.dashboard-modal__subtitle { + margin: 0 0 1rem; + color: rgba(255, 255, 255, 0.58); + font-size: 1.15rem; +} + +.dashboard-modal__form { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.dashboard-modal__textarea textarea { + min-height: 10rem; + border-radius: 1.8rem; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.06); + font-size: clamp(1.05rem, 2.8vw, 1.35rem); +} + +.overlay-signal-grid { + display: grid; + gap: 0.95rem; +} + +.overlay-signal-grid--summary-row { + grid-template-columns: repeat(3, minmax(0, 1fr)); + align-items: start; +} + +.overlay-signal-card { + display: grid; + grid-template-columns: 1fr auto; + gap: 1rem; + align-items: center; + padding: 1rem 0; + border-top: 1px solid rgba(255, 255, 255, 0.08); +} + +.overlay-signal-card--inline { + grid-template-columns: 1fr; + align-items: start; + gap: 0.75rem; +} + +.overlay-signal-card h3, +.overlay-signal-card p { + margin: 0; +} + +.overlay-signal-card p { + color: rgba(255, 255, 255, 0.58); + margin-top: 0.35rem; +} + +.overlay-signal-card__control { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; +} + +.moment-alcohol-field--summary { + margin: 0; +} + +.overlay-signal-card__ring { + width: 5.25rem; + height: 5.25rem; + border-radius: 999px; + display: flex; + align-items: center; + justify-content: center; + border: 0.34rem solid rgba(139, 228, 255, 0.92); + background: rgba(17, 42, 64, 0.72); + font-size: 1.75rem; + font-weight: 700; +} + +.overlay-signal-card__buttons { + display: inline-flex; + overflow: hidden; + border-radius: 999px; + background: rgba(255, 255, 255, 0.12); +} + +.overlay-signal-card__buttons button { + width: 4rem; + height: 3.2rem; + border: 0; + background: transparent; + color: var(--text); + font-size: 2rem; +} + +.overlay-signal-card__buttons button + button { + border-left: 1px solid rgba(255, 255, 255, 0.12); +} + +.dashboard-modal__secondary-action { + margin-top: 1rem; +} + +.dashboard-modal__heading-row { + display: flex; + justify-content: space-between; + align-items: end; + gap: 1rem; +} + +.moment-type-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.9rem; +} + +.moment-type-grid--sport { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.moment-type-card { + display: flex; + align-items: center; + gap: 0.85rem; + padding: 1rem; + border-radius: 1.6rem; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.08); + color: var(--text); + text-align: left; +} + +.moment-type-card img { + width: 1.5rem; + height: 1.5rem; +} + +.moment-type-card.is-selected { + background: rgba(139, 228, 255, 0.14); + border-color: rgba(139, 228, 255, 0.44); +} + +.moment-type-card--sport { + justify-content: flex-start; +} + +.moment-choice-row { + display: flex; + gap: 0.8rem; +} + +.moment-choice-pill input { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.moment-choice-pill span { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 5rem; + padding: 0.8rem 1rem; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.18); + background: rgba(255, 255, 255, 0.05); +} + +.moment-choice-pill input:checked + span { + background: rgba(139, 228, 255, 0.16); + border-color: rgba(139, 228, 255, 0.44); +} + +.dashboard-composer[hidden] { + display: none; +} + +.dashboard-range-view { + padding-bottom: 3rem; +} + +.range-period-rail { + display: grid; + grid-auto-flow: column; + grid-auto-columns: minmax(42%, 42%); + gap: 1rem; + margin-inline: calc(clamp(0rem, (100vw - 920px) / -2, 0rem)); + overflow-x: auto; + overscroll-behavior-x: contain; + scroll-snap-type: x proximity; + scrollbar-width: none; +} + +.range-period-rail::-webkit-scrollbar { + display: none; +} + +.range-period-panel { + min-width: 0; + scroll-snap-align: start; + padding: 0.25rem; + border-radius: 1.9rem; +} + +.range-period-panel.is-selected { + background: rgba(139, 228, 255, 0.08); + box-shadow: 0 0 0 1px rgba(139, 228, 255, 0.28); +} + +.range-period-panel__head { + padding: 0 0.25rem 0.7rem; +} + +.range-period-panel__head a { + color: var(--text); + text-decoration: none; +} + +.range-period-panel.is-selected .range-period-panel__head h3 { + color: var(--primary); +} + +.range-period-panel__head h3, +.range-period-panel__head p { + margin: 0; +} + +.range-period-panel__head h3 { + font-size: clamp(1.2rem, 2.8vw, 1.8rem); +} + +.range-period-panel__head p { + margin-top: 0.2rem; + color: rgba(255, 255, 255, 0.62); +} + +.range-score-strip { + display: grid; + gap: 0.55rem; + align-items: stretch; + margin-bottom: 1.1rem; + padding: 0.85rem; + border-radius: 1.7rem; +} + +.range-score-strip--week { + grid-template-columns: repeat(7, minmax(0, 1fr)); +} + +.range-score-strip--month { + display: flex; + gap: 0.18rem; + overflow: visible; + padding-inline: 0.55rem; +} + +.range-score-day { + display: grid; + grid-template-rows: 7rem auto; + gap: 0.35rem; + min-width: 0; + color: var(--text); + text-align: center; + text-decoration: none; +} + +.range-score-strip--month .range-score-day { + flex: 1 1 0; + grid-template-rows: 5.4rem; + min-width: 0; +} + +.range-score-day .compare-day__line { + height: 7rem; +} + +.range-score-strip--month .compare-day__line { + height: 5.4rem; +} + +.range-score-strip--month .range-score-day__label { + font-size: 0.64rem; +} + +.range-score-day .compare-day__marker { + width: 1.1rem; + height: 0.28rem; +} + +.range-score-day.is-current .compare-day__marker, +.range-score-day .compare-day__line.is-primary .compare-day__marker { + width: 2.4rem; + height: 0.36rem; +} + +.range-score-day.is-empty .compare-day__marker { + background: rgba(255, 255, 255, 0.28); + box-shadow: none; +} + +.range-score-day.is-future { + opacity: 0.55; +} + +.range-score-day__label { + font-size: 0.76rem; + color: rgba(255, 255, 255, 0.66); +} + +.range-day-list { + display: flex; + flex-direction: column; + gap: 1rem; + max-height: min(68vh, 56rem); + overflow: auto; + padding-right: 0.15rem; +} + +.range-day-card { + display: block; + overflow: hidden; + border-radius: 1.8rem; + color: var(--text); + text-decoration: none; + border: 1px solid rgba(143, 191, 255, 0.24); + background: + linear-gradient(180deg, rgba(25, 36, 56, 0.74), rgba(16, 25, 40, 0.62)), + radial-gradient(circle at top left, rgba(255, 255, 255, 0.16), transparent 44%); +} + +.range-day-card--neg2 { + background: + linear-gradient(180deg, rgba(91, 28, 34, 0.78), rgba(61, 18, 22, 0.68)), + radial-gradient(circle at top left, color-mix(in srgb, var(--danger) 20%, transparent), transparent 42%); + border-color: color-mix(in srgb, var(--danger) 40%, transparent); +} + +.range-day-card--neg1 { + background: + linear-gradient(180deg, rgba(84, 46, 22, 0.76), rgba(61, 34, 17, 0.68)), + radial-gradient(circle at top left, color-mix(in srgb, var(--warm) 20%, transparent), transparent 42%); + border-color: color-mix(in srgb, var(--warm) 40%, transparent); +} + +.range-day-card--zero { + background: + linear-gradient(180deg, rgba(23, 50, 76, 0.78), rgba(13, 32, 52, 0.68)), + radial-gradient(circle at top left, color-mix(in srgb, var(--primary) 18%, transparent), transparent 42%); + border-color: color-mix(in srgb, var(--primary) 34%, transparent); +} + +.range-day-card--pos1 { + background: + linear-gradient(180deg, rgba(20, 63, 54, 0.76), rgba(14, 46, 39, 0.68)), + radial-gradient(circle at top left, color-mix(in srgb, var(--accent) 18%, transparent), transparent 42%); + border-color: color-mix(in srgb, var(--accent) 34%, transparent); +} + +.range-day-card--pos2 { + background: + linear-gradient(180deg, rgba(16, 78, 55, 0.78), rgba(10, 55, 39, 0.7)), + radial-gradient(circle at top left, color-mix(in srgb, var(--good) 20%, transparent), transparent 42%); + border-color: color-mix(in srgb, var(--good) 38%, transparent); +} + +.range-day-card.is-empty { + opacity: 0.78; +} + +.range-day-card__image { + display: block; + width: 100%; + height: clamp(12rem, 36vw, 20rem); + object-fit: cover; +} + +.range-day-card__body { + padding: 1.1rem; +} + +.range-day-card__body .eyebrow { + margin: 0 0 0.5rem; + color: rgba(255, 255, 255, 0.64); +} + +.range-day-card__summary { + margin: 0; + font-size: clamp(1.05rem, 2.4vw, 1.35rem); + line-height: 1.45; +} + +.range-day-card--summary-only .range-day-card__summary { + font-size: clamp(1rem, 2.2vw, 1.25rem); +} + +.range-moment-list { + display: flex; + flex-direction: column; + gap: 0.7rem; + margin: 1rem 0 0; + padding: 0; + list-style: none; +} + +.range-moment-list__item { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.65rem; + align-items: start; + color: rgba(239, 247, 255, 0.88); +} + +.range-moment-list__item strong, +.range-moment-list__item span span { + display: block; +} + +.range-moment-list__item span span { + margin-top: 0.16rem; + color: rgba(239, 247, 255, 0.64); + font-size: 0.92rem; +} + +.range-moment-list__bullet { + width: 0.62rem; + height: 0.62rem; + margin-top: 0.4rem; + border-radius: 999px; + background: var(--primary); + box-shadow: 0 0 12px color-mix(in srgb, var(--primary) 28%, transparent); +} + +.range-moment-list__item--neg2 .range-moment-list__bullet { background: var(--danger); box-shadow: 0 0 12px color-mix(in srgb, var(--danger) 30%, transparent); } +.range-moment-list__item--neg1 .range-moment-list__bullet { background: var(--warm); box-shadow: 0 0 12px color-mix(in srgb, var(--warm) 28%, transparent); } +.range-moment-list__item--zero .range-moment-list__bullet { background: var(--primary); } +.range-moment-list__item--pos1 .range-moment-list__bullet { background: var(--accent); box-shadow: 0 0 12px color-mix(in srgb, var(--accent) 26%, transparent); } +.range-moment-list__item--pos2 .range-moment-list__bullet { background: var(--good); box-shadow: 0 0 12px color-mix(in srgb, var(--good) 30%, transparent); } + +.range-card-grid { + display: grid; + gap: 0.9rem; +} + +.range-card-grid--week { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.range-card-grid--month { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.range-card { + padding: 1rem 1.1rem; + border-radius: 1.45rem; + min-height: 7rem; +} + +.range-card strong { + display: block; + margin-bottom: 0.5rem; + font-size: 1.15rem; +} + +.range-card p { + margin: 0; + color: var(--muted); + line-height: 1.25; +} + +.range-card.is-filled { + background: + linear-gradient(180deg, rgba(144, 118, 20, 0.58), rgba(121, 98, 18, 0.54)), + radial-gradient(circle at top left, rgba(255, 213, 102, 0.15), transparent 42%); + border-color: rgba(255, 208, 86, 0.4); +} + +.range-card.is-empty { + background: rgba(255, 255, 255, 0.05); + border-color: rgba(255, 255, 255, 0.06); +} + +.range-card.is-future { + opacity: 0.72; +} + +.options-shell { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.options-menu-panel { + padding: 1.2rem; + border-radius: var(--radius-xl); +} + +.options-menu-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.9rem; +} + +.settings-menu-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.9rem; +} + +.options-menu-card { + display: flex; + flex-direction: column; + gap: 0.35rem; + align-items: flex-start; + width: 100%; + padding: 1rem 1.1rem; + border-radius: 1.4rem; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.06); + color: var(--text); + text-decoration: none; +} + +.options-menu-card strong { + font-size: 1.02rem; + font-weight: 600; +} + +.options-menu-card span { + color: var(--muted); + text-align: left; +} + +.options-menu-card--danger { + background: rgba(255, 130, 130, 0.08); + border-color: rgba(255, 143, 143, 0.18); +} + +.options-logout-form { + margin: 0; +} + +.options-overlay[hidden] { + display: none; +} + +.options-overlay { + position: fixed; + inset: 0; + width: 100vw; + min-height: 100vh; + z-index: 70; + display: flex; + align-items: stretch; + justify-content: stretch; + padding: 0; + overflow: auto; +} + +.options-overlay__backdrop { + position: absolute; + inset: 0; + background: rgba(3, 9, 17, 0.64); + backdrop-filter: blur(10px); +} + +.options-modal { + position: relative; + z-index: 1; + flex: 1 1 auto; + width: 100%; + min-height: 100vh; + max-height: none; + padding: max(1rem, env(safe-area-inset-top)) 1rem max(1rem, env(safe-area-inset-bottom)); + border-radius: 0; + overflow: auto; +} + +.options-modal__controls { + display: flex; + justify-content: flex-end; + margin-bottom: 0.8rem; +} + +.options-panel[hidden] { + display: none; +} + +.options-panel h2 { + margin-top: 0; + margin-bottom: 1rem; + font-size: 2rem; +} + +.detail-card--overlay { + padding: 1rem; + border-radius: 1.4rem; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.05); + margin-bottom: 1rem; +} + +.dashboard-grid--embedded-stats { + margin-top: 1rem; +} + +@media (min-width: 900px) { + .dashboard-shell { + padding-inline: 1.6rem; + } + + .range-card-grid--week { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .range-card-grid--month { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@media (max-width: 760px) { + .range-period-rail { + grid-auto-columns: minmax(86%, 86%); + } + + .dashboard-topbar { + gap: 0.7rem; + align-items: start; + width: calc(100% - 1rem); + } + + .dashboard-switcher { + width: auto; + flex: 1; + } + + .dashboard-switcher a { + min-width: 0; + flex: 1; + padding-inline: 0.8rem; + } + + .day-summary-card__head, + .timeline-card { + grid-template-columns: 1fr; + } + + .timeline-card__delete { + justify-self: start; + } + + .dashboard-modal { + margin: 0; + width: 100%; + max-height: 100vh; + min-height: 100vh; + border-radius: 0; + padding-top: max(1rem, env(safe-area-inset-top)); + padding-bottom: max(1rem, env(safe-area-inset-bottom)); + } + + .dashboard-overlay, + .options-overlay { + padding: 0; + } + + .moment-type-grid { + grid-template-columns: 1fr; + } + + .overlay-signal-card { + grid-template-columns: 1fr; + } + + .options-menu-grid { + grid-template-columns: 1fr; + } + + .settings-menu-grid { + grid-template-columns: 1fr; + } + + .options-modal { + width: 100%; + max-height: 100vh; + min-height: 100vh; + border-radius: 0; + padding-top: max(1rem, env(safe-area-inset-top)); + padding-bottom: max(1rem, env(safe-area-inset-bottom)); + } +} + .site-footer { display: flex; justify-content: space-between; diff --git a/assets/icons/activity-event.svg b/assets/icons/activity-event.svg new file mode 100644 index 0000000..21e442b --- /dev/null +++ b/assets/icons/activity-event.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/activity-sleep.svg b/assets/icons/activity-sleep.svg new file mode 100644 index 0000000..bdf0dfe --- /dev/null +++ b/assets/icons/activity-sleep.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/js/app.js b/assets/js/app.js index 6744b31..caf9fd1 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -39,6 +39,10 @@ return `/assets/icons/mood-${sentiment}.svg`; } + function dashboardDayPath(date) { + return `/?view=day&date=${encodeURIComponent(date)}`; + } + function sportIconPath(icon) { return `/assets/icons/sport-${icon}.svg`; } @@ -705,7 +709,7 @@ } return ` - + ${title} @@ -730,7 +734,7 @@ ${formatNumber(Number(latestVisibleEntry.score))} Punkte - Tag öffnen + Tag öffnen `; container.innerHTML = ` @@ -773,7 +777,7 @@ detailDate.textContent = formatDateLabel(entry.date); detailLabel.textContent = entry.label; detailScore.textContent = formatNumber(Number(entry.score)); - detailLink.href = `/track?date=${encodeURIComponent(entry.date)}`; + detailLink.href = dashboardDayPath(entry.date); container.querySelectorAll(".calendar-cell--selected").forEach(cell => { cell.classList.remove("calendar-cell--selected"); @@ -971,6 +975,532 @@ syncPresets(); } + function initDashboardExperience() { + const summaryOverlay = document.querySelector("[data-summary-overlay]"); + const openSummary = document.querySelector("[data-summary-overlay-open]"); + const closeSummary = [...document.querySelectorAll("[data-summary-overlay-close]")]; + const momentOverlay = document.querySelector("[data-moment-overlay]"); + const settingsMenuOverlay = document.querySelector("[data-settings-menu-overlay]"); + const openSettingsMenu = document.querySelector("[data-settings-menu-open]"); + const closeSettingsMenu = [...document.querySelectorAll("[data-settings-menu-close]")]; + const openMoment = document.querySelector("[data-moment-overlay-open]"); + const closeMoment = [...document.querySelectorAll("[data-moment-overlay-close]")]; + const chooseStep = document.querySelector('[data-moment-step="choose"]'); + const formStep = document.querySelector('[data-moment-step="form"]'); + const momentSubmit = document.querySelector("[data-moment-submit]"); + const typeInput = document.querySelector("[data-moment-type-input]"); + const formNameInput = document.querySelector("[data-moment-form-name]"); + const eventIdInput = document.querySelector("[data-moment-event-id]"); + const typeLabel = document.querySelector("[data-moment-type-label]"); + const valueField = document.querySelector("[data-moment-value-field]"); + const valueLabel = document.querySelector("[data-moment-value-label]"); + const valueInput = document.querySelector("[data-moment-value-input]"); + const walkField = document.querySelector("[data-moment-walk-field]"); + const walkModeInput = document.querySelector("[data-walk-mode-input]"); + const sportField = document.querySelector("[data-moment-sport-field]"); + const alcoholField = document.querySelector("[data-moment-alcohol-field]"); + const momentComment = document.querySelector("[data-moment-comment]"); + const backButton = document.querySelector("[data-moment-back]"); + const deleteForm = document.querySelector("[data-moment-delete-form]"); + const deleteIdInput = document.querySelector("[data-moment-delete-id]"); + const typeSelect = document.querySelector("[data-event-type-select]"); + const unitInput = document.querySelector("[data-event-unit]"); + const swipeContainer = document.querySelector("[data-day-swipe]"); + const periodRail = document.querySelector(".range-period-rail"); + + const walkMode = document.body.dataset.walkMode || "time"; + const walkUnit = walkMode === "steps" ? "steps" : "min"; + + const hoistOverlay = overlay => { + if (!(overlay instanceof HTMLElement)) { + return; + } + + if (overlay.parentElement !== document.body) { + document.body.appendChild(overlay); + } + }; + + hoistOverlay(summaryOverlay); + hoistOverlay(momentOverlay); + hoistOverlay(settingsMenuOverlay); + + if (periodRail instanceof HTMLElement) { + const params = new URLSearchParams(window.location.search); + const view = params.get("view") || "day"; + const storageKey = `mood:${view}:period-scroll`; + const storedScroll = window.sessionStorage.getItem(storageKey); + + if (storedScroll !== null) { + periodRail.scrollLeft = Number(storedScroll) || 0; + } + + periodRail.addEventListener("click", event => { + const link = event.target.closest("a[href]"); + if (!link) { + return; + } + + window.sessionStorage.setItem(storageKey, String(periodRail.scrollLeft)); + }); + } + + const stepperConfigs = { + event: { label: "Ereignis", valueLabel: "Wert", unit: "", placeholder: "optional", showValue: false, showSport: false, showAlcohol: false, commentPlaceholder: "Was hast du erlebt?" }, + walk: { label: "Spaziergang", valueLabel: "Spaziergang", unit: walkUnit, placeholder: walkMode === "steps" ? "Schritte" : "Minuten", showValue: true, showSport: false, showAlcohol: false, showWalk: true, commentPlaceholder: "Was war dabei besonders?" }, + sport: { label: "Sport", valueLabel: "Dauer", unit: "min", placeholder: "Minuten", showValue: true, showSport: true, showAlcohol: false, commentPlaceholder: "Was hast du gemacht?" }, + sleep: { label: "Schlaf", valueLabel: "Dauer", unit: "h", placeholder: "Stunden", showValue: true, showSport: false, showAlcohol: false, commentPlaceholder: "Wie war der Schlaf?" }, + alcohol: { label: "Alkohol", valueLabel: "", unit: "", placeholder: "", showValue: false, showSport: false, showAlcohol: true, commentPlaceholder: "Optionaler Kommentar" }, + }; + + const toneClass = (value, metric = "mood") => { + const current = Math.max(-2, Math.min(2, Number(value || 0))); + if (metric === "stress") { + if (current <= -2) return "tone-pos2"; + if (current === -1) return "tone-pos1"; + if (current === 1) return "tone-neg1"; + if (current >= 2) return "tone-neg2"; + return "tone-zero"; + } + if (current <= -2) return "tone-neg2"; + if (current === -1) return "tone-neg1"; + if (current === 1) return "tone-pos1"; + if (current >= 2) return "tone-pos2"; + return "tone-zero"; + }; + + const setHidden = (element, hidden) => { + if (!element) { + return; + } + + if (hidden) { + element.setAttribute("hidden", "hidden"); + } else { + element.removeAttribute("hidden"); + } + }; + + const setOverlay = (overlay, open) => { + if (!overlay) { + return; + } + + setHidden(overlay, !open); + document.body.classList.toggle("is-dashboard-overlay-open", open); + + if (open) { + const focusTarget = overlay.querySelector("input, textarea, select, button"); + if (focusTarget instanceof HTMLElement) { + window.setTimeout(() => focusTarget.focus(), 10); + } + } + }; + + document.querySelectorAll("[data-stepper]").forEach(stepper => { + const input = stepper.querySelector("[data-stepper-input]"); + const value = stepper.querySelector("[data-stepper-value]"); + const minus = stepper.querySelector("[data-stepper-minus]"); + const plus = stepper.querySelector("[data-stepper-plus]"); + + if (!input || !value || !minus || !plus) { + return; + } + + const render = () => { + const current = Math.max(-2, Math.min(2, Number(input.value || 0))); + const metric = stepper.dataset.stepperMetric || "mood"; + input.value = String(current); + value.textContent = `${current > 0 ? "+" : ""}${current}`; + minus.disabled = current <= -2; + plus.disabled = current >= 2; + const ring = stepper.querySelector(".overlay-signal-card__ring"); + if (ring) { + ring.classList.remove("tone-neg2", "tone-neg1", "tone-zero", "tone-pos1", "tone-pos2"); + ring.classList.add(toneClass(current, metric)); + } + }; + + minus.addEventListener("click", () => { + input.value = String(Number(input.value || 0) - 1); + render(); + }); + + plus.addEventListener("click", () => { + input.value = String(Number(input.value || 0) + 1); + render(); + }); + + render(); + }); + + if (openSummary) { + openSummary.addEventListener("click", event => { + event.preventDefault(); + setOverlay(summaryOverlay, true); + }); + } + + closeSummary.forEach(button => { + button.addEventListener("click", event => { + event.preventDefault(); + setOverlay(summaryOverlay, false); + }); + }); + + if (openSettingsMenu) { + openSettingsMenu.addEventListener("click", event => { + event.preventDefault(); + setOverlay(settingsMenuOverlay, true); + }); + } + + closeSettingsMenu.forEach(button => { + button.addEventListener("click", event => { + event.preventDefault(); + setOverlay(settingsMenuOverlay, false); + }); + }); + + const setMomentEditMode = payload => { + if (!formNameInput || !eventIdInput) { + return; + } + + if (payload) { + formNameInput.value = "update_event"; + eventIdInput.value = payload.id || ""; + if (deleteForm && deleteIdInput) { + deleteIdInput.value = payload.id || ""; + setHidden(deleteForm, false); + } + return; + } + + formNameInput.value = "add_event"; + eventIdInput.value = ""; + if (deleteForm && deleteIdInput) { + deleteIdInput.value = ""; + setHidden(deleteForm, true); + } + }; + + const showMomentChoose = () => { + setMomentEditMode(null); + setHidden(chooseStep, false); + setHidden(formStep, true); + document.querySelectorAll("[data-sport-choice]").forEach(item => item.classList.remove("is-selected")); + if (momentSubmit) { + momentSubmit.disabled = true; + } + }; + + const showMomentForm = type => { + const config = stepperConfigs[type] || stepperConfigs.event; + if (typeInput) typeInput.value = type; + if (typeLabel) typeLabel.textContent = config.label; + if (valueLabel) valueLabel.textContent = config.valueLabel || "Wert"; + if (valueInput) { + valueInput.placeholder = config.placeholder; + valueInput.required = !!config.showValue; + valueInput.value = config.showValue ? valueInput.value : ""; + valueInput.step = type === "sleep" ? "0.25" : "1"; + } + if (unitInput) { + unitInput.value = config.unit; + } + if (walkModeInput) { + walkModeInput.value = walkMode; + } + if (momentComment) { + momentComment.placeholder = config.commentPlaceholder; + momentComment.required = type !== "alcohol"; + } + + if (sportField) setHidden(sportField, !config.showSport); + if (alcoholField) setHidden(alcoholField, !config.showAlcohol); + if (valueField) setHidden(valueField, !config.showValue); + if (walkField) setHidden(walkField, !config.showWalk); + if (momentSubmit) { + momentSubmit.disabled = false; + } + + if (config.showWalk) { + document.querySelectorAll('input[name="event_walk_mode"]').forEach(radio => { + radio.checked = radio.value === walkMode; + }); + } + + setHidden(chooseStep, true); + setHidden(formStep, false); + }; + + const populateMomentForm = payload => { + showMomentForm(payload.type || "event"); + setMomentEditMode(payload); + + const form = document.querySelector("#moment-form"); + if (!form) { + return; + } + + const setValue = (selector, value) => { + const field = form.querySelector(selector); + if (field) { + field.value = value; + } + }; + + setValue('input[name="event_time"]', payload.time || ""); + setValue('[name="event_comment"]', payload.comment || ""); + setValue('[name="event_value"]', payload.value || ""); + setValue('[name="event_sport_type_id"]', payload.sport_type_id || ""); + setValue('[name="event_unit"]', payload.unit || ""); + setValue('[name="event_walk_mode"]', payload.unit === "steps" ? "steps" : "time"); + setValue('[name="event_mood"]', payload.mood ?? 0); + setValue('[name="event_energy"]', payload.energy ?? 0); + setValue('[name="event_stress"]', payload.stress ?? 0); + + const consumedYes = form.querySelector('input[name="event_consumed"][value="1"]'); + const consumedNo = form.querySelector('input[name="event_consumed"][value="0"]'); + if (consumedYes && consumedNo) { + if (payload.consumed) { + consumedYes.checked = true; + } else { + consumedNo.checked = true; + } + } + + form.querySelectorAll('input[name="event_walk_mode"]').forEach(radio => { + radio.checked = radio.value === (payload.unit === "steps" ? "steps" : "time"); + }); + + form.querySelectorAll("[data-stepper]").forEach(stepper => { + const input = stepper.querySelector("[data-stepper-input]"); + const value = stepper.querySelector("[data-stepper-value]"); + const minus = stepper.querySelector("[data-stepper-minus]"); + const plus = stepper.querySelector("[data-stepper-plus]"); + if (!input || !value || !minus || !plus) return; + const current = Math.max(-2, Math.min(2, Number(input.value || 0))); + const metric = stepper.dataset.stepperMetric || "mood"; + input.value = String(current); + value.textContent = `${current > 0 ? "+" : ""}${current}`; + minus.disabled = current <= -2; + plus.disabled = current >= 2; + const ring = stepper.querySelector(".overlay-signal-card__ring"); + if (ring) { + ring.classList.remove("tone-neg2", "tone-neg1", "tone-zero", "tone-pos1", "tone-pos2"); + ring.classList.add(toneClass(current, metric)); + } + }); + + form.querySelectorAll("[data-sport-choice]").forEach(button => { + button.classList.toggle("is-selected", button.dataset.sportChoice === (payload.sport_type_id || "")); + }); + }; + + if (openMoment) { + openMoment.addEventListener("click", event => { + event.preventDefault(); + showMomentChoose(); + setOverlay(momentOverlay, true); + }); + } + + closeMoment.forEach(button => { + button.addEventListener("click", event => { + event.preventDefault(); + setOverlay(momentOverlay, false); + showMomentChoose(); + }); + }); + + document.querySelectorAll("[data-moment-type-choice]").forEach(button => { + button.addEventListener("click", event => { + event.preventDefault(); + showMomentForm(button.dataset.momentTypeChoice || "event"); + }); + }); + + if (backButton) { + backButton.addEventListener("click", event => { + event.preventDefault(); + showMomentChoose(); + }); + } + + document.querySelectorAll("[data-sport-choice]").forEach(button => { + button.addEventListener("click", event => { + event.preventDefault(); + const hidden = document.querySelector('input[name="event_sport_type_id"]'); + if (!hidden) { + return; + } + + hidden.value = button.dataset.sportChoice || ""; + document.querySelectorAll("[data-sport-choice]").forEach(item => item.classList.remove("is-selected")); + button.classList.add("is-selected"); + }); + }); + + document.querySelectorAll('input[name="event_walk_mode"]').forEach(radio => { + radio.addEventListener("change", () => { + if (!valueInput || !valueLabel || !unitInput || !walkModeInput) { + return; + } + + const mode = radio.checked ? radio.value : walkModeInput.value; + walkModeInput.value = mode; + unitInput.value = mode === "steps" ? "steps" : "min"; + valueLabel.textContent = mode === "steps" ? "Schritte" : "Dauer"; + valueInput.placeholder = mode === "steps" ? "Schritte" : "Minuten"; + valueInput.step = "1"; + }); + }); + + document.querySelectorAll("[data-confirm-delete]").forEach(button => { + button.addEventListener("click", event => { + if (!window.confirm("Diesen Moment wirklich löschen?")) { + event.preventDefault(); + event.stopPropagation(); + } + }); + }); + + document.querySelectorAll("[data-event-editable]").forEach(card => { + card.addEventListener("click", event => { + if (event.target.closest("form") || event.target.closest("button")) { + return; + } + + const payload = decodePayload(card.dataset.eventPayload || ""); + if (!payload) { + return; + } + + populateMomentForm(payload); + setOverlay(momentOverlay, true); + }); + }); + + if (typeSelect && unitInput) { + const syncUnit = () => { + const option = typeSelect.options[typeSelect.selectedIndex]; + unitInput.value = option?.dataset.defaultUnit || ""; + }; + + syncUnit(); + typeSelect.addEventListener("change", syncUnit); + } + + if (swipeContainer) { + let pointerStartX = 0; + let pointerStartY = 0; + let dragging = false; + + const handleSwipe = (deltaX, deltaY) => { + if (Math.abs(deltaX) < 70 || Math.abs(deltaY) > 50) { + return; + } + + if (deltaX < 0 && swipeContainer.dataset.nextDate) { + window.location.href = dashboardDayPath(swipeContainer.dataset.nextDate); + } else if (deltaX > 0 && swipeContainer.dataset.prevDate) { + window.location.href = dashboardDayPath(swipeContainer.dataset.prevDate); + } + }; + + swipeContainer.addEventListener("pointerdown", event => { + dragging = true; + pointerStartX = event.clientX; + pointerStartY = event.clientY; + }); + + swipeContainer.addEventListener("pointerup", event => { + if (!dragging) { + return; + } + + dragging = false; + handleSwipe(event.clientX - pointerStartX, event.clientY - pointerStartY); + }); + + swipeContainer.addEventListener("pointercancel", () => { + dragging = false; + }); + + window.addEventListener("keydown", event => { + if (document.body.classList.contains("is-dashboard-overlay-open")) { + return; + } + + if (event.key === "ArrowLeft" && swipeContainer.dataset.prevDate) { + window.location.href = dashboardDayPath(swipeContainer.dataset.prevDate); + } + + if (event.key === "ArrowRight" && swipeContainer.dataset.nextDate) { + window.location.href = dashboardDayPath(swipeContainer.dataset.nextDate); + } + }); + } + } + + function initOptionsPanels() { + const overlay = document.querySelector("[data-options-overlay]"); + if (!overlay) { + return; + } + + if (overlay.parentElement !== document.body) { + document.body.appendChild(overlay); + } + + const panels = [...overlay.querySelectorAll("[data-options-panel]")]; + const closeButtons = [...overlay.querySelectorAll("[data-options-close]")]; + const backButtons = [...overlay.querySelectorAll("[data-options-back]")]; + const initialPanel = overlay.dataset.openPanel || null; + + const setOpen = (panelName) => { + overlay.hidden = panelName === null; + document.body.classList.toggle("is-dashboard-overlay-open", panelName !== null); + + panels.forEach(panel => { + panel.hidden = panel.dataset.optionsPanel !== panelName; + }); + + if (panelName === "stats") { + window.setTimeout(() => { + initDashboardCharts(); + }, 40); + } + }; + + document.querySelectorAll("[data-options-open]").forEach(button => { + button.addEventListener("click", event => { + event.preventDefault(); + setOpen(button.dataset.optionsOpen || null); + }); + }); + + closeButtons.forEach(button => { + button.addEventListener("click", event => { + event.preventDefault(); + setOpen(null); + }); + }); + + backButtons.forEach(button => { + button.addEventListener("click", event => { + event.preventDefault(); + setOpen(null); + }); + }); + + if (initialPanel) { + setOpen(initialPanel); + } + } + function csrfToken() { return document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") || ""; } @@ -1300,6 +1830,8 @@ initTrackPreview(); initArchiveMobileDetail(); initDashboardCharts(); + initDashboardExperience(); + initOptionsPanels(); initSportTypeManager(); initPwaShell(); initPullToRefresh(); diff --git a/src/App.php b/src/App.php index ac4086e..bc27caa 100644 --- a/src/App.php +++ b/src/App.php @@ -87,7 +87,11 @@ final class App redirect('/login'); case '/': - $this->showDashboard(); + $method === 'POST' ? $this->handleDashboard() : $this->showDashboard(); + return; + + case '/day-image': + $this->serveDayImage(); return; case '/track': @@ -232,21 +236,584 @@ final class App $settings = $this->hydrateSettings($this->settings->forUser($user['username'])); $entries = $this->entries->all($user['username']); $evaluatedEntries = $this->evaluateEntriesWithContext($entries, $settings); + $evaluatedEntries = array_map( + fn (array $entry): array => $this->withDashboardImageState($user['username'], $entry), + $evaluatedEntries + ); + $dashboardView = $this->normalizeDashboardView((string) ($_GET['view'] ?? 'day')); + $dashboardDate = (string) ($_GET['date'] ?? today()); + + if (!$this->isValidDate($dashboardDate)) { + $dashboardDate = today(); + } + + $entryMap = []; + foreach ($evaluatedEntries as $entry) { + $entryMap[(string) ($entry['date'] ?? '')] = $entry; + } + + $selectedEntry = $entryMap[$dashboardDate] ?? $this->withDashboardImageState($user['username'], $this->buildEmptyDashboardEntry($dashboardDate, $settings, $entryMap[shift_date($dashboardDate, -1)] ?? null)); $summary = $this->buildDashboardSummary($evaluatedEntries); $chartData = $this->buildDashboardCharts($evaluatedEntries); View::render('dashboard', [ - 'pageTitle' => 'Dashboard', + 'pageTitle' => 'Mood', 'page' => 'dashboard', + 'pageBodyClass' => 'page-dashboard-immersive', 'authUser' => $user, 'settings' => $settings, 'summary' => $summary, 'entries' => array_reverse($evaluatedEntries), 'chartPayload' => encode_payload($chartData), + 'dashboardView' => $dashboardView, + 'dashboardDate' => $dashboardDate, + 'dayEntry' => $selectedEntry, + 'dashboardEventTypes' => day_event_type_options(), + 'dashboardSignals' => signal_scale_options(), + 'dashboardTimeline' => $this->buildDashboardTimeline($selectedEntry), + 'dashboardCompareDays' => $this->buildDashboardCompareDays($dashboardDate, $entryMap, $settings), + 'dashboardWeek' => $this->buildDashboardWeekView($dashboardDate, $entryMap), + 'dashboardMonth' => $this->buildDashboardMonthView($dashboardDate, $entryMap), + 'dashboardPrevDate' => shift_date($dashboardDate, -1), + 'dashboardNextDate' => shift_date($dashboardDate, 1), + 'dashboardSportTypes' => normalized_sport_types($settings), + 'dashboardWalkMode' => (string) ($settings['walk']['mode'] ?? 'time'), + 'dashboardHasContent' => $this->entryHasContent($selectedEntry, isset($entryMap[$dashboardDate])), + 'dashboardVisualScore' => $this->dashboardVisualScore($selectedEntry, isset($entryMap[$dashboardDate])), + 'dashboardLineLevel' => $this->dashboardLineLevel($selectedEntry, isset($entryMap[$dashboardDate])), + 'dashboardLineTone' => $this->dashboardLineTone($selectedEntry, isset($entryMap[$dashboardDate])), ]); } + private function handleDashboard(): void + { + $this->enforceCsrf(); + + $user = $this->requireUser(); + $settings = $this->hydrateSettings($this->settings->forUser($user['username'])); + $form = (string) ($_POST['form_name'] ?? ''); + $date = (string) ($_POST['date'] ?? today()); + + if (!$this->isValidDate($date)) { + flash('error', 'Bitte wähle einen gültigen Tag.'); + redirect('/'); + } + + $entries = $this->entries->all($user['username']); + $entryMap = []; + foreach ($entries as $existingEntry) { + $entryMap[(string) ($existingEntry['date'] ?? '')] = $existingEntry; + } + + $current = $entryMap[$date] ?? $this->buildEmptyDashboardEntry($date, $settings, $entryMap[shift_date($date, -1)] ?? null); + + try { + if ($form === 'save_day_summary') { + $current['summary'] = [ + 'comment' => trim((string) ($_POST['summary_comment'] ?? '')), + 'mood' => normalize_signal_value($_POST['summary_mood'] ?? 0), + 'energy' => normalize_signal_value($_POST['summary_energy'] ?? 0), + 'stress' => normalize_signal_value($_POST['summary_stress'] ?? 0), + 'alcohol' => isset($_POST['summary_alcohol']) && (string) $_POST['summary_alcohol'] === '1', + ]; + $current['summary_comment'] = $current['summary']['comment']; + $current['summary_mood'] = $current['summary']['mood']; + $current['summary_energy'] = $current['summary']['energy']; + $current['summary_stress'] = $current['summary']['stress']; + $current['summary_alcohol'] = !empty($current['summary']['alcohol']); + $current['note'] = $current['summary']['comment']; + $current['alcohol'] = !empty($current['summary']['alcohol']); + + $upload = uploaded_files('background_image')[0] ?? null; + if (is_array($upload) && (int) ($upload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) { + $this->deleteDashboardBackgroundImage($user['username'], $date, (string) ($current['background_image'] ?? '')); + $current['background_image'] = $this->storeDashboardBackgroundImage($user['username'], $date, $upload); + } + + $entryMap[$date] = $current; + $this->persistUserEntries($user['username'], $settings, array_values($entryMap)); + flash('success', 'Die Tagesbilanz wurde gespeichert.'); + redirect('/?view=day&date=' . rawurlencode($date)); + } + + if ($form === 'add_event') { + $event = $this->dashboardEventFromPost($_POST); + $events = is_array($current['events'] ?? null) ? $current['events'] : []; + $events[] = $event; + $current['events'] = $events; + $entryMap[$date] = $current; + $this->persistUserEntries($user['username'], $settings, array_values($entryMap)); + flash('success', 'Die Aktivität wurde hinzugefügt.'); + redirect('/?view=day&date=' . rawurlencode($date)); + } + + if ($form === 'update_event') { + $eventID = trim((string) ($_POST['event_id'] ?? '')); + $updatedEvent = $this->dashboardEventFromPost($_POST); + $updatedEvent['id'] = $eventID !== '' ? $eventID : $updatedEvent['id']; + $events = []; + + foreach (is_array($current['events'] ?? null) ? $current['events'] : [] as $event) { + if (!is_array($event)) { + continue; + } + + if ((string) ($event['id'] ?? '') === $eventID) { + $events[] = $updatedEvent; + continue; + } + + $events[] = $event; + } + + $current['events'] = $events; + $entryMap[$date] = $current; + $this->persistUserEntries($user['username'], $settings, array_values($entryMap)); + flash('success', 'Der Moment wurde aktualisiert.'); + redirect('/?view=day&date=' . rawurlencode($date)); + } + + if ($form === 'delete_event') { + $eventID = trim((string) ($_POST['event_id'] ?? '')); + $current['events'] = array_values(array_filter( + is_array($current['events'] ?? null) ? $current['events'] : [], + static fn (array $event): bool => (string) ($event['id'] ?? '') !== $eventID + )); + $entryMap[$date] = $current; + $this->persistUserEntries($user['username'], $settings, array_values($entryMap)); + flash('success', 'Die Aktivität wurde entfernt.'); + redirect('/?view=day&date=' . rawurlencode($date)); + } + + if ($form === 'remove_background') { + $this->deleteDashboardBackgroundImage($user['username'], $date, (string) ($current['background_image'] ?? '')); + $current['background_image'] = ''; + $entryMap[$date] = $current; + $this->persistUserEntries($user['username'], $settings, array_values($entryMap)); + flash('success', 'Das Tagesbild wurde entfernt.'); + redirect('/?view=day&date=' . rawurlencode($date)); + } + } catch (RuntimeException $exception) { + flash('error', $exception->getMessage()); + redirect('/?view=day&date=' . rawurlencode($date)); + } + + redirect('/?view=day&date=' . rawurlencode($date)); + } + + private function normalizeDashboardView(string $view): string + { + return in_array($view, ['day', 'week', 'month'], true) ? $view : 'day'; + } + + private function buildEmptyDashboardEntry(string $date, array $settings, ?array $previousEntry = null): array + { + $entry = $this->scoring->normalize([ + 'date' => $date, + 'pain_enabled' => !empty($settings['tracking']['pain_enabled']), + 'walk_mode' => (string) ($settings['walk']['mode'] ?? 'time'), + 'summary' => [ + 'comment' => '', + 'mood' => 0, + 'energy' => 0, + 'stress' => 0, + 'alcohol' => false, + ], + 'events' => [], + 'background_image' => '', + ]); + + return array_merge($entry, [ + 'evaluation' => $this->scoring->evaluate($entry, $settings, $previousEntry), + 'sport_type_meta' => [], + ]); + } + + private function buildDashboardTimeline(array $entry): array + { + $timeline = []; + + foreach (is_array($entry['events'] ?? null) ? $entry['events'] : [] as $event) { + if (!is_array($event)) { + continue; + } + + $timeline[] = [ + 'kind' => 'event', + 'id' => (string) ($event['id'] ?? ''), + 'type' => (string) ($event['type'] ?? 'event'), + 'time' => (string) ($event['time'] ?? ''), + 'comment' => (string) ($event['comment'] ?? ''), + 'value' => (float) ($event['value'] ?? 0), + 'unit' => (string) ($event['unit'] ?? ''), + 'sport_type_id' => (string) ($event['sport_type_id'] ?? ''), + 'consumed' => !empty($event['consumed']), + 'mood' => normalize_signal_value($event['mood'] ?? 0), + 'energy' => normalize_signal_value($event['energy'] ?? 0), + 'stress' => normalize_signal_value($event['stress'] ?? 0), + ]; + } + + return $timeline; + } + + private function buildDashboardCompareDays(string $date, array $entryMap, array $settings): array + { + $days = []; + + for ($offset = -3; $offset <= 1; $offset++) { + $dayDate = shift_date($date, $offset); + $entry = $entryMap[$dayDate] ?? $this->buildEmptyDashboardEntry($dayDate, $settings, $entryMap[shift_date($dayDate, -1)] ?? null); + $isPersisted = isset($entryMap[$dayDate]); + $hasContent = $isPersisted || $this->entryHasContent($entry); + $visualScore = $this->dashboardVisualScore($entry, $isPersisted); + + $days[] = [ + 'date' => $dayDate, + 'short' => (new DateTimeImmutable($dayDate))->format('D'), + 'day' => format_compact_date($dayDate), + 'offset' => $offset, + 'is_current' => $dayDate === $date, + 'has_content' => $hasContent, + 'visual_score' => $visualScore, + 'score_level' => $visualScore, + 'line_level' => $this->dashboardLineLevel($entry, $isPersisted), + 'line_tone' => $visualScore === null ? 'empty' : signal_value_class($visualScore), + ]; + } + + return $days; + } + + private function buildDashboardWeekView(string $date, array $entryMap): array + { + $current = new DateTimeImmutable($date); + $selectedStart = $current->modify('monday this week'); + $selectedKey = $selectedStart->format('Y-m-d'); + $currentStart = (new DateTimeImmutable(today()))->modify('monday this week'); + $currentKey = $currentStart->format('Y-m-d'); + $weekKeys = [$currentKey => true, $selectedKey => true]; + + foreach (array_keys($entryMap) as $entryDate) { + if (!$this->isValidDate((string) $entryDate)) { + continue; + } + + $weekKeys[(new DateTimeImmutable((string) $entryDate))->modify('monday this week')->format('Y-m-d')] = true; + } + + unset($weekKeys[$currentKey]); + $otherWeekKeys = array_keys($weekKeys); + rsort($otherWeekKeys, SORT_STRING); + $orderedWeekKeys = array_merge([$currentKey], $otherWeekKeys); + + $periods = []; + foreach ($orderedWeekKeys as $weekKey) { + $periods[] = $this->buildDashboardWeekPeriod(new DateTimeImmutable((string) $weekKey), $date, $entryMap, $weekKey === $selectedKey); + } + + $selectedPeriod = $this->buildDashboardWeekPeriod($selectedStart, $date, $entryMap, true); + + return array_merge($selectedPeriod, [ + 'periods' => $periods, + ]); + } + + private function buildDashboardWeekPeriod(DateTimeImmutable $start, string $selectedDate, array $entryMap, bool $isSelected): array + { + $days = []; + + for ($index = 0; $index < 7; $index++) { + $day = $start->modify('+' . $index . ' day'); + $iso = $day->format('Y-m-d'); + $entry = $entryMap[$iso] ?? null; + $hasContent = $entry !== null && $this->entryHasContent($entry); + $visualScore = $entry !== null ? $this->dashboardVisualScore($entry, true) : null; + + $days[] = [ + 'date' => $iso, + 'weekday' => format_display_date($iso, true), + 'short' => $day->format('D'), + 'day' => $day->format('j'), + 'entry' => $entry, + 'has_content' => $hasContent, + 'score_level' => $visualScore, + 'line_tone' => $visualScore === null ? 'empty' : signal_value_class($visualScore), + 'is_current' => $iso === $selectedDate, + ]; + } + + return [ + 'key' => $start->format('Y-m-d'), + 'title' => 'Woche ' . $start->format('W'), + 'range' => $start->format('j.n.Y') . ' - ' . $start->modify('+6 day')->format('j.n.Y'), + 'is_selected' => $isSelected, + 'days' => $days, + ]; + } + + private function buildDashboardMonthView(string $date, array $entryMap): array + { + $current = new DateTimeImmutable($date); + $selectedStart = $current->modify('first day of this month'); + $selectedKey = $selectedStart->format('Y-m-d'); + $currentStart = (new DateTimeImmutable(today()))->modify('first day of this month'); + $currentKey = $currentStart->format('Y-m-d'); + $monthKeys = [$currentKey => true, $selectedKey => true]; + + foreach (array_keys($entryMap) as $entryDate) { + if (!$this->isValidDate((string) $entryDate)) { + continue; + } + + $monthKeys[(new DateTimeImmutable((string) $entryDate))->modify('first day of this month')->format('Y-m-d')] = true; + } + + unset($monthKeys[$currentKey]); + $otherMonthKeys = array_keys($monthKeys); + rsort($otherMonthKeys, SORT_STRING); + $orderedMonthKeys = array_merge([$currentKey], $otherMonthKeys); + + $periods = []; + foreach ($orderedMonthKeys as $monthKey) { + $periods[] = $this->buildDashboardMonthPeriod(new DateTimeImmutable((string) $monthKey), $date, $entryMap, $monthKey === $selectedKey); + } + + $selectedPeriod = $this->buildDashboardMonthPeriod($selectedStart, $date, $entryMap, true); + + return array_merge($selectedPeriod, [ + 'periods' => $periods, + ]); + } + + private function buildDashboardMonthPeriod(DateTimeImmutable $start, string $selectedDate, array $entryMap, bool $isSelected): array + { + $end = $start->modify('last day of this month'); + $days = []; + + for ($day = $start; $day <= $end; $day = $day->modify('+1 day')) { + $iso = $day->format('Y-m-d'); + $entry = $entryMap[$iso] ?? null; + $hasContent = $entry !== null && $this->entryHasContent($entry); + $visualScore = $entry !== null ? $this->dashboardVisualScore($entry, true) : null; + $days[] = [ + 'date' => $iso, + 'day' => $day->format('j'), + 'weekday' => format_display_date($iso, true), + 'entry' => $entry, + 'has_content' => $hasContent, + 'score_level' => $visualScore, + 'line_tone' => $visualScore === null ? 'empty' : signal_value_class($visualScore), + 'is_future' => $iso > $selectedDate, + ]; + } + + return [ + 'key' => $start->format('Y-m-d'), + 'title' => month_label($start->format('Y-m')), + 'is_selected' => $isSelected, + 'days' => $days, + ]; + } + + private function entryHasContent(array $entry, bool $isPersisted = false): bool + { + if ($isPersisted) { + return true; + } + + if (trim((string) ($entry['summary']['comment'] ?? '')) !== '') { + return true; + } + + if (!empty($entry['summary']['alcohol'] ?? $entry['summary_alcohol'] ?? false)) { + return true; + } + + if (trim((string) ($entry['background_image'] ?? '')) !== '') { + return true; + } + + return count(array_filter(is_array($entry['events'] ?? null) ? $entry['events'] : [], 'is_array')) > 0; + } + + private function dashboardVisualScore(array $entry, bool $isPersisted = false): ?int + { + if (!$this->entryHasContent($entry, $isPersisted)) { + return null; + } + + $summary = is_array($entry['summary'] ?? null) ? $entry['summary'] : []; + $mood = normalize_signal_value($summary['mood'] ?? $entry['summary_mood'] ?? legacy_to_signal_scale($entry['mood'] ?? 5)); + $energy = normalize_signal_value($summary['energy'] ?? $entry['summary_energy'] ?? legacy_to_signal_scale($entry['energy'] ?? 5)); + $stress = normalize_signal_value($summary['stress'] ?? $entry['summary_stress'] ?? legacy_to_signal_scale($entry['stress'] ?? 5)); + + return signal_combo_score($mood, $energy, $stress); + } + + private function dashboardLineLevel(array $entry, bool $isPersisted = false): ?int + { + if (!$this->entryHasContent($entry, $isPersisted)) { + return null; + } + + $percentage = max(0.0, min(100.0, (float) ($entry['evaluation']['percentage'] ?? 0))); + + return (int) round($percentage / 5); + } + + private function dashboardLineTone(array $entry, bool $isPersisted = false): string + { + return signal_value_class($this->dashboardVisualScore($entry, $isPersisted) ?? 0); + } + + private function dashboardEventFromPost(array $input): array + { + $type = trim((string) ($input['event_type'] ?? 'event')); + if (!array_key_exists($type, day_event_type_options())) { + $type = 'event'; + } + + $time = trim((string) ($input['event_time'] ?? '')); + if (!$this->isValidTime($time)) { + $time = date('H:i'); + } + + $comment = trim((string) ($input['event_comment'] ?? '')); + + $value = max(0, min(50000, (float) ($input['event_value'] ?? 0))); + if (in_array($type, ['walk', 'sport', 'sleep'], true) && $value <= 0) { + throw new RuntimeException('Für diese Aktivität braucht es einen Wert oder eine Dauer.'); + } + + $sportTypeID = trim((string) ($input['event_sport_type_id'] ?? '')); + if ($type === 'sport' && $sportTypeID === '') { + throw new RuntimeException('Bitte wähle eine Sportart.'); + } + + $unit = trim((string) ($input['event_unit'] ?? day_event_type_unit($type))); + if ($type === 'walk') { + $walkMode = trim((string) ($input['event_walk_mode'] ?? 'time')); + $unit = $walkMode === 'steps' ? 'steps' : 'min'; + } + + return [ + 'id' => 'evt-' . substr(sha1((string) microtime(true) . $type . $time . (string) ($input['event_comment'] ?? '')), 0, 12), + 'type' => $type, + 'time' => $time, + 'comment' => $comment, + 'value' => $value, + 'unit' => $unit, + 'sport_type_id' => $sportTypeID, + 'consumed' => isset($input['event_consumed']) ? ((string) $input['event_consumed'] === '1') : true, + 'mood' => normalize_signal_value($input['event_mood'] ?? 0), + 'energy' => normalize_signal_value($input['event_energy'] ?? 0), + 'stress' => normalize_signal_value($input['event_stress'] ?? 0), + ]; + } + + private function dashboardMediaDirectory(string $username): string + { + return storage_path('users/' . normalize_username($username) . '/media'); + } + + private function withDashboardImageState(string $username, array $entry): array + { + $fileName = trim((string) ($entry['background_image'] ?? '')); + $date = (string) ($entry['date'] ?? ''); + + $entry['background_image_url'] = null; + if ($fileName === '' || !$this->isValidDate($date)) { + return $entry; + } + + $path = $this->dashboardMediaDirectory($username) . '/' . basename($fileName); + if (is_file($path)) { + $entry['background_image_url'] = '/day-image?date=' . rawurlencode($date); + } + + return $entry; + } + + private function storeDashboardBackgroundImage(string $username, string $date, array $upload): string + { + $error = (int) ($upload['error'] ?? UPLOAD_ERR_NO_FILE); + if ($error !== UPLOAD_ERR_OK) { + throw new RuntimeException('Das Tagesbild konnte nicht hochgeladen werden.'); + } + + $tmpName = (string) ($upload['tmp_name'] ?? ''); + if ($tmpName === '' || !is_uploaded_file($tmpName)) { + throw new RuntimeException('Die hochgeladene Bilddatei ist ungültig.'); + } + + $mime = mime_content_type($tmpName) ?: ''; + $extension = match ($mime) { + 'image/jpeg' => 'jpg', + 'image/png' => 'png', + 'image/webp' => 'webp', + default => '', + }; + + if ($extension === '') { + throw new RuntimeException('Bitte nutze JPG, PNG oder WebP als Tagesbild.'); + } + + $directory = $this->dashboardMediaDirectory($username); + if (!is_dir($directory)) { + mkdir($directory, 0775, true); + } + + $fileName = $date . '-' . substr(sha1((string) microtime(true) . $username), 0, 10) . '.' . $extension; + $target = $directory . '/' . $fileName; + + if (!move_uploaded_file($tmpName, $target)) { + throw new RuntimeException('Das Tagesbild konnte nicht gespeichert werden.'); + } + + return $fileName; + } + + private function deleteDashboardBackgroundImage(string $username, string $date, string $fileName): void + { + $path = $this->dashboardMediaDirectory($username) . '/' . basename($fileName); + if (is_file($path)) { + @unlink($path); + } + } + + private function serveDayImage(): void + { + $user = $this->requireUser(); + $date = (string) ($_GET['date'] ?? ''); + + if (!$this->isValidDate($date)) { + http_response_code(404); + exit('Nicht gefunden'); + } + + $entry = $this->entries->find($user['username'], $date); + $fileName = trim((string) ($entry['background_image'] ?? '')); + if ($fileName === '') { + http_response_code(404); + exit('Nicht gefunden'); + } + + $path = $this->dashboardMediaDirectory($user['username']) . '/' . basename($fileName); + if (!is_file($path)) { + http_response_code(404); + exit('Nicht gefunden'); + } + + $mime = mime_content_type($path) ?: 'application/octet-stream'; + header('Content-Type: ' . $mime); + header('Content-Length: ' . (string) filesize($path)); + header('Cache-Control: private, max-age=3600'); + readfile($path); + exit; + } + private function showTrack(): void { $user = $this->requireUser(); @@ -507,6 +1074,7 @@ final class App { $user = $this->requireUser(); $settings = $this->hydrateSettings($this->settings->forUser($user['username'])); + $evaluatedEntries = $this->evaluateEntriesWithContext($this->entries->all($user['username']), $settings); $sportTypePresets = array_values(array_filter( Defaults::settings()['sport_types'], static function (array $preset) use ($settings): bool { @@ -534,6 +1102,7 @@ final class App 'pageTitle' => 'Optionen', 'page' => 'options', 'authUser' => $user, + 'optionsOpenPanel' => trim((string) ($_GET['panel'] ?? '')), 'settings' => $settings, 'sportTypePresets' => $sportTypePresets, 'sportLocationOptions' => sport_location_options(), @@ -545,6 +1114,8 @@ final class App 'aiConfig' => $user['is_admin'] ? $this->aiConfig->get() : null, 'aiStatus' => $user['is_admin'] ? $this->openAi->configuration() : null, 'users' => $user['is_admin'] ? $this->users->all() : [], + 'statsSummary' => $this->buildDashboardSummary($evaluatedEntries), + 'statsChartPayload' => encode_payload($this->buildDashboardCharts($evaluatedEntries)), 'maxScore' => $this->scoring->evaluate([ 'mood' => 10, 'energy' => 10, diff --git a/src/Domain/EntryRepository.php b/src/Domain/EntryRepository.php index 77ec0a6..091486e 100644 --- a/src/Domain/EntryRepository.php +++ b/src/Domain/EntryRepository.php @@ -96,6 +96,10 @@ final class EntryRepository private function parse(string $content, string $fallbackDate): ?array { + if (str_contains($content, '')) { + return $this->parseV3($content, $fallbackDate); + } + $sportTypes = []; $sportTypesRaw = (string) ($this->extract('/^- Sportarten:\s*(.*)$/mu', $content) ?? ''); if ($sportTypesRaw !== '') { @@ -134,6 +138,19 @@ final class EntryRepository 'walk_steps' => $walkMode === 'steps' ? $walkValue : 0, 'alcohol' => in_array($alcoholRaw, ['ja', 'yes', 'true', '1'], true), 'note' => $this->extractNote($content), + 'summary' => [ + 'comment' => $this->extractNote($content), + 'mood' => legacy_to_signal_scale((int) ($this->extract('/^- Stimmung:\s*(.+)$/m', $content) ?? 5)), + 'energy' => legacy_to_signal_scale((int) ($this->extract('/^- Energie:\s*(.+)$/m', $content) ?? 5)), + 'stress' => legacy_to_signal_scale((int) ($this->extract('/^- Stress:\s*(.+)$/m', $content) ?? 5)), + 'alcohol' => in_array($alcoholRaw, ['ja', 'yes', 'true', '1'], true), + ], + 'summary_comment' => $this->extractNote($content), + 'summary_mood' => legacy_to_signal_scale((int) ($this->extract('/^- Stimmung:\s*(.+)$/m', $content) ?? 5)), + 'summary_energy' => legacy_to_signal_scale((int) ($this->extract('/^- Energie:\s*(.+)$/m', $content) ?? 5)), + 'summary_stress' => legacy_to_signal_scale((int) ($this->extract('/^- Stress:\s*(.+)$/m', $content) ?? 5)), + 'background_image' => '', + 'events' => [], ]; return $entry; @@ -163,18 +180,56 @@ final class EntryRepository private function toMarkdown(string $username, string $date, array $entry, array $evaluation): string { + $summary = is_array($entry['summary'] ?? null) ? $entry['summary'] : [ + 'comment' => (string) ($entry['summary_comment'] ?? $entry['note'] ?? ''), + 'mood' => normalize_signal_value($entry['summary_mood'] ?? legacy_to_signal_scale($entry['mood'] ?? 5)), + 'energy' => normalize_signal_value($entry['summary_energy'] ?? legacy_to_signal_scale($entry['energy'] ?? 5)), + 'stress' => normalize_signal_value($entry['summary_stress'] ?? legacy_to_signal_scale($entry['stress'] ?? 5)), + ]; + $events = is_array($entry['events'] ?? null) ? $entry['events'] : []; $sportTypes = $evaluation['sport_types'] ?? []; $sportTypeValues = array_map( static fn (array $type): string => (string) $type['label'] . ' [' . (string) $type['id'] . ']', array_filter($sportTypes, 'is_array') ); + $eventLines = []; + foreach ($events as $event) { + if (!is_array($event)) { + continue; + } + + $eventLines[] = '### ' . ((string) ($event['id'] ?? 'evt')); + $eventLines[] = '- Typ: ' . day_event_type_label((string) ($event['type'] ?? 'event')) . ' [' . (string) ($event['type'] ?? 'event') . ']'; + $eventLines[] = '- Uhrzeit: ' . (string) ($event['time'] ?? ''); + $eventLines[] = '- Wert: ' . (string) ($event['value'] ?? 0); + $eventLines[] = '- Einheit: ' . (string) ($event['unit'] ?? ''); + $eventLines[] = '- Sportart: ' . (string) ($event['sport_type_id'] ?? ''); + $eventLines[] = '- Getrunken: ' . (!empty($event['consumed']) ? 'ja' : 'nein'); + $eventLines[] = '- Kommentar: ' . (string) ($event['comment'] ?? ''); + $eventLines[] = '- Stimmung: ' . (string) ($event['mood'] ?? 0); + $eventLines[] = '- Energie: ' . (string) ($event['energy'] ?? 0); + $eventLines[] = '- Stress: ' . (string) ($event['stress'] ?? 0); + $eventLines[] = ''; + } + $lines = [ - '', - '# Stimmungstracker', + '', + '# Stimmungstracker Tag', 'Datum: ' . $date, 'Benutzer: ' . normalize_username($username), + 'Hintergrundbild: ' . trim((string) ($entry['background_image'] ?? '')), '', + '## Tagesbilanz', + '- Kommentar: ' . trim((string) ($summary['comment'] ?? '')), + '- Stimmung: ' . normalize_signal_value($summary['mood'] ?? 0), + '- Energie: ' . normalize_signal_value($summary['energy'] ?? 0), + '- Stress: ' . normalize_signal_value($summary['stress'] ?? 0), + '- Alkohol: ' . (!empty($summary['alcohol']) ? 'ja' : 'nein'), + '', + '## Ereignisse', + ...($eventLines !== [] ? $eventLines : ['- Keine Ereignisse', '']), + '## Tracking', '## Werte', '- Stimmung: ' . $entry['mood'], '- Energie: ' . $entry['energy'], @@ -202,14 +257,93 @@ final class EntryRepository '- Sport: ' . format_points((float) $evaluation['components']['sport_minutes']), '- Sportbonus: ' . format_points((float) ($evaluation['components']['sport_bonus'] ?? 0)), '- Spaziergang: ' . format_points((float) $evaluation['components']['walk_minutes']), + '- Momente: ' . format_points((float) ($evaluation['components']['events'] ?? 0)), '- Alkohol: ' . format_points((float) ($evaluation['components']['alcohol'] ?? 0)), '- Notiz: ' . format_points((float) $evaluation['components']['note']), '', '## Notiz', - trim((string) $entry['note']), + trim((string) ($summary['comment'] ?? $entry['note'] ?? '')), '', ]; return implode("\n", $lines); } + + private function parseV3(string $content, string $fallbackDate): ?array + { + $date = $this->extract('/^Datum:\s*(\d{4}-\d{2}-\d{2})$/m', $content) ?? $fallbackDate; + $backgroundImage = trim((string) ($this->extract('/^Hintergrundbild:[ \t]*([^\r\n]*)$/mu', $content) ?? '')); + if (preg_match('/^[A-Za-z0-9._-]+\.(?:jpe?g|png|webp)$/i', $backgroundImage) !== 1) { + $backgroundImage = ''; + } + $summarySection = $this->extractSection($content, '## Tagesbilanz', '## Ereignisse'); + $eventsSection = $this->extractSection($content, '## Ereignisse', '## Tracking'); + $legacySection = $this->extractSection($content, '## Werte', '## Bewertung'); + $legacyContent = $legacySection !== null ? "## Werte\n" . $legacySection : $content; + + $base = $this->parse(str_replace('', '', $legacyContent), $fallbackDate); + if ($base === null) { + return null; + } + + $summary = [ + 'comment' => (string) ($this->extract('/^- Kommentar:\s*(.*)$/mu', $summarySection ?? '') ?? ''), + 'mood' => normalize_signal_value($this->extract('/^- Stimmung:\s*(-?\d+)$/m', $summarySection ?? '') ?? 0), + 'energy' => normalize_signal_value($this->extract('/^- Energie:\s*(-?\d+)$/m', $summarySection ?? '') ?? 0), + 'stress' => normalize_signal_value($this->extract('/^- Stress:\s*(-?\d+)$/m', $summarySection ?? '') ?? 0), + 'alcohol' => in_array(strtolower((string) ($this->extract('/^- Alkohol:\s*(.*)$/mu', $summarySection ?? '') ?? 'nein')), ['ja', 'yes', 'true', '1'], true), + ]; + + $events = []; + foreach (preg_split('/^###\s+/m', trim((string) $eventsSection)) ?: [] as $chunk) { + $chunk = trim($chunk); + if ($chunk === '' || str_starts_with($chunk, '- Keine Ereignisse')) { + continue; + } + + $lines = preg_split('/\R/', $chunk) ?: []; + $id = trim((string) array_shift($lines)); + $block = implode("\n", $lines); + $typeLine = (string) ($this->extract('/^- Typ:\s*.*\[([^\]]+)\]$/mu', $block) ?? 'event'); + + $events[] = [ + 'id' => $id, + 'type' => $typeLine, + 'time' => (string) ($this->extract('/^- Uhrzeit:\s*(.*)$/mu', $block) ?? ''), + 'value' => (float) ($this->extract('/^- Wert:\s*(.*)$/mu', $block) ?? 0), + 'unit' => (string) ($this->extract('/^- Einheit:\s*(.*)$/mu', $block) ?? ''), + 'sport_type_id' => (string) ($this->extract('/^- Sportart:\s*(.*)$/mu', $block) ?? ''), + 'consumed' => in_array(strtolower((string) ($this->extract('/^- Getrunken:\s*(.*)$/mu', $block) ?? 'ja')), ['ja', 'yes', 'true', '1'], true), + 'comment' => (string) ($this->extract('/^- Kommentar:\s*(.*)$/mu', $block) ?? ''), + 'mood' => normalize_signal_value($this->extract('/^- Stimmung:\s*(-?\d+)$/m', $block) ?? 0), + 'energy' => normalize_signal_value($this->extract('/^- Energie:\s*(-?\d+)$/m', $block) ?? 0), + 'stress' => normalize_signal_value($this->extract('/^- Stress:\s*(-?\d+)$/m', $block) ?? 0), + ]; + } + + $base['date'] = $date; + $base['background_image'] = $backgroundImage; + $base['summary'] = $summary; + $base['summary_comment'] = $summary['comment']; + $base['summary_mood'] = $summary['mood']; + $base['summary_energy'] = $summary['energy']; + $base['summary_stress'] = $summary['stress']; + $base['summary_alcohol'] = !empty($summary['alcohol']); + $base['events'] = $events; + $base['alcohol'] = !empty($summary['alcohol']); + $base['note'] = $summary['comment']; + + return $base; + } + + private function extractSection(string $content, string $startHeading, string $endHeading): ?string + { + $pattern = '/' . preg_quote($startHeading, '/') . '\s*(.*?)\s*' . preg_quote($endHeading, '/') . '/su'; + + if (preg_match($pattern, $content, $matches) !== 1) { + return null; + } + + return trim((string) ($matches[1] ?? '')); + } } diff --git a/src/Domain/ScoringService.php b/src/Domain/ScoringService.php index 7497ac7..edd91a5 100644 --- a/src/Domain/ScoringService.php +++ b/src/Domain/ScoringService.php @@ -6,25 +6,47 @@ final class ScoringService { public function normalize(array $input): array { - $sportTypes = normalize_sport_type_selection($input['sport_types'] ?? ($input['sport_type'] ?? [])); + $hasSummaryInput = is_array($input['summary'] ?? null) + || array_key_exists('summary_mood', $input) + || array_key_exists('summary_energy', $input) + || array_key_exists('summary_stress', $input); + $hasEventInput = is_array($input['events'] ?? null) && $input['events'] !== []; + $summary = $this->normalizeSummary($input['summary'] ?? [ + 'comment' => $input['summary_comment'] ?? ($input['note'] ?? ''), + 'mood' => $input['summary_mood'] ?? legacy_to_signal_scale($input['mood'] ?? 5), + 'energy' => $input['summary_energy'] ?? legacy_to_signal_scale($input['energy'] ?? 5), + 'stress' => $input['summary_stress'] ?? legacy_to_signal_scale($input['stress'] ?? 5), + 'alcohol' => !empty($input['summary_alcohol'] ?? $input['alcohol'] ?? false), + ]); + $events = $this->normalizeEvents($input['events'] ?? []); + $derived = $this->deriveLegacyFieldsFromEvents($events, $summary, $input); + $sportTypes = normalize_sport_type_selection($hasEventInput ? ($derived['sport_types'] ?? []) : ($input['sport_types'] ?? ($derived['sport_types'] ?? ($input['sport_type'] ?? [])))); return [ 'date' => $input['date'] ?? today(), - 'mood' => max(1, min(10, (int) ($input['mood'] ?? 5))), - 'energy' => max(1, min(10, (int) ($input['energy'] ?? 5))), - 'stress' => max(1, min(10, (int) ($input['stress'] ?? 5))), + 'mood' => max(1, min(10, (int) ($hasSummaryInput ? $derived['mood'] : ($input['mood'] ?? $derived['mood'])))), + 'energy' => max(1, min(10, (int) ($hasSummaryInput ? $derived['energy'] : ($input['energy'] ?? $derived['energy'])))), + 'stress' => max(1, min(10, (int) ($hasSummaryInput ? $derived['stress'] : ($input['stress'] ?? $derived['stress'])))), 'pain' => max(1, min(10, (int) ($input['pain'] ?? 1))), 'pain_enabled' => $this->normalizeBoolean($input['pain_enabled'] ?? false), - 'sleep_hours' => max(0, min(24, (float) ($input['sleep_hours'] ?? 0))), - 'sleep_feeling' => max(1, min(5, (int) ($input['sleep_feeling'] ?? 3))), - 'sport_minutes' => max(0, min(1440, (int) ($input['sport_minutes'] ?? 0))), + 'sleep_hours' => max(0, min(24, (float) ($hasEventInput ? $derived['sleep_hours'] : ($input['sleep_hours'] ?? $derived['sleep_hours'])))), + 'sleep_feeling' => max(1, min(5, (int) ($hasSummaryInput ? $derived['sleep_feeling'] : ($input['sleep_feeling'] ?? $derived['sleep_feeling'])))), + 'sport_minutes' => max(0, min(1440, (int) ($hasEventInput ? $derived['sport_minutes'] : ($input['sport_minutes'] ?? $derived['sport_minutes'])))), 'sport_type' => $sportTypes[0] ?? '', 'sport_types' => $sportTypes, - 'walk_mode' => $this->normalizeWalkMode((string) ($input['walk_mode'] ?? ((isset($input['walk_steps']) && !isset($input['walk_minutes'])) ? 'steps' : 'time'))), - 'walk_minutes' => max(0, min(1440, (int) ($input['walk_minutes'] ?? 0))), - 'walk_steps' => max(0, min(50000, (int) ($input['walk_steps'] ?? 0))), - 'alcohol' => $this->normalizeBoolean($input['alcohol'] ?? false), - 'note' => trim((string) ($input['note'] ?? '')), + 'walk_mode' => $this->normalizeWalkMode((string) ($input['walk_mode'] ?? $derived['walk_mode'] ?? ((isset($input['walk_steps']) && !isset($input['walk_minutes'])) ? 'steps' : 'time'))), + 'walk_minutes' => max(0, min(1440, (int) ($hasEventInput ? $derived['walk_minutes'] : ($input['walk_minutes'] ?? $derived['walk_minutes'])))), + 'walk_steps' => max(0, min(50000, (int) ($hasEventInput ? $derived['walk_steps'] : ($input['walk_steps'] ?? $derived['walk_steps'])))), + 'alcohol' => $this->normalizeBoolean($hasSummaryInput ? $derived['alcohol'] : ($input['alcohol'] ?? $derived['alcohol'])), + 'note' => trim((string) ($input['note'] ?? $summary['comment'])), + 'summary' => $summary, + 'summary_comment' => $summary['comment'], + 'summary_mood' => $summary['mood'], + 'summary_energy' => $summary['energy'], + 'summary_stress' => $summary['stress'], + 'summary_alcohol' => !empty($summary['alcohol']), + 'background_image' => trim((string) ($input['background_image'] ?? '')), + 'events' => $events, ]; } @@ -36,6 +58,7 @@ final class ScoringService $ratings = $this->sortedRatings($settings['ratings'] ?? []); $sportTypes = find_sport_types($settings, $entry['sport_types']); $sportBonus = $this->sportBonusPoints($entry, $previousEntry, $settings, $sportTypes); + $eventSignalPoints = $this->eventSignalPoints($entry['events']); $painEnabled = !empty($settings['tracking']['pain_enabled']); $components = [ @@ -47,6 +70,7 @@ final class ScoringService 'sport_minutes' => $this->bandPoints((int) $entry['sport_minutes'], $scoring['sport_bands']), 'sport_bonus' => $sportBonus, 'walk_minutes' => $this->walkPoints($entry, $settings), + 'events' => $eventSignalPoints, 'alcohol' => !empty($entry['alcohol']) ? ((float) abs((float) ($scoring['alcohol_penalty'] ?? 5)) * -1) : 0.0, 'note' => $entry['note'] === '' ? 0 : (float) $scoring['journal_points'], ]; @@ -66,6 +90,7 @@ final class ScoringService $this->maxBandPoints($scoring['sport_bands']) + $this->maxSportBonusPoints($settings) + $this->maxWalkPoints($entry, $settings) + + ($eventSignalPoints !== 0.0 ? 8.0 : 0.0) + (float) $scoring['journal_points'], 1 ); @@ -100,6 +125,28 @@ final class ScoringService ]; } + private function eventSignalPoints(array $events): float + { + if ($events === []) { + return 0.0; + } + + $scores = []; + foreach ($events as $event) { + if (!is_array($event)) { + continue; + } + + $scores[] = signal_combo_score($event['mood'] ?? 0, $event['energy'] ?? 0, $event['stress'] ?? 0); + } + + if ($scores === []) { + return 0.0; + } + + return round(max(-8.0, min(8.0, (array_sum($scores) / count($scores)) * 4.0)), 1); + } + private function sleepDurationPoints(float $hours, array $points): float { if ($hours < 4) { @@ -304,6 +351,127 @@ final class ScoringService return $total; } + private function normalizeSummary(mixed $summary): array + { + $summary = is_array($summary) ? $summary : []; + + return [ + 'comment' => trim((string) ($summary['comment'] ?? '')), + 'mood' => normalize_signal_value($summary['mood'] ?? 0), + 'energy' => normalize_signal_value($summary['energy'] ?? 0), + 'stress' => normalize_signal_value($summary['stress'] ?? 0), + 'alcohol' => $this->normalizeBoolean($summary['alcohol'] ?? false), + ]; + } + + private function normalizeEvents(mixed $events): array + { + if (!is_array($events)) { + return []; + } + + $normalized = []; + + foreach ($events as $event) { + if (!is_array($event)) { + continue; + } + + $type = trim((string) ($event['type'] ?? 'event')); + if (!array_key_exists($type, day_event_type_options())) { + $type = 'event'; + } + + $time = trim((string) ($event['time'] ?? '')); + if (preg_match('/^(?:[01]\d|2[0-3]):[0-5]\d$/', $time) !== 1) { + $time = ''; + } + + $unit = trim((string) ($event['unit'] ?? day_event_type_unit($type))); + $value = is_numeric($event['value'] ?? null) ? (float) $event['value'] : 0.0; + + $normalized[] = [ + 'id' => trim((string) ($event['id'] ?? '')) ?: ('evt-' . substr(sha1(json_encode($event) . microtime(true)), 0, 12)), + 'type' => $type, + 'time' => $time, + 'comment' => trim(preg_replace('/\s+/u', ' ', (string) ($event['comment'] ?? '')) ?? ''), + 'value' => max(0, min(50000, $value)), + 'unit' => $unit, + 'sport_type_id' => trim((string) ($event['sport_type_id'] ?? '')), + 'consumed' => $this->normalizeBoolean($event['consumed'] ?? true), + 'mood' => normalize_signal_value($event['mood'] ?? 0), + 'energy' => normalize_signal_value($event['energy'] ?? 0), + 'stress' => normalize_signal_value($event['stress'] ?? 0), + ]; + } + + usort($normalized, static function (array $left, array $right): int { + return strcmp((string) ($left['time'] ?? ''), (string) ($right['time'] ?? '')); + }); + + return $normalized; + } + + private function deriveLegacyFieldsFromEvents(array $events, array $summary, array $input): array + { + $sportMinutes = 0; + $walkMinutes = 0; + $walkSteps = 0; + $sleepHours = 0.0; + $alcohol = false; + $walkMode = $this->normalizeWalkMode((string) ($input['walk_mode'] ?? 'time')); + $sportTypes = []; + + foreach ($events as $event) { + $type = (string) ($event['type'] ?? 'event'); + $unit = (string) ($event['unit'] ?? ''); + $value = (float) ($event['value'] ?? 0); + + if ($type === 'sport') { + $sportMinutes += (int) round($value); + $sportTypeID = trim((string) ($event['sport_type_id'] ?? '')); + if ($sportTypeID !== '') { + $sportTypes[$sportTypeID] = true; + } + } + + if ($type === 'walk') { + if ($unit === 'steps') { + $walkMode = 'steps'; + $walkSteps += (int) round($value); + } else { + $walkMinutes += (int) round($value); + } + } + + if ($type === 'sleep') { + $sleepHours += $unit === 'min' ? ($value / 60) : $value; + } + + if ($type === 'alcohol') { + $alcohol = !empty($event['consumed']); + } + } + + if (!empty($summary['alcohol'])) { + $alcohol = true; + } + + return [ + 'mood' => signal_to_legacy_scale($summary['mood']), + 'energy' => signal_to_legacy_scale($summary['energy']), + 'stress' => signal_to_legacy_scale($summary['stress']), + 'sleep_hours' => $sleepHours, + 'sleep_feeling' => max(1, min(5, 3 + $summary['energy'])), + 'sport_minutes' => $sportMinutes, + 'walk_mode' => $walkMode, + 'walk_minutes' => $walkMinutes, + 'walk_steps' => $walkSteps, + 'alcohol' => $alcohol, + 'sport_types' => array_keys($sportTypes), + ]; + } + private function sortedRatings(array $ratings): array { usort($ratings, static fn (array $a, array $b): int => ((int) $a['min']) <=> ((int) $b['min'])); diff --git a/src/helpers.php b/src/helpers.php index 596512f..e7f4dc5 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -634,3 +634,168 @@ function find_sport_types(array $settings, array $ids): array return $types; } + +function signal_scale_options(): array +{ + return [ + -2 => 'sehr niedrig', + -1 => 'niedrig', + 0 => 'neutral', + 1 => 'hoch', + 2 => 'sehr hoch', + ]; +} + +function signal_labels_for_metric(string $metric): array +{ + return match ($metric) { + 'stress' => [ + -2 => 'sehr ruhig', + -1 => 'ruhig', + 0 => 'neutral', + 1 => 'angespannt', + 2 => 'sehr angespannt', + ], + 'energy' => [ + -2 => 'leer', + -1 => 'matt', + 0 => 'okay', + 1 => 'wach', + 2 => 'kraftvoll', + ], + default => [ + -2 => 'sehr niedrig', + -1 => 'niedrig', + 0 => 'neutral', + 1 => 'hoch', + 2 => 'sehr hoch', + ], + }; +} + +function normalize_signal_value(mixed $value): int +{ + return max(-2, min(2, (int) $value)); +} + +function signal_to_legacy_scale(mixed $value): int +{ + return match (normalize_signal_value($value)) { + -2 => 1, + -1 => 3, + 0 => 5, + 1 => 7, + 2 => 9, + }; +} + +function legacy_to_signal_scale(mixed $value): int +{ + $legacy = max(1, min(10, (int) $value)); + + return match (true) { + $legacy <= 2 => -2, + $legacy <= 4 => -1, + $legacy <= 6 => 0, + $legacy <= 8 => 1, + default => 2, + }; +} + +function day_event_type_options(): array +{ + return [ + 'event' => [ + 'label' => 'Ereignis', + 'icon' => '/assets/icons/activity-event.svg', + 'unit' => '', + ], + 'walk' => [ + 'label' => 'Spaziergang', + 'icon' => sport_icon_path('hike'), + 'unit' => 'min', + ], + 'sport' => [ + 'label' => 'Sport', + 'icon' => sport_icon_path('run'), + 'unit' => 'min', + ], + 'sleep' => [ + 'label' => 'Schlaf', + 'icon' => '/assets/icons/activity-sleep.svg', + 'unit' => 'h', + ], + 'alcohol' => [ + 'label' => 'Alkohol', + 'icon' => icon_path('alcohol'), + 'unit' => '', + ], + ]; +} + +function day_event_type_label(string $type): string +{ + return day_event_type_options()[$type]['label'] ?? day_event_type_options()['event']['label']; +} + +function day_event_type_icon(string $type): string +{ + return day_event_type_options()[$type]['icon'] ?? day_event_type_options()['event']['icon']; +} + +function day_event_type_unit(string $type): string +{ + return day_event_type_options()[$type]['unit'] ?? ''; +} + +function signal_badge_tone(int $value, string $metric): string +{ + $value = normalize_signal_value($value); + + if ($metric === 'stress') { + return match (true) { + $value <= -1 => 'good', + $value === 0 => 'neutral', + default => 'warn', + }; + } + + return match (true) { + $value <= -1 => 'warn', + $value === 0 => 'neutral', + default => 'good', + }; +} + +function signal_combo_score(mixed $mood, mixed $energy, mixed $stress): int +{ + return max(-2, min(2, (int) round(( + normalize_signal_value($mood) + + normalize_signal_value($energy) - + normalize_signal_value($stress) + ) / 3))); +} + +function day_entry_has_content(array $entry): bool +{ + if (trim((string) ($entry['summary']['comment'] ?? '')) !== '') { + return true; + } + + if (trim((string) ($entry['background_image'] ?? '')) !== '') { + return true; + } + + return count(array_filter(is_array($entry['events'] ?? null) ? $entry['events'] : [], 'is_array')) > 0; +} + +function signal_value_class(int $value): string +{ + return match (normalize_signal_value($value)) { + -2 => 'neg2', + -1 => 'neg1', + 0 => 'zero', + 1 => 'pos1', + 2 => 'pos2', + }; +} diff --git a/templates/layout.php b/templates/layout.php index 419061e..da98097 100644 --- a/templates/layout.php +++ b/templates/layout.php @@ -3,7 +3,7 @@ declare(strict_types=1); $brandSubtitle = match ($page) { - 'dashboard' => 'Statistiken und Verlauf', + 'dashboard' => '', 'track' => 'Tag erfassen und bewerten', 'archive' => '', 'options' => 'Logik, Erinnerungen, Sicherheit und Accounts', @@ -11,6 +11,9 @@ $brandSubtitle = match ($page) { 'setup' => 'Erstkonfiguration', default => 'Stimmungstracker', }; +$immersiveDashboard = $page === 'dashboard'; +$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'; ?> @@ -33,15 +36,15 @@ $brandSubtitle = match ($page) { - - + + -> +>
-
- +
+ + + +
+ + + + + + + + + + + + + + + + + + + +