26 Commits

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

After

Width:  |  Height:  |  Size: 348 B

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

After

Width:  |  Height:  |  Size: 434 B

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

After

Width:  |  Height:  |  Size: 349 B

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

After

Width:  |  Height:  |  Size: 438 B

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

After

Width:  |  Height:  |  Size: 620 B

+911 -7
View File
@@ -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`;
}
@@ -421,7 +425,10 @@
let minValue = Math.min(...values);
let maxValue = Math.max(...values);
if (seriesName === "mood" || seriesName === "stress" || seriesName === "pain") {
if (seriesName === "balance") {
minValue = -2;
maxValue = 2;
} else if (seriesName === "mood" || seriesName === "stress" || seriesName === "pain") {
minValue = Math.max(1, minValue - 1.5);
maxValue = Math.min(10, maxValue + 1.5);
} else {
@@ -431,7 +438,10 @@
if ((maxValue - minValue) < 3) {
const center = (maxValue + minValue) / 2;
if (seriesName === "mood" || seriesName === "stress" || seriesName === "pain") {
if (seriesName === "balance") {
minValue = -2;
maxValue = 2;
} else if (seriesName === "mood" || seriesName === "stress" || seriesName === "pain") {
minValue = Math.max(1, center - 1.5);
maxValue = Math.min(10, center + 1.5);
} else {
@@ -697,7 +707,7 @@
}
const title = item.entry
? `${item.date}: ${formatNumber(Number(item.entry.score))} Punkte · ${item.entry.label}`
? `${item.date}: ${formatNumber(Number(item.entry.score))}% Bilanz · ${item.entry.label}`
: `${item.date}: kein Eintrag`;
if (!item.entry) {
@@ -705,7 +715,7 @@
}
return `
<a class="calendar-link" data-date="${item.date}" href="/track?date=${encodeURIComponent(item.date)}" aria-label="${title}">
<a class="calendar-link" data-date="${item.date}" href="${dashboardDayPath(item.date)}" aria-label="${title}">
<rect class="calendar-cell calendar-cell--active ${activeDate === item.date ? "calendar-cell--selected" : ""}" x="${x}" y="${y}" width="${cellSize}" height="${cellSize}" rx="4" fill="${fill}"></rect>
<title>${title}</title>
</a>
@@ -728,9 +738,9 @@
</div>
<div class="calendar-detail__score">
<span data-calendar-score>${formatNumber(Number(latestVisibleEntry.score))}</span>
<small>Punkte</small>
<small>% Bilanz</small>
</div>
<a class="ghost-link calendar-detail__link" data-calendar-link href="/track?date=${encodeURIComponent(latestVisibleEntry.date)}">Tag öffnen</a>
<a class="ghost-link calendar-detail__link" data-calendar-link href="${dashboardDayPath(latestVisibleEntry.date)}">Tag öffnen</a>
`;
container.innerHTML = `
@@ -773,7 +783,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 +981,895 @@
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 fabMenu = document.querySelector("[data-fab-menu]");
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 dayStrip = document.querySelector("[data-day-strip]");
const daySlider = document.querySelector("[data-day-slider]");
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: "Moment", 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 signalLabels = {
mood: { "-2": "sehr niedrig", "-1": "niedrig", 0: "neutral", 1: "hoch", 2: "sehr hoch" },
energy: { "-2": "leer", "-1": "matt", 0: "okay", 1: "wach", 2: "kraftvoll" },
stress: { "-2": "sehr ruhig", "-1": "ruhig", 0: "neutral", 1: "angespannt", 2: "sehr angespannt" },
};
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("button, [href]");
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}`;
const label = stepper.querySelector("[data-stepper-label]");
if (label) {
label.textContent = signalLabels[metric]?.[current] || signalLabels.mood[current] || "neutral";
}
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.01" : "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();
if (fabMenu instanceof HTMLElement) {
fabMenu.hidden = !fabMenu.hidden;
openMoment.classList.toggle("is-open", !fabMenu.hidden);
return;
}
showMomentChoose();
setOverlay(momentOverlay, true);
});
}
document.querySelectorAll("[data-fab-moment-choice]").forEach(button => {
button.addEventListener("click", event => {
event.preventDefault();
const type = button.dataset.fabMomentChoice || "event";
if (fabMenu instanceof HTMLElement) {
fabMenu.hidden = true;
}
if (openMoment) {
openMoment.classList.remove("is-open");
}
showMomentForm(type);
setOverlay(momentOverlay, true);
});
});
document.addEventListener("click", event => {
if (!(fabMenu instanceof HTMLElement) || fabMenu.hidden) {
return;
}
if (event.target.closest("[data-fab-menu]") || event.target.closest("[data-moment-overlay-open]")) {
return;
}
fabMenu.hidden = true;
if (openMoment) {
openMoment.classList.remove("is-open");
}
});
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 && dayStrip && daySlider) {
let pointerStartX = 0;
let pointerStartY = 0;
let dragging = false;
let didSwipe = false;
let activePointerId = null;
let currentOffset = 0;
let targetOffset = 0;
let animationFrame = null;
const prefetchedDays = new Set();
const setSlideProgress = offset => {
const progress = Math.min(1, Math.abs(offset) / 120);
daySlider.style.setProperty("--day-slider-offset", `${offset.toFixed(1)}px`);
daySlider.style.setProperty("--day-slider-scale", (1 - (progress * 0.025)).toFixed(3));
swipeContainer.style.setProperty("--day-prev-hint", offset > 0 ? progress.toFixed(3) : "0");
swipeContainer.style.setProperty("--day-next-hint", offset < 0 ? progress.toFixed(3) : "0");
};
const animateSlide = () => {
currentOffset += (targetOffset - currentOffset) * 0.34;
if (Math.abs(targetOffset - currentOffset) < 0.4) {
currentOffset = targetOffset;
}
setSlideProgress(currentOffset);
if (currentOffset !== targetOffset) {
animationFrame = window.requestAnimationFrame(animateSlide);
} else {
animationFrame = null;
}
};
const setTargetOffset = offset => {
targetOffset = offset;
if (animationFrame === null) {
animationFrame = window.requestAnimationFrame(animateSlide);
}
};
const preloadDay = date => {
if (!date || prefetchedDays.has(date)) {
return;
}
prefetchedDays.add(date);
window.fetch(dashboardDayPath(date), {
credentials: "same-origin",
cache: "force-cache",
priority: "low"
}).catch(() => {});
};
preloadDay(swipeContainer.dataset.prevDate);
preloadDay(swipeContainer.dataset.nextDate);
const resetStrip = () => {
dayStrip.classList.remove("is-dragging");
daySlider.classList.remove("is-dragging");
setTargetOffset(0);
};
const handleSwipe = (deltaX, deltaY) => {
const absX = Math.abs(deltaX);
const absY = Math.abs(deltaY);
if (document.body.classList.contains("is-dashboard-overlay-open") || absX < 55 || absY > Math.max(120, absX * 1.15)) {
resetStrip();
return;
}
if (deltaX < 0 && swipeContainer.dataset.nextDate) {
didSwipe = true;
setSlideProgress(-window.innerWidth);
window.location.href = dashboardDayPath(swipeContainer.dataset.nextDate);
} else if (deltaX > 0 && swipeContainer.dataset.prevDate) {
didSwipe = true;
setSlideProgress(window.innerWidth);
window.location.href = dashboardDayPath(swipeContainer.dataset.prevDate);
} else {
resetStrip();
}
};
daySlider.addEventListener("pointerdown", event => {
if (event.target.closest("input, textarea, select, label, [data-stepper], .dashboard-overlay")) {
dragging = false;
return;
}
didSwipe = false;
dragging = true;
activePointerId = event.pointerId;
pointerStartX = event.clientX;
pointerStartY = event.clientY;
dayStrip.classList.add("is-dragging");
daySlider.classList.add("is-dragging");
daySlider.setPointerCapture?.(event.pointerId);
});
daySlider.addEventListener("pointermove", event => {
if (!dragging || (activePointerId !== null && event.pointerId !== activePointerId)) {
return;
}
const deltaX = event.clientX - pointerStartX;
const deltaY = event.clientY - pointerStartY;
if (Math.abs(deltaY) > Math.max(52, Math.abs(deltaX) * 1.35)) {
dragging = false;
activePointerId = null;
resetStrip();
return;
}
const dampedOffset = Math.sign(deltaX) * Math.min(148, Math.pow(Math.abs(deltaX), 0.88) * 1.6);
setTargetOffset(dampedOffset);
if (deltaX < -32) {
preloadDay(swipeContainer.dataset.nextDate);
} else if (deltaX > 32) {
preloadDay(swipeContainer.dataset.prevDate);
}
});
daySlider.addEventListener("pointerup", event => {
if (!dragging) {
return;
}
dragging = false;
activePointerId = null;
handleSwipe(event.clientX - pointerStartX, event.clientY - pointerStartY);
});
daySlider.addEventListener("pointercancel", () => {
dragging = false;
activePointerId = null;
resetStrip();
});
daySlider.addEventListener("lostpointercapture", () => {
if (!dragging) {
return;
}
dragging = false;
activePointerId = null;
resetStrip();
});
daySlider.addEventListener("click", event => {
if (!didSwipe) {
return;
}
event.preventDefault();
event.stopPropagation();
didSwipe = false;
}, true);
window.addEventListener("keydown", event => {
if (document.body.classList.contains("is-dashboard-overlay-open")) {
return;
}
if (event.key === "ArrowLeft" && swipeContainer.dataset.nextDate) {
window.location.href = dashboardDayPath(swipeContainer.dataset.nextDate);
}
if (event.key === "ArrowRight" && swipeContainer.dataset.prevDate) {
window.location.href = dashboardDayPath(swipeContainer.dataset.prevDate);
}
});
}
}
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 menu = overlay.querySelector("[data-options-menu]");
const closeButtons = [...overlay.querySelectorAll("[data-options-close]")];
const backButtons = [...overlay.querySelectorAll("[data-options-back]")];
const isStandalone = overlay.dataset.optionsStandalone === "1";
const initialPanel = overlay.dataset.openPanel || null;
const setOpen = (panelName) => {
overlay.hidden = !isStandalone && panelName === null;
document.body.classList.toggle("is-dashboard-overlay-open", isStandalone || panelName !== null);
if (menu instanceof HTMLElement) {
menu.hidden = panelName !== null;
}
backButtons.forEach(button => {
button.hidden = 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();
if (isStandalone) {
window.location.href = "/";
return;
}
setOpen(null);
});
});
backButtons.forEach(button => {
button.addEventListener("click", event => {
event.preventDefault();
setOpen(null);
if (window.location.search.includes("panel=")) {
window.history.replaceState(null, "", "/options");
}
});
});
if (initialPanel) {
setOpen(initialPanel);
} else if (isStandalone) {
setOpen(null);
}
}
function initHealthImportStatus() {
const panel = document.querySelector("[data-health-import-status]");
if (!panel) {
return;
}
const progressWrap = panel.querySelector("[data-health-progress-wrap]");
const progress = panel.querySelector("[data-health-progress-bar]");
const progressText = panel.querySelector("[data-health-progress-text]");
const lastImport = panel.querySelector("[data-health-last-import]");
const lastMessage = panel.querySelector("[data-health-last-message]");
let timer = null;
const formatDuration = seconds => {
const rounded = Math.max(0, Math.round(Number(seconds) || 0));
if (rounded < 60) {
return `${rounded} s`;
}
return `${Math.ceil(rounded / 60)} min`;
};
const render = status => {
const done = Math.max(0, Number(status.progress_done || 0));
const total = Math.max(0, Number(status.progress_total || 0));
const percent = total > 0 ? Math.min(100, Math.round((done / total) * 100)) : 0;
if (progress) {
progress.value = String(percent);
progress.textContent = `${percent}%`;
}
if (progressWrap) {
progressWrap.dataset.progressDone = String(done);
progressWrap.dataset.progressTotal = String(total);
}
if (lastImport) {
lastImport.textContent = status.last_import_at ? new Date(status.last_import_at).toLocaleString("de-DE") : "-";
}
if (lastMessage) {
lastMessage.textContent = status.last_message || "-";
}
if (progressText) {
if (status.last_status === "running") {
let eta = "wird berechnet";
const started = Date.parse(status.started_at || "");
if (started && done > 0 && total > done) {
const elapsed = (Date.now() - started) / 1000;
eta = formatDuration((elapsed / done) * (total - done));
}
progressText.textContent = `Import läuft: ${done} von ${total} verarbeitet. Restzeit ca. ${eta}.`;
return;
}
if (status.last_status === "error") {
progressText.textContent = `${status.last_message || "Import fehlgeschlagen."} Derselbe Export kann erneut gesendet werden und wird idempotent übernommen.`;
return;
}
progressText.textContent = status.last_message || "Noch kein Import gelaufen.";
}
};
const initialDone = progressWrap ? Number(progressWrap.dataset.progressDone || 0) : 0;
const initialTotal = progressWrap ? Number(progressWrap.dataset.progressTotal || 0) : 0;
render({ progress_done: initialDone, progress_total: initialTotal });
const refresh = async () => {
try {
const response = await fetch("/api/health/status", { credentials: "same-origin" });
if (!response.ok) {
return;
}
const data = await response.json();
if (!data || !data.ok || !data.status) {
return;
}
render(data.status);
if (data.status.last_status !== "running" && timer !== null) {
window.clearInterval(timer);
timer = null;
}
} catch (error) {
// Status polling is best-effort; the import itself is server-side.
}
};
refresh();
timer = window.setInterval(refresh, 3500);
}
function initMediaLightbox() {
const lightbox = document.querySelector("[data-media-lightbox]");
const content = document.querySelector("[data-media-lightbox-content]");
if (!(lightbox instanceof HTMLElement) || !(content instanceof HTMLElement)) {
return;
}
if (lightbox.parentElement !== document.body) {
document.body.appendChild(lightbox);
}
const close = () => {
lightbox.setAttribute("hidden", "hidden");
content.replaceChildren();
document.body.classList.remove("is-dashboard-overlay-open");
};
document.querySelectorAll("[data-media-lightbox-close]").forEach(button => {
button.addEventListener("click", close);
});
document.addEventListener("keydown", event => {
if (event.key === "Escape" && !lightbox.hasAttribute("hidden")) {
close();
}
});
document.querySelectorAll("[data-lightbox-kind]").forEach(trigger => {
trigger.addEventListener("click", event => {
event.preventDefault();
event.stopPropagation();
content.replaceChildren();
if (trigger.dataset.lightboxKind === "image") {
const image = document.createElement("img");
image.src = trigger.dataset.lightboxSrc || "";
image.alt = "";
content.appendChild(image);
} else {
const clone = trigger.cloneNode(true);
clone.removeAttribute("data-lightbox-kind");
clone.removeAttribute("aria-label");
clone.classList.add("is-lightbox-clone");
content.appendChild(clone);
}
lightbox.removeAttribute("hidden");
document.body.classList.add("is-dashboard-overlay-open");
});
});
}
function initSleepPhaseTooltips() {
document.querySelectorAll(".sleep-phase-bar__segment[data-tooltip]").forEach(segment => {
segment.addEventListener("click", event => {
event.preventDefault();
event.stopPropagation();
document.querySelectorAll(".sleep-phase-bar__segment.is-tooltip-visible").forEach(active => {
if (active !== segment) {
active.classList.remove("is-tooltip-visible");
}
});
segment.classList.toggle("is-tooltip-visible");
});
});
document.addEventListener("click", event => {
if (event.target.closest(".sleep-phase-bar__segment[data-tooltip]")) {
return;
}
document.querySelectorAll(".sleep-phase-bar__segment.is-tooltip-visible").forEach(segment => {
segment.classList.remove("is-tooltip-visible");
});
});
}
function csrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") || "";
}
@@ -1300,6 +2199,11 @@
initTrackPreview();
initArchiveMobileDetail();
initDashboardCharts();
initDashboardExperience();
initOptionsPanels();
initHealthImportStatus();
initMediaLightbox();
initSleepPhaseTooltips();
initSportTypeManager();
initPwaShell();
initPullToRefresh();
+1975 -22
View File
File diff suppressed because it is too large Load Diff
+219 -3
View File
@@ -96,6 +96,10 @@ final class EntryRepository
private function parse(string $content, string $fallbackDate): ?array
{
if (str_contains($content, '<!-- mood-tracker:v3 -->')) {
return $this->parseV3($content, $fallbackDate);
}
$sportTypes = [];
$sportTypesRaw = (string) ($this->extract('/^- Sportarten:\s*(.*)$/mu', $content) ?? '');
if ($sportTypesRaw !== '') {
@@ -134,6 +138,19 @@ final class EntryRepository
'walk_steps' => $walkMode === 'steps' ? $walkValue : 0,
'alcohol' => in_array($alcoholRaw, ['ja', 'yes', 'true', '1'], true),
'note' => $this->extractNote($content),
'summary' => [
'comment' => $this->extractNote($content),
'mood' => legacy_to_signal_scale((int) ($this->extract('/^- Stimmung:\s*(.+)$/m', $content) ?? 5)),
'energy' => legacy_to_signal_scale((int) ($this->extract('/^- Energie:\s*(.+)$/m', $content) ?? 5)),
'stress' => legacy_to_signal_scale((int) ($this->extract('/^- Stress:\s*(.+)$/m', $content) ?? 5)),
'alcohol' => in_array($alcoholRaw, ['ja', 'yes', 'true', '1'], true),
],
'summary_comment' => $this->extractNote($content),
'summary_mood' => legacy_to_signal_scale((int) ($this->extract('/^- Stimmung:\s*(.+)$/m', $content) ?? 5)),
'summary_energy' => legacy_to_signal_scale((int) ($this->extract('/^- Energie:\s*(.+)$/m', $content) ?? 5)),
'summary_stress' => legacy_to_signal_scale((int) ($this->extract('/^- Stress:\s*(.+)$/m', $content) ?? 5)),
'background_image' => '',
'events' => [],
];
return $entry;
@@ -163,18 +180,73 @@ final class EntryRepository
private function toMarkdown(string $username, string $date, array $entry, array $evaluation): string
{
$summary = is_array($entry['summary'] ?? null) ? $entry['summary'] : [
'comment' => (string) ($entry['summary_comment'] ?? $entry['note'] ?? ''),
'mood' => normalize_signal_value($entry['summary_mood'] ?? legacy_to_signal_scale($entry['mood'] ?? 5)),
'energy' => normalize_signal_value($entry['summary_energy'] ?? legacy_to_signal_scale($entry['energy'] ?? 5)),
'stress' => normalize_signal_value($entry['summary_stress'] ?? legacy_to_signal_scale($entry['stress'] ?? 5)),
];
$events = is_array($entry['events'] ?? null) ? $entry['events'] : [];
$health = is_array($entry['health'] ?? null) ? $entry['health'] : [];
$sportTypes = $evaluation['sport_types'] ?? [];
$sportTypeValues = array_map(
static fn (array $type): string => (string) $type['label'] . ' [' . (string) $type['id'] . ']',
array_filter($sportTypes, 'is_array')
);
$eventLines = [];
foreach ($events as $event) {
if (!is_array($event)) {
continue;
}
$eventLines[] = '### ' . ((string) ($event['id'] ?? 'evt'));
$eventLines[] = '- Typ: ' . day_event_type_label((string) ($event['type'] ?? 'event')) . ' [' . (string) ($event['type'] ?? 'event') . ']';
$eventLines[] = '- Uhrzeit: ' . (string) ($event['time'] ?? '');
$eventLines[] = '- Wert: ' . (string) ($event['value'] ?? 0);
$eventLines[] = '- Einheit: ' . (string) ($event['unit'] ?? '');
$eventLines[] = '- Sportart: ' . (string) ($event['sport_type_id'] ?? '');
$eventLines[] = '- Bild: ' . (string) ($event['image'] ?? '');
$eventLines[] = '- Getrunken: ' . (!empty($event['consumed']) ? 'ja' : 'nein');
$eventLines[] = '- Kommentar: ' . (string) ($event['comment'] ?? '');
$eventLines[] = '- Stimmung: ' . (string) ($event['mood'] ?? 0);
$eventLines[] = '- Energie: ' . (string) ($event['energy'] ?? 0);
$eventLines[] = '- Stress: ' . (string) ($event['stress'] ?? 0);
$eventLines[] = '- Quelle: ' . (string) ($event['source'] ?? '');
$eventLines[] = '- Import-ID: ' . (string) ($event['import_id'] ?? '');
$eventLines[] = '- Dauer-Label: ' . (string) ($event['duration_label'] ?? '');
$eventLines[] = '- Distanz-Label: ' . (string) ($event['distance_label'] ?? '');
$eventLines[] = '- Energie-Label: ' . (string) ($event['energy_label'] ?? '');
$eventLines[] = '- Puls-Label: ' . (string) ($event['heart_rate_label'] ?? '');
$eventLines[] = '- Tiefschlaf: ' . (string) ($event['sleep_deep'] ?? 0);
$eventLines[] = '- REM-Schlaf: ' . (string) ($event['sleep_rem'] ?? 0);
$eventLines[] = '- Kernschlaf: ' . (string) ($event['sleep_core'] ?? 0);
$route = is_array($event['route'] ?? null) ? $event['route'] : [];
$eventLines[] = '- Route: ' . ($route !== [] ? base64_encode((string) json_encode($route, JSON_UNESCAPED_SLASHES)) : '');
$eventLines[] = '';
}
$lines = [
'<!-- mood-tracker:v2 -->',
'# Stimmungstracker',
'<!-- mood-tracker:v3 -->',
'# Stimmungstracker Tag',
'Datum: ' . $date,
'Benutzer: ' . normalize_username($username),
'Hintergrundbild: ' . trim((string) ($entry['background_image'] ?? '')),
'',
'## Tagesbilanz',
'- Kommentar: ' . trim((string) ($summary['comment'] ?? '')),
'- Stimmung: ' . normalize_signal_value($summary['mood'] ?? 0),
'- Energie: ' . normalize_signal_value($summary['energy'] ?? 0),
'- Stress: ' . normalize_signal_value($summary['stress'] ?? 0),
'- Alkohol: ' . (!empty($summary['alcohol']) ? 'ja' : 'nein'),
'',
'## Ereignisse',
...($eventLines !== [] ? $eventLines : ['- Keine Ereignisse', '']),
'## Gesundheitsdaten',
'- Schritte: ' . (int) ($health['steps'] ?? 0),
'- Schritte importiert am: ' . (string) ($health['steps_imported_at'] ?? ''),
'',
'## Tracking',
'## Werte',
'- Stimmung: ' . $entry['mood'],
'- Energie: ' . $entry['energy'],
@@ -202,14 +274,158 @@ final class EntryRepository
'- Sport: ' . format_points((float) $evaluation['components']['sport_minutes']),
'- Sportbonus: ' . format_points((float) ($evaluation['components']['sport_bonus'] ?? 0)),
'- Spaziergang: ' . format_points((float) $evaluation['components']['walk_minutes']),
'- Schrittebonus: ' . format_points((float) ($evaluation['components']['step_bonus'] ?? 0)),
'- Momente: ' . format_points((float) ($evaluation['components']['events'] ?? 0)),
'- Alkohol: ' . format_points((float) ($evaluation['components']['alcohol'] ?? 0)),
'- Notiz: ' . format_points((float) $evaluation['components']['note']),
'',
'## Notiz',
trim((string) $entry['note']),
trim((string) ($summary['comment'] ?? $entry['note'] ?? '')),
'',
];
return implode("\n", $lines);
}
private function parseV3(string $content, string $fallbackDate): ?array
{
$date = $this->extract('/^Datum:\s*(\d{4}-\d{2}-\d{2})$/m', $content) ?? $fallbackDate;
$backgroundImage = trim((string) ($this->extract('/^Hintergrundbild:[ \t]*([^\r\n]*)$/mu', $content) ?? ''));
if (preg_match('/^[A-Za-z0-9._-]+\.(?:jpe?g|png|webp)$/i', $backgroundImage) !== 1) {
$backgroundImage = '';
}
$summarySection = $this->extractSection($content, '## Tagesbilanz', '## Ereignisse');
$eventsSection = $this->extractSection($content, '## Ereignisse', '## Gesundheitsdaten')
?? $this->extractSection($content, '## Ereignisse', '## Tracking');
$healthSection = $this->extractSection($content, '## Gesundheitsdaten', '## Tracking');
$legacySection = $this->extractSection($content, '## Werte', '## Bewertung');
$legacyContent = $legacySection !== null ? "## Werte\n" . $legacySection : $content;
$base = $this->parse(str_replace('<!-- mood-tracker:v3 -->', '<!-- mood-tracker:v2 -->', $legacyContent), $fallbackDate);
if ($base === null) {
return null;
}
$summary = [
'comment' => (string) ($this->extract('/^- Kommentar:\s*(.*)$/mu', $summarySection ?? '') ?? ''),
'mood' => normalize_signal_value($this->extract('/^- Stimmung:\s*(-?\d+)$/m', $summarySection ?? '') ?? 0),
'energy' => normalize_signal_value($this->extract('/^- Energie:\s*(-?\d+)$/m', $summarySection ?? '') ?? 0),
'stress' => normalize_signal_value($this->extract('/^- Stress:\s*(-?\d+)$/m', $summarySection ?? '') ?? 0),
'alcohol' => in_array(strtolower((string) ($this->extract('/^- Alkohol:\s*(.*)$/mu', $summarySection ?? '') ?? 'nein')), ['ja', 'yes', 'true', '1'], true),
];
$events = [];
foreach (preg_split('/^###\s+/m', trim((string) $eventsSection)) ?: [] as $chunk) {
$chunk = trim($chunk);
if ($chunk === '' || str_starts_with($chunk, '- Keine Ereignisse')) {
continue;
}
$lines = preg_split('/\R/', $chunk) ?: [];
$id = trim((string) array_shift($lines));
$block = implode("\n", $lines);
$typeLine = (string) ($this->extract('/^- Typ:\s*.*\[([^\]]+)\]$/mu', $block) ?? 'event');
$events[] = [
'id' => $id,
'type' => $typeLine,
'time' => (string) ($this->extract('/^- Uhrzeit:\s*(.*)$/mu', $block) ?? ''),
'value' => (float) ($this->extract('/^- Wert:\s*(.*)$/mu', $block) ?? 0),
'unit' => (string) ($this->extract('/^- Einheit:\s*(.*)$/mu', $block) ?? ''),
'sport_type_id' => (string) ($this->extract('/^- Sportart:\s*(.*)$/mu', $block) ?? ''),
'image' => $this->normalizeImageFileName((string) ($this->extract('/^- Bild:\s*(.*)$/mu', $block) ?? '')),
'consumed' => in_array(strtolower((string) ($this->extract('/^- Getrunken:\s*(.*)$/mu', $block) ?? 'ja')), ['ja', 'yes', 'true', '1'], true),
'comment' => (string) ($this->extract('/^- Kommentar:\s*(.*)$/mu', $block) ?? ''),
'mood' => normalize_signal_value($this->extract('/^- Stimmung:\s*(-?\d+)$/m', $block) ?? 0),
'energy' => normalize_signal_value($this->extract('/^- Energie:\s*(-?\d+)$/m', $block) ?? 0),
'stress' => normalize_signal_value($this->extract('/^- Stress:\s*(-?\d+)$/m', $block) ?? 0),
'source' => (string) ($this->extract('/^- Quelle:\s*(.*)$/mu', $block) ?? ''),
'import_id' => (string) ($this->extract('/^- Import-ID:\s*(.*)$/mu', $block) ?? ''),
'duration_label' => (string) ($this->extract('/^- Dauer-Label:\s*(.*)$/mu', $block) ?? ''),
'distance_label' => (string) ($this->extract('/^- Distanz-Label:\s*(.*)$/mu', $block) ?? ''),
'energy_label' => (string) ($this->extract('/^- Energie-Label:\s*(.*)$/mu', $block) ?? ''),
'heart_rate_label' => (string) ($this->extract('/^- Puls-Label:\s*(.*)$/mu', $block) ?? ''),
'sleep_deep' => (float) ($this->extract('/^- Tiefschlaf:\s*(.*)$/mu', $block) ?? 0),
'sleep_rem' => (float) ($this->extract('/^- REM-Schlaf:\s*(.*)$/mu', $block) ?? 0),
'sleep_core' => (float) ($this->extract('/^- Kernschlaf:\s*(.*)$/mu', $block) ?? 0),
'route' => $this->decodeRoute((string) ($this->extract('/^- Route:\s*(.*)$/mu', $block) ?? '')),
];
}
$health = [
'steps' => max(0, (int) ($this->extract('/^- Schritte:\s*(\d+)$/m', $healthSection ?? '') ?? 0)),
'steps_imported_at' => (string) ($this->extract('/^- Schritte importiert am:\s*(.*)$/mu', $healthSection ?? '') ?? ''),
];
$base['date'] = $date;
$base['background_image'] = $backgroundImage;
$base['summary'] = $summary;
$base['summary_comment'] = $summary['comment'];
$base['summary_mood'] = $summary['mood'];
$base['summary_energy'] = $summary['energy'];
$base['summary_stress'] = $summary['stress'];
$base['summary_alcohol'] = !empty($summary['alcohol']);
$base['health'] = $health;
$base['events'] = $events;
$base['alcohol'] = !empty($summary['alcohol']);
$base['note'] = $summary['comment'];
return $base;
}
private function extractSection(string $content, string $startHeading, string $endHeading): ?string
{
$pattern = '/' . preg_quote($startHeading, '/') . '\s*(.*?)\s*' . preg_quote($endHeading, '/') . '/su';
if (preg_match($pattern, $content, $matches) !== 1) {
return null;
}
return trim((string) ($matches[1] ?? ''));
}
private function normalizeImageFileName(string $fileName): string
{
$fileName = trim($fileName);
return preg_match('/^[A-Za-z0-9._-]+\.(?:jpe?g|png|webp)$/i', $fileName) === 1 ? $fileName : '';
}
private function decodeRoute(string $encoded): array
{
$encoded = trim($encoded);
if ($encoded === '') {
return [];
}
$decoded = base64_decode($encoded, true);
if (!is_string($decoded)) {
return [];
}
$route = json_decode($decoded, true);
if (!is_array($route)) {
return [];
}
$points = [];
foreach ($route as $point) {
if (!is_array($point) || !is_numeric($point['lat'] ?? null) || !is_numeric($point['lon'] ?? null)) {
continue;
}
$lat = (float) $point['lat'];
$lon = (float) $point['lon'];
if ($lat < -90 || $lat > 90 || $lon < -180 || $lon > 180) {
continue;
}
$points[] = [
'lat' => round($lat, 6),
'lon' => round($lon, 6),
];
}
return $points;
}
}
+330 -12
View File
@@ -6,25 +6,49 @@ final class ScoringService
{
public function normalize(array $input): array
{
$sportTypes = normalize_sport_type_selection($input['sport_types'] ?? ($input['sport_type'] ?? []));
$hasSummaryInput = is_array($input['summary'] ?? null)
|| array_key_exists('summary_mood', $input)
|| array_key_exists('summary_energy', $input)
|| array_key_exists('summary_stress', $input);
$hasEventInput = is_array($input['events'] ?? null) && $input['events'] !== [];
$summary = $this->normalizeSummary($input['summary'] ?? [
'comment' => $input['summary_comment'] ?? ($input['note'] ?? ''),
'mood' => $input['summary_mood'] ?? legacy_to_signal_scale($input['mood'] ?? 5),
'energy' => $input['summary_energy'] ?? legacy_to_signal_scale($input['energy'] ?? 5),
'stress' => $input['summary_stress'] ?? legacy_to_signal_scale($input['stress'] ?? 5),
'alcohol' => !empty($input['summary_alcohol'] ?? $input['alcohol'] ?? false),
]);
$events = $this->normalizeEvents($input['events'] ?? []);
$derived = $this->deriveLegacyFieldsFromEvents($events, $summary, $input);
$sportTypes = normalize_sport_type_selection($hasEventInput ? ($derived['sport_types'] ?? []) : ($input['sport_types'] ?? ($derived['sport_types'] ?? ($input['sport_type'] ?? []))));
$health = $this->normalizeHealth($input['health'] ?? []);
return [
'date' => $input['date'] ?? today(),
'mood' => max(1, min(10, (int) ($input['mood'] ?? 5))),
'energy' => max(1, min(10, (int) ($input['energy'] ?? 5))),
'stress' => max(1, min(10, (int) ($input['stress'] ?? 5))),
'mood' => max(1, min(10, (int) ($hasSummaryInput ? $derived['mood'] : ($input['mood'] ?? $derived['mood'])))),
'energy' => max(1, min(10, (int) ($hasSummaryInput ? $derived['energy'] : ($input['energy'] ?? $derived['energy'])))),
'stress' => max(1, min(10, (int) ($hasSummaryInput ? $derived['stress'] : ($input['stress'] ?? $derived['stress'])))),
'pain' => max(1, min(10, (int) ($input['pain'] ?? 1))),
'pain_enabled' => $this->normalizeBoolean($input['pain_enabled'] ?? false),
'sleep_hours' => max(0, min(24, (float) ($input['sleep_hours'] ?? 0))),
'sleep_feeling' => max(1, min(5, (int) ($input['sleep_feeling'] ?? 3))),
'sport_minutes' => max(0, min(1440, (int) ($input['sport_minutes'] ?? 0))),
'sleep_hours' => max(0, min(24, (float) ($hasEventInput ? $derived['sleep_hours'] : ($input['sleep_hours'] ?? $derived['sleep_hours'])))),
'sleep_feeling' => max(1, min(5, (int) ($hasSummaryInput ? $derived['sleep_feeling'] : ($input['sleep_feeling'] ?? $derived['sleep_feeling'])))),
'sport_minutes' => max(0, min(1440, (int) ($hasEventInput ? $derived['sport_minutes'] : ($input['sport_minutes'] ?? $derived['sport_minutes'])))),
'sport_type' => $sportTypes[0] ?? '',
'sport_types' => $sportTypes,
'walk_mode' => $this->normalizeWalkMode((string) ($input['walk_mode'] ?? ((isset($input['walk_steps']) && !isset($input['walk_minutes'])) ? 'steps' : 'time'))),
'walk_minutes' => max(0, min(1440, (int) ($input['walk_minutes'] ?? 0))),
'walk_steps' => max(0, min(50000, (int) ($input['walk_steps'] ?? 0))),
'alcohol' => $this->normalizeBoolean($input['alcohol'] ?? false),
'note' => trim((string) ($input['note'] ?? '')),
'walk_mode' => $this->normalizeWalkMode((string) ($input['walk_mode'] ?? $derived['walk_mode'] ?? ((isset($input['walk_steps']) && !isset($input['walk_minutes'])) ? 'steps' : 'time'))),
'walk_minutes' => max(0, min(1440, (int) ($hasEventInput ? $derived['walk_minutes'] : ($input['walk_minutes'] ?? $derived['walk_minutes'])))),
'walk_steps' => max(0, min(50000, (int) ($hasEventInput ? $derived['walk_steps'] : ($input['walk_steps'] ?? $derived['walk_steps'])))),
'alcohol' => $this->normalizeBoolean($hasSummaryInput ? $derived['alcohol'] : ($input['alcohol'] ?? $derived['alcohol'])),
'note' => trim((string) ($input['note'] ?? $summary['comment'])),
'summary' => $summary,
'summary_comment' => $summary['comment'],
'summary_mood' => $summary['mood'],
'summary_energy' => $summary['energy'],
'summary_stress' => $summary['stress'],
'summary_alcohol' => !empty($summary['alcohol']),
'background_image' => trim((string) ($input['background_image'] ?? '')),
'health' => $health,
'events' => $events,
];
}
@@ -36,6 +60,7 @@ final class ScoringService
$ratings = $this->sortedRatings($settings['ratings'] ?? []);
$sportTypes = find_sport_types($settings, $entry['sport_types']);
$sportBonus = $this->sportBonusPoints($entry, $previousEntry, $settings, $sportTypes);
$eventSignalPoints = $this->eventSignalPoints($entry['events']);
$painEnabled = !empty($settings['tracking']['pain_enabled']);
$components = [
@@ -47,6 +72,9 @@ final class ScoringService
'sport_minutes' => $this->bandPoints((int) $entry['sport_minutes'], $scoring['sport_bands']),
'sport_bonus' => $sportBonus,
'walk_minutes' => $this->walkPoints($entry, $settings),
'step_bonus' => $this->stepBonusPoints($entry, $scoring['step_bonus'] ?? []),
'daily_steps' => $this->stepTargetPoints((int) ($entry['health']['steps'] ?? 0), $scoring['walk_step_targets'] ?? []),
'events' => $eventSignalPoints,
'alcohol' => !empty($entry['alcohol']) ? ((float) abs((float) ($scoring['alcohol_penalty'] ?? 5)) * -1) : 0.0,
'note' => $entry['note'] === '' ? 0 : (float) $scoring['journal_points'],
];
@@ -66,6 +94,9 @@ final class ScoringService
$this->maxBandPoints($scoring['sport_bands']) +
$this->maxSportBonusPoints($settings) +
$this->maxWalkPoints($entry, $settings) +
$this->maxStepTargetPoints($scoring['walk_step_targets'] ?? []) +
max(0.0, (float) ($scoring['step_bonus']['points'] ?? 0)) +
($eventSignalPoints !== 0.0 ? 8.0 : 0.0) +
(float) $scoring['journal_points'],
1
);
@@ -95,11 +126,72 @@ final class ScoringService
'guardrail' => $guardrail,
'sentiment' => $this->sentimentForLabel($label, $ratings),
'percentage' => $maxTotal > 0 ? round(($total / $maxTotal) * 100, 1) : 0,
'balance' => $this->dayBalance($entry, $components, $settings),
'sport_type' => $sportTypes[0] ?? null,
'sport_types' => $sportTypes,
];
}
private function dayBalance(array $entry, array $components, array $settings): array
{
$config = is_array($settings['day_balance'] ?? null) ? $settings['day_balance'] : [];
$moodWeight = max(0.0, (float) ($config['mood_weight'] ?? 3));
$energyWeight = max(0.0, (float) ($config['energy_weight'] ?? 2));
$stressWeight = max(0.0, (float) ($config['stress_weight'] ?? 2));
$weightTotal = max(1.0, $moodWeight + $energyWeight + $stressWeight);
$summary = is_array($entry['summary'] ?? null) ? $entry['summary'] : [];
$mood = normalize_signal_value($summary['mood'] ?? $entry['summary_mood'] ?? legacy_to_signal_scale($entry['mood'] ?? 5));
$energy = normalize_signal_value($summary['energy'] ?? $entry['summary_energy'] ?? legacy_to_signal_scale($entry['energy'] ?? 5));
$stress = normalize_signal_value($summary['stress'] ?? $entry['summary_stress'] ?? legacy_to_signal_scale($entry['stress'] ?? 5));
$base = (($mood * $moodWeight) + ($energy * $energyWeight) + ((-$stress) * $stressWeight)) / $weightTotal;
$adjustmentPoints = 0.0;
foreach ($components as $key => $value) {
if (in_array($key, ['mood', 'energy', 'stress', 'pain'], true)) {
continue;
}
$adjustmentPoints += (float) $value;
}
$pointsPerStep = max(1.0, (float) ($config['points_per_step'] ?? 12));
$cap = max(0.0, min(2.0, (float) ($config['adjustment_cap'] ?? 1.0)));
$adjustment = max(-$cap, min($cap, $adjustmentPoints / $pointsPerStep));
$raw = max(-2.0, min(2.0, $base + $adjustment));
$level = max(-2, min(2, (int) round($raw)));
return [
'base' => round($base, 2),
'adjustment' => round($adjustment, 2),
'raw' => round($raw, 2),
'level' => $level,
'percentage' => round((($raw + 2.0) / 4.0) * 100, 1),
'tone' => signal_value_class($level),
];
}
private function eventSignalPoints(array $events): float
{
if ($events === []) {
return 0.0;
}
$scores = [];
foreach ($events as $event) {
if (!is_array($event)) {
continue;
}
$scores[] = signal_combo_score($event['mood'] ?? 0, $event['energy'] ?? 0, $event['stress'] ?? 0);
}
if ($scores === []) {
return 0.0;
}
return round(max(-8.0, min(8.0, (array_sum($scores) / count($scores)) * 4.0)), 1);
}
private function sleepDurationPoints(float $hours, array $points): float
{
if ($hours < 4) {
@@ -170,6 +262,19 @@ final class ScoringService
return $this->bandPoints((int) $entry['walk_minutes'], $scoring['walk_bands'] ?? []);
}
private function stepBonusPoints(array $entry, array $config): float
{
$steps = (int) ($entry['health']['steps'] ?? 0);
$min = max(0, (int) ($config['min'] ?? 10000));
$max = max($min, (int) ($config['max'] ?? 15000));
if ($steps > $min && $steps <= $max) {
return max(0.0, (float) ($config['points'] ?? 1));
}
return 0.0;
}
private function maxWalkPoints(array $entry, array $settings): float
{
$scoring = $settings['scoring'] ?? [];
@@ -186,6 +291,20 @@ final class ScoringService
return $this->maxBandPoints($scoring['walk_bands'] ?? []);
}
private function maxStepTargetPoints(array $targets): float
{
$max = 0.0;
foreach ($targets as $target) {
if (!is_array($target)) {
continue;
}
$max = max($max, (float) ($target['points'] ?? 0));
}
return $max;
}
private function stepTargetPoints(int $steps, array $targets): float
{
if ($targets === []) {
@@ -304,6 +423,205 @@ final class ScoringService
return $total;
}
private function normalizeSummary(mixed $summary): array
{
$summary = is_array($summary) ? $summary : [];
return [
'comment' => trim((string) ($summary['comment'] ?? '')),
'mood' => normalize_signal_value($summary['mood'] ?? 0),
'energy' => normalize_signal_value($summary['energy'] ?? 0),
'stress' => normalize_signal_value($summary['stress'] ?? 0),
'alcohol' => $this->normalizeBoolean($summary['alcohol'] ?? false),
];
}
private function normalizeEvents(mixed $events): array
{
if (!is_array($events)) {
return [];
}
$normalized = [];
foreach ($events as $event) {
if (!is_array($event)) {
continue;
}
$type = trim((string) ($event['type'] ?? 'event'));
if (!array_key_exists($type, day_event_type_options())) {
$type = 'event';
}
$time = trim((string) ($event['time'] ?? ''));
if (preg_match('/^(?:[01]\d|2[0-3]):[0-5]\d$/', $time) !== 1) {
$time = '';
}
$unit = trim((string) ($event['unit'] ?? day_event_type_unit($type)));
$value = is_numeric($event['value'] ?? null) ? (float) $event['value'] : 0.0;
$normalized[] = [
'id' => trim((string) ($event['id'] ?? '')) ?: ('evt-' . substr(sha1(json_encode($event) . microtime(true)), 0, 12)),
'type' => $type,
'time' => $time,
'comment' => trim(preg_replace('/\s+/u', ' ', (string) ($event['comment'] ?? '')) ?? ''),
'value' => max(0, min(50000, $value)),
'unit' => $unit,
'sport_type_id' => trim((string) ($event['sport_type_id'] ?? '')),
'image' => trim((string) ($event['image'] ?? '')),
'consumed' => $this->normalizeBoolean($event['consumed'] ?? true),
'mood' => normalize_signal_value($event['mood'] ?? 0),
'energy' => normalize_signal_value($event['energy'] ?? 0),
'stress' => normalize_signal_value($event['stress'] ?? 0),
'source' => trim((string) ($event['source'] ?? '')),
'import_id' => trim((string) ($event['import_id'] ?? '')),
'duration_label' => trim((string) ($event['duration_label'] ?? '')),
'distance_label' => trim((string) ($event['distance_label'] ?? '')),
'energy_label' => trim((string) ($event['energy_label'] ?? '')),
'heart_rate_label' => trim((string) ($event['heart_rate_label'] ?? '')),
'sleep_deep' => max(0.0, min(24.0, is_numeric($event['sleep_deep'] ?? null) ? (float) $event['sleep_deep'] : 0.0)),
'sleep_rem' => max(0.0, min(24.0, is_numeric($event['sleep_rem'] ?? null) ? (float) $event['sleep_rem'] : 0.0)),
'sleep_core' => max(0.0, min(24.0, is_numeric($event['sleep_core'] ?? null) ? (float) $event['sleep_core'] : 0.0)),
'route' => $this->normalizeRoute($event['route'] ?? []),
];
}
usort($normalized, static function (array $left, array $right): int {
return strcmp((string) ($left['time'] ?? ''), (string) ($right['time'] ?? ''));
});
return $normalized;
}
private function normalizeHealth(mixed $health): array
{
if (!is_array($health)) {
return [
'steps' => 0,
'steps_imported_at' => '',
];
}
return [
'steps' => max(0, min(100000, (int) ($health['steps'] ?? 0))),
'steps_imported_at' => trim((string) ($health['steps_imported_at'] ?? '')),
];
}
private function normalizeRoute(mixed $route): array
{
if (!is_array($route)) {
return [];
}
$points = [];
foreach ($route as $point) {
if (!is_array($point)) {
continue;
}
$lat = $point['lat'] ?? $point['latitude'] ?? null;
$lon = $point['lon'] ?? $point['longitude'] ?? null;
if (!is_numeric($lat) || !is_numeric($lon)) {
continue;
}
$lat = (float) $lat;
$lon = (float) $lon;
if ($lat < -90 || $lat > 90 || $lon < -180 || $lon > 180) {
continue;
}
$points[] = [
'lat' => round($lat, 6),
'lon' => round($lon, 6),
];
}
if (count($points) <= 180) {
return $points;
}
$step = max(1, (int) floor(count($points) / 180));
$reduced = [];
foreach ($points as $index => $point) {
if ($index % $step === 0) {
$reduced[] = $point;
}
}
$last = $points[count($points) - 1];
if ($reduced === [] || $reduced[count($reduced) - 1] !== $last) {
$reduced[] = $last;
}
return $reduced;
}
private function deriveLegacyFieldsFromEvents(array $events, array $summary, array $input): array
{
$sportMinutes = 0;
$walkMinutes = 0;
$walkSteps = 0;
$sleepHours = 0.0;
$alcohol = false;
$walkMode = $this->normalizeWalkMode((string) ($input['walk_mode'] ?? 'time'));
$sportTypes = [];
foreach ($events as $event) {
$type = (string) ($event['type'] ?? 'event');
$unit = (string) ($event['unit'] ?? '');
$value = (float) ($event['value'] ?? 0);
if ($type === 'sport') {
$sportMinutes += (int) round($value);
$sportTypeID = trim((string) ($event['sport_type_id'] ?? ''));
if ($sportTypeID !== '') {
$sportTypes[$sportTypeID] = true;
}
}
if ($type === 'walk') {
if ($unit === 'steps') {
$walkMode = 'steps';
$walkSteps += (int) round($value);
} else {
$walkMinutes += (int) round($value);
}
}
if ($type === 'sleep') {
$sleepHours += $unit === 'min' ? ($value / 60) : $value;
}
if ($type === 'alcohol') {
$alcohol = !empty($event['consumed']);
}
}
if (!empty($summary['alcohol'])) {
$alcohol = true;
}
return [
'mood' => signal_to_legacy_scale($summary['mood']),
'energy' => signal_to_legacy_scale($summary['energy']),
'stress' => signal_to_legacy_scale($summary['stress']),
'sleep_hours' => $sleepHours,
'sleep_feeling' => max(1, min(5, 3 + $summary['energy'])),
'sport_minutes' => $sportMinutes,
'walk_mode' => $walkMode,
'walk_minutes' => $walkMinutes,
'walk_steps' => $walkSteps,
'alcohol' => $alcohol,
'sport_types' => array_keys($sportTypes),
];
}
private function sortedRatings(array $ratings): array
{
usort($ratings, static fn (array $a, array $b): int => ((int) $a['min']) <=> ((int) $b['min']));
+252 -1
View File
@@ -38,7 +38,7 @@ final class UserRepository
public function verify(string $username, string $password): ?array
{
$user = $this->find($username);
$user = $this->find($username) ?? [];
if ($user === null) {
return null;
@@ -51,6 +51,257 @@ final class UserRepository
return $user;
}
public function findByRememberToken(string $selector, string $validator): ?array
{
$validatorHash = hash('sha256', $validator);
$now = time();
foreach ($this->all() as $user) {
$token = $user['remember_token'] ?? null;
if (!is_array($token) || !hash_equals((string) ($token['selector'] ?? ''), $selector)) {
continue;
}
$expiresAt = strtotime((string) ($token['expires_at'] ?? '')) ?: 0;
if ($expiresAt < $now) {
return null;
}
if (!hash_equals((string) ($token['validator_hash'] ?? ''), $validatorHash)) {
return null;
}
return $user;
}
return null;
}
public function storeRememberToken(string $username, string $selector, string $validatorHash, int $expiresAt): void
{
$normalized = normalize_username($username);
$users = $this->all();
$updated = false;
foreach ($users as &$user) {
if (($user['username'] ?? '') !== $normalized) {
continue;
}
$user['remember_token'] = [
'selector' => $selector,
'validator_hash' => $validatorHash,
'expires_at' => date(DATE_ATOM, $expiresAt),
'created_at' => date(DATE_ATOM),
];
$user['updated_at'] = date(DATE_ATOM);
$updated = true;
break;
}
unset($user);
if (!$updated) {
throw new RuntimeException('Der Remember-Me-Token konnte keinem Benutzer zugeordnet werden.');
}
$this->write(['users' => $users]);
}
public function clearRememberToken(string $username): void
{
$normalized = normalize_username($username);
$users = $this->all();
$updated = false;
foreach ($users as &$user) {
if (($user['username'] ?? '') !== $normalized || !array_key_exists('remember_token', $user)) {
continue;
}
unset($user['remember_token']);
$user['updated_at'] = date(DATE_ATOM);
$updated = true;
break;
}
unset($user);
if ($updated) {
$this->write(['users' => $users]);
}
}
public function findByHealthImportToken(string $token): ?array
{
$tokenHash = hash('sha256', $token);
foreach ($this->all() as $user) {
$config = $user['health_import'] ?? null;
if (!is_array($config) || empty($config['enabled'])) {
continue;
}
if (hash_equals((string) ($config['token_hash'] ?? ''), $tokenHash)) {
return $user;
}
}
return null;
}
public function healthImportConfig(string $username): array
{
$user = $this->find($username);
$config = is_array($user['health_import'] ?? null) ? $user['health_import'] : [];
return [
'enabled' => !empty($config['enabled']),
'token_prefix' => (string) ($config['token_prefix'] ?? ''),
'created_at' => (string) ($config['created_at'] ?? ''),
'last_import_at' => (string) ($config['last_import_at'] ?? ''),
'last_status' => (string) ($config['last_status'] ?? ''),
'last_message' => (string) ($config['last_message'] ?? ''),
'progress_done' => max(0, (int) ($config['progress_done'] ?? 0)),
'progress_total' => max(0, (int) ($config['progress_total'] ?? 0)),
'started_at' => (string) ($config['started_at'] ?? ''),
'updated_at' => (string) ($config['updated_at'] ?? ''),
'finished_at' => (string) ($config['finished_at'] ?? ''),
];
}
public function issueHealthImportToken(string $username): string
{
$token = 'mood_health_' . bin2hex(random_bytes(24));
$normalized = normalize_username($username);
$users = $this->all();
$updated = false;
foreach ($users as &$user) {
if (($user['username'] ?? '') !== $normalized) {
continue;
}
$currentConfig = is_array($user['health_import'] ?? null) ? $user['health_import'] : [];
$user['health_import'] = [
'enabled' => true,
'token_hash' => hash('sha256', $token),
'token_prefix' => substr($token, 0, 18),
'created_at' => date(DATE_ATOM),
'last_import_at' => (string) ($currentConfig['last_import_at'] ?? ''),
'last_status' => (string) ($currentConfig['last_status'] ?? ''),
'last_message' => (string) ($currentConfig['last_message'] ?? ''),
'progress_done' => max(0, (int) ($currentConfig['progress_done'] ?? 0)),
'progress_total' => max(0, (int) ($currentConfig['progress_total'] ?? 0)),
'started_at' => (string) ($currentConfig['started_at'] ?? ''),
'updated_at' => (string) ($currentConfig['updated_at'] ?? ''),
'finished_at' => (string) ($currentConfig['finished_at'] ?? ''),
];
$user['updated_at'] = date(DATE_ATOM);
$updated = true;
break;
}
unset($user);
if (!$updated) {
throw new RuntimeException('Der Health-Import-Token konnte keinem Benutzer zugeordnet werden.');
}
$this->write(['users' => $users]);
return $token;
}
public function revokeHealthImportToken(string $username): void
{
$normalized = normalize_username($username);
$users = $this->all();
$updated = false;
foreach ($users as &$user) {
if (($user['username'] ?? '') !== $normalized || !array_key_exists('health_import', $user)) {
continue;
}
unset($user['health_import']);
$user['updated_at'] = date(DATE_ATOM);
$updated = true;
break;
}
unset($user);
if ($updated) {
$this->write(['users' => $users]);
}
}
public function recordHealthImport(string $username, string $status, string $message): void
{
$normalized = normalize_username($username);
$users = $this->all();
$updated = false;
foreach ($users as &$user) {
if (($user['username'] ?? '') !== $normalized) {
continue;
}
$config = is_array($user['health_import'] ?? null) ? $user['health_import'] : [];
$config['last_import_at'] = date(DATE_ATOM);
$config['last_status'] = $status;
$config['last_message'] = substr($message, 0, 240);
$config['updated_at'] = date(DATE_ATOM);
if ($status !== 'running') {
$config['finished_at'] = date(DATE_ATOM);
if ($status === 'ok') {
$total = max(0, (int) ($config['progress_total'] ?? 0));
$config['progress_done'] = $total > 0 ? $total : max(0, (int) ($config['progress_done'] ?? 0));
}
}
$user['health_import'] = $config;
$user['updated_at'] = date(DATE_ATOM);
$updated = true;
break;
}
unset($user);
if ($updated) {
$this->write(['users' => $users]);
}
}
public function recordHealthImportProgress(string $username, string $message, int $done, int $total, ?string $startedAt = null): void
{
$normalized = normalize_username($username);
$users = $this->all();
$updated = false;
foreach ($users as &$user) {
if (($user['username'] ?? '') !== $normalized) {
continue;
}
$config = is_array($user['health_import'] ?? null) ? $user['health_import'] : [];
$config['last_status'] = 'running';
$config['last_message'] = substr($message, 0, 240);
$config['progress_done'] = max(0, min($done, max($total, 0)));
$config['progress_total'] = max(0, $total);
$config['started_at'] = $startedAt ?? (string) ($config['started_at'] ?? date(DATE_ATOM));
$config['updated_at'] = date(DATE_ATOM);
$config['finished_at'] = '';
$user['health_import'] = $config;
$user['updated_at'] = date(DATE_ATOM);
$updated = true;
break;
}
unset($user);
if ($updated) {
$this->write(['users' => $users]);
}
}
public function create(string $username, string $password, bool $isAdmin = false): array
{
$normalized = normalize_username($username);
+60 -1
View File
@@ -11,7 +11,7 @@ final class Auth
public function check(): bool
{
if (!isset($_SESSION['user']) || !is_array($_SESSION['user'])) {
return false;
return $this->attemptRememberedLogin();
}
$username = $_SESSION['user']['username'] ?? null;
@@ -62,17 +62,76 @@ final class Auth
$_SESSION['remember_me'] = $remember;
if ($remember) {
$this->issueRememberCookie($user['username']);
setcookie(session_name(), session_id(), session_cookie_options_for(time() + remember_me_lifetime()));
} else {
$this->users->clearRememberToken($user['username']);
$this->clearRememberCookie();
setcookie(session_name(), session_id(), session_cookie_options_for());
}
}
public function logout(): void
{
$username = $_SESSION['user']['username'] ?? null;
if (is_string($username) && $username !== '') {
$this->users->clearRememberToken($username);
}
unset($_SESSION['user']);
unset($_SESSION['remember_me']);
$this->clearRememberCookie();
setcookie(session_name(), '', session_cookie_options_for(time() - 3600));
session_regenerate_id(true);
}
private function attemptRememberedLogin(): bool
{
$cookie = $_COOKIE[remember_cookie_name()] ?? '';
if (!is_string($cookie) || $cookie === '') {
return false;
}
$parts = explode(':', $cookie, 2);
if (count($parts) !== 2) {
$this->clearRememberCookie();
return false;
}
[$selector, $validator] = $parts;
if (!ctype_xdigit($selector) || strlen($selector) !== 32 || !ctype_xdigit($validator) || strlen($validator) !== 64) {
$this->clearRememberCookie();
return false;
}
$user = $this->users->findByRememberToken($selector, $validator);
if ($user === null) {
$this->clearRememberCookie();
return false;
}
$this->login($user, true);
return true;
}
private function issueRememberCookie(string $username): void
{
$selector = bin2hex(random_bytes(16));
$validator = bin2hex(random_bytes(32));
$expiresAt = time() + remember_me_lifetime();
$this->users->storeRememberToken($username, $selector, hash('sha256', $validator), $expiresAt);
setcookie(remember_cookie_name(), $selector . ':' . $validator, session_cookie_options_for($expiresAt));
}
private function clearRememberCookie(): void
{
setcookie(remember_cookie_name(), '', session_cookie_options_for(time() - 3600));
}
}
+19 -1
View File
@@ -19,6 +19,19 @@ final class Defaults
'walk' => [
'mode' => 'time',
],
'sleep' => [
'optimal_hours' => 7.0,
],
'display' => [
'score_mode' => 'scale',
],
'day_balance' => [
'mood_weight' => 3,
'energy_weight' => 2,
'stress_weight' => 2,
'adjustment_cap' => 1.0,
'points_per_step' => 12,
],
'tracking' => [
'pain_enabled' => false,
],
@@ -88,7 +101,7 @@ final class Defaults
],
[
'id' => 'rowing',
'label' => 'Rudern',
'label' => 'Rudergerät',
'icon' => 'row',
'location' => '',
'recovery_group' => 'rudern',
@@ -152,6 +165,11 @@ final class Defaults
['steps' => 15000, 'points' => 4],
['steps' => 20000, 'points' => 0],
],
'step_bonus' => [
'min' => 10000,
'max' => 15000,
'points' => 1,
],
'journal_points' => 2,
'alcohol_penalty' => 5,
],
+191
View File
@@ -115,6 +115,23 @@ function format_points(float $value): string
return number_format($rounded, 1, ',', '.');
}
function format_duration_hours(float $hours): string
{
$minutes = max(0, (int) round($hours * 60));
$wholeHours = intdiv($minutes, 60);
$remainingMinutes = $minutes % 60;
if ($wholeHours <= 0) {
return $remainingMinutes . ' min';
}
if ($remainingMinutes === 0) {
return $wholeHours . ' h';
}
return $wholeHours . ' h ' . $remainingMinutes . ' min';
}
function normalize_username(string $username): string
{
return strtolower(trim($username));
@@ -335,6 +352,11 @@ function remember_me_lifetime(): int
return 60 * 60 * 24 * 30;
}
function remember_cookie_name(): string
{
return 'mood_remember';
}
function session_cookie_params_for(int $lifetime = 0): array
{
return [
@@ -634,3 +656,172 @@ function find_sport_types(array $settings, array $ids): array
return $types;
}
function signal_scale_options(): array
{
return [
-2 => 'sehr niedrig',
-1 => 'niedrig',
0 => 'neutral',
1 => 'hoch',
2 => 'sehr hoch',
];
}
function signal_labels_for_metric(string $metric): array
{
return match ($metric) {
'stress' => [
-2 => 'sehr ruhig',
-1 => 'ruhig',
0 => 'neutral',
1 => 'angespannt',
2 => 'sehr angespannt',
],
'energy' => [
-2 => 'leer',
-1 => 'matt',
0 => 'okay',
1 => 'wach',
2 => 'kraftvoll',
],
default => [
-2 => 'sehr niedrig',
-1 => 'niedrig',
0 => 'neutral',
1 => 'hoch',
2 => 'sehr hoch',
],
};
}
function normalize_signal_value(mixed $value): int
{
return max(-2, min(2, (int) $value));
}
function signal_to_legacy_scale(mixed $value): int
{
return match (normalize_signal_value($value)) {
-2 => 1,
-1 => 3,
0 => 5,
1 => 7,
2 => 9,
};
}
function legacy_to_signal_scale(mixed $value): int
{
$legacy = max(1, min(10, (int) $value));
return match (true) {
$legacy <= 2 => -2,
$legacy <= 4 => -1,
$legacy <= 6 => 0,
$legacy <= 8 => 1,
default => 2,
};
}
function day_event_type_options(): array
{
return [
'event' => [
'label' => 'Moment',
'icon' => '/assets/icons/activity-event.svg',
'unit' => '',
],
'walk' => [
'label' => 'Spaziergang',
'icon' => sport_icon_path('hike'),
'unit' => 'min',
],
'sport' => [
'label' => 'Sport',
'icon' => sport_icon_path('run'),
'unit' => 'min',
],
'sleep' => [
'label' => 'Schlaf',
'icon' => '/assets/icons/activity-sleep.svg',
'unit' => 'h',
],
'alcohol' => [
'label' => 'Alkohol',
'icon' => icon_path('alcohol'),
'unit' => '',
],
];
}
function day_event_type_label(string $type): string
{
return day_event_type_options()[$type]['label'] ?? day_event_type_options()['event']['label'];
}
function day_event_type_icon(string $type): string
{
return day_event_type_options()[$type]['icon'] ?? day_event_type_options()['event']['icon'];
}
function day_event_type_unit(string $type): string
{
return day_event_type_options()[$type]['unit'] ?? '';
}
function signal_badge_tone(int $value, string $metric): string
{
$value = normalize_signal_value($value);
if ($metric === 'stress') {
return match (true) {
$value <= -1 => 'good',
$value === 0 => 'neutral',
default => 'warn',
};
}
return match (true) {
$value <= -1 => 'warn',
$value === 0 => 'neutral',
default => 'good',
};
}
function signal_combo_score(mixed $mood, mixed $energy, mixed $stress): int
{
return max(-2, min(2, (int) round((
normalize_signal_value($mood) +
normalize_signal_value($energy) -
normalize_signal_value($stress)
) / 3)));
}
function day_entry_has_content(array $entry): bool
{
if (trim((string) ($entry['summary']['comment'] ?? '')) !== '') {
return true;
}
if (trim((string) ($entry['background_image'] ?? '')) !== '') {
return true;
}
if ((int) ($entry['health']['steps'] ?? 0) > 0) {
return true;
}
return count(array_filter(is_array($entry['events'] ?? null) ? $entry['events'] : [], 'is_array')) > 0;
}
function signal_value_class(int $value): string
{
return match (normalize_signal_value($value)) {
-2 => 'neg2',
-1 => 'neg1',
0 => 'zero',
1 => 'pos1',
2 => 'pos2',
};
}
+34 -30
View File
@@ -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,12 +11,15 @@ $brandSubtitle = match ($page) {
'setup' => 'Erstkonfiguration',
default => 'Stimmungstracker',
};
$immersiveDashboard = in_array($page, ['dashboard', 'options'], true);
$cssVersion = is_file(base_path('assets/css/app.css')) ? (string) filemtime(base_path('assets/css/app.css')) : '1';
$jsVersion = is_file(base_path('assets/js/app.js')) ? (string) filemtime(base_path('assets/js/app.js')) : '1';
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0b1e2e">
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet">
<meta name="apple-mobile-web-app-capable" content="yes">
@@ -33,15 +36,19 @@ $brandSubtitle = match ($page) {
<link rel="shortcut icon" href="/favicon.ico?v=20260412">
<link rel="apple-touch-icon" sizes="180x180" href="/assets/branding/apple-touch-icon.png?v=20260412">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="stylesheet" href="/assets/css/app.css">
<script defer src="/assets/js/app.js"></script>
<?php if (($page ?? '') === 'dashboard' && ($dashboardView ?? '') === 'day'): ?>
<link rel="prefetch" href="/?view=day&amp;date=<?= e(rawurlencode((string) ($dashboardPrevDate ?? shift_date(today(), -1)))) ?>">
<link rel="prefetch" href="/?view=day&amp;date=<?= e(rawurlencode((string) ($dashboardNextDate ?? shift_date(today(), 1)))) ?>">
<?php endif; ?>
<link rel="stylesheet" href="/assets/css/app.css?v=<?= e($cssVersion) ?>">
<script defer src="/assets/js/app.js?v=<?= e($jsVersion) ?>"></script>
</head>
<body class="app-body page-<?= e($page) ?><?= $authUser !== null ? ' is-authenticated' : '' ?><?= !empty($pageBodyClass) ? ' ' . e((string) $pageBodyClass) : '' ?>" data-authenticated="<?= $authUser !== null ? '1' : '0' ?>"<?= isset($trackMood) ? ' data-track-mood="' . e($trackMood) . '"' : '' ?>>
<body class="app-body page-<?= e($page) ?><?= $authUser !== null ? ' is-authenticated' : '' ?><?= !empty($pageBodyClass) ? ' ' . e((string) $pageBodyClass) : '' ?>" data-authenticated="<?= $authUser !== null ? '1' : '0' ?>"<?= isset($trackMood) ? ' data-track-mood="' . e($trackMood) . '"' : '' ?><?= isset($dashboardWalkMode) ? ' data-walk-mode="' . e((string) $dashboardWalkMode) . '"' : '' ?>>
<div class="aurora aurora-one"></div>
<div class="aurora aurora-two"></div>
<div class="pull-refresh-indicator glass-panel" data-pull-refresh-indicator aria-hidden="true">Zum Aktualisieren ziehen</div>
<div class="shell">
<?php if ($authUser !== null): ?>
<div class="shell<?= $immersiveDashboard ? ' shell--dashboard' : '' ?>">
<?php if ($authUser !== null && !$immersiveDashboard): ?>
<aside class="sidebar glass-panel">
<div class="brand-block">
<div class="brand-mark">
@@ -56,11 +63,7 @@ $brandSubtitle = match ($page) {
<nav class="main-nav" aria-label="Hauptnavigation">
<a class="<?= is_active_path('/') ? 'active' : '' ?>" href="/">
<img class="nav-icon" src="<?= e(icon_path('dashboard')) ?>" alt="">
<span>Dashboard</span>
</a>
<a class="<?= is_active_path('/track') ? 'active' : '' ?>" href="/track">
<img class="nav-icon" src="<?= e(icon_path('track')) ?>" alt="">
<span>Tracken</span>
<span>Start</span>
</a>
<a class="<?= is_active_path('/archive') ? 'active' : '' ?>" href="/archive">
<img class="nav-icon" src="<?= e(icon_path('archive')) ?>" alt="">
@@ -86,7 +89,7 @@ $brandSubtitle = match ($page) {
<?php endif; ?>
<main class="content">
<?php if ($authUser !== null): ?>
<?php if ($authUser !== null && !$immersiveDashboard): ?>
<header class="topbar glass-panel">
<div>
<?php if ($brandSubtitle !== ''): ?>
@@ -115,28 +118,29 @@ $brandSubtitle = match ($page) {
<?= $content ?>
<footer class="site-footer glass-panel">
<a class="site-footer__link" href="https://git.hnz.io/hnzio/mood-tracking/releases" target="_blank" rel="noreferrer">Version 1.2.1</a>
<a class="site-footer__link" href="https://hnz.io" target="_blank" rel="noreferrer">(c) 2026 @hnz.io</a>
</footer>
<?php if (!$immersiveDashboard): ?>
<footer class="site-footer glass-panel">
<a class="site-footer__link" href="https://git.hnz.io/hnzio/mood-tracking/releases" target="_blank" rel="noreferrer">Version 1.7.0</a>
<a class="site-footer__link" href="https://hnz.io" target="_blank" rel="noreferrer">(c) 2026 @hnz.io</a>
</footer>
<?php endif; ?>
</main>
<?php if ($authUser !== null): ?>
<nav class="mobile-nav glass-panel" aria-label="Mobile Hauptnavigation">
<a class="<?= is_active_path('/') ? 'active' : '' ?>" href="/">
<img class="nav-icon" src="<?= e(icon_path('dashboard')) ?>" alt="">
<span>Dashboard</span>
<nav class="ios-tabbar" aria-label="Mobile Navigation">
<a class="<?= $page === 'dashboard' && ($dashboardView ?? 'day') === 'day' ? 'active' : '' ?>" href="/?view=day&amp;date=<?= e(rawurlencode(today())) ?>">
<span class="ios-tabbar__icon" aria-hidden="true"></span>
<span>Heute</span>
</a>
<a class="<?= is_active_path('/track') ? 'active' : '' ?>" href="/track">
<img class="nav-icon" src="<?= e(icon_path('track')) ?>" alt="">
<span>Tracken</span>
<a class="<?= $page === 'dashboard' && ($dashboardView ?? '') === 'week' ? 'active' : '' ?>" href="/?view=week&amp;date=<?= e(rawurlencode(today())) ?>">
<span class="ios-tabbar__icon" aria-hidden="true"></span>
<span>Woche</span>
</a>
<a class="<?= is_active_path('/archive') ? 'active' : '' ?>" href="/archive">
<img class="nav-icon" src="<?= e(icon_path('archive')) ?>" alt="">
<span>Archiv</span>
<a class="<?= $page === 'dashboard' && ($dashboardView ?? '') === 'month' ? 'active' : '' ?>" href="/?view=month&amp;date=<?= e(rawurlencode(today())) ?>">
<span class="ios-tabbar__icon" aria-hidden="true"></span>
<span>Monat</span>
</a>
<a class="<?= is_active_path('/options') ? 'active' : '' ?>" href="/options">
<img class="nav-icon" src="<?= e(icon_path('options')) ?>" alt="">
<a class="<?= $page === 'options' ? 'active' : '' ?>" href="/options">
<span class="ios-tabbar__icon" aria-hidden="true"></span>
<span>Optionen</span>
</a>
</nav>
+703 -90
View File
@@ -1,99 +1,712 @@
<section class="hero-grid">
<article class="hero-card hero-card--wide glass-panel">
<p class="eyebrow">Stimmung im Blick</p>
<h3>Dein Dashboard verbindet Verlauf, Score und Bewegungsdaten in einer schnellen Übersicht.</h3>
<p class="hero-copy">Die Bewertung basiert auf deiner konfigurierbaren Logik. Sobald du die Regeln in den Optionen änderst, spiegeln Archiv und Statistiken die neue Gewichtung wider.</p>
</article>
<?php
$dayDateLabel = format_display_date((string) $dayEntry['date']);
$dayWeekday = strtok($dayDateLabel, ',');
$dayBackground = is_string($dayEntry['background_image_url'] ?? null) ? (string) $dayEntry['background_image_url'] : null;
$summaryComment = trim((string) ($dayEntry['summary']['comment'] ?? $dayEntry['summary_comment'] ?? ''));
if (preg_match('/^\s*-?\s*(?:Stimmung|Energie|Stress)\s*:\s*0\s*$/iu', $summaryComment) === 1) {
$summaryComment = '';
}
$summaryMood = normalize_signal_value($dayEntry['summary']['mood'] ?? $dayEntry['summary_mood'] ?? 0);
$summaryEnergy = normalize_signal_value($dayEntry['summary']['energy'] ?? $dayEntry['summary_energy'] ?? 0);
$summaryStress = normalize_signal_value($dayEntry['summary']['stress'] ?? $dayEntry['summary_stress'] ?? 0);
$summaryAlcohol = !empty($dayEntry['summary']['alcohol'] ?? $dayEntry['summary_alcohol'] ?? false);
$dayHealth = is_array($dayEntry['health'] ?? null) ? $dayEntry['health'] : [];
$daySteps = (int) ($dayHealth['steps'] ?? 0);
$dayStepBonus = (float) ($dayEntry['evaluation']['components']['step_bonus'] ?? 0);
$optimalSleepHours = max(1.0, min(16.0, (float) ($settings['sleep']['optimal_hours'] ?? 7.0)));
$formatBalanceValue = static function (?array $entry) use ($settings): string {
if ($entry === null) {
return '';
}
<article class="hero-card glass-panel">
<p class="eyebrow">Heute</p>
<?php if ($summary['today'] !== null): ?>
<div class="hero-score"><?= e(format_points((float) $summary['today']['evaluation']['total'])) ?></div>
<p class="hero-label"><?= e($summary['today']['evaluation']['label']) ?></p>
<?php else: ?>
<div class="hero-score">-</div>
<p class="hero-label">Noch kein Eintrag für heute</p>
<?php endif; ?>
</article>
</section>
$mode = (string) ($settings['display']['score_mode'] ?? 'scale');
$balance = is_array($entry['evaluation']['balance'] ?? null) ? $entry['evaluation']['balance'] : [];
if ($mode === 'points') {
return format_points((float) ($entry['evaluation']['total'] ?? 0)) . ' Punkte';
}
<section class="stats-grid">
<article class="metric-card glass-panel">
<span>Getrackte Tage</span>
<strong><?= e((string) $summary['tracked_days']) ?></strong>
</article>
<article class="metric-card glass-panel">
<span>Ø Score</span>
<strong><?= e(format_points((float) $summary['average_score'])) ?></strong>
</article>
<article class="metric-card glass-panel">
<span>Ø Stimmung</span>
<strong><?= e(format_points((float) $summary['average_mood'])) ?>/10</strong>
</article>
<article class="metric-card glass-panel">
<span>Ø Stress</span>
<strong><?= e(format_points((float) $summary['average_stress'])) ?>/10</strong>
</article>
<article class="metric-card glass-panel">
<span>Serie</span>
<strong><?= e((string) $summary['streak']) ?> Tage</strong>
</article>
</section>
if ($mode === 'percent') {
return format_points((float) ($balance['percentage'] ?? $entry['evaluation']['percentage'] ?? 0)) . ' %';
}
<section class="dashboard-grid">
<article class="glass-panel chart-card chart-card--calendar">
<div class="section-head">
<div>
<p class="eyebrow">Kalender</p>
<h3>Gesamtstimmung pro Tag</h3>
</div>
$level = max(-2, min(2, (int) ($balance['level'] ?? 0)));
return ($level > 0 ? '+' : '') . (string) $level;
};
?>
<section class="dashboard-shell<?= $dayBackground !== null ? ' dashboard-shell--with-image' : '' ?>" data-dashboard-root>
<?php if ($dayBackground !== null): ?>
<div class="dashboard-shell__background" aria-hidden="true">
<img src="<?= e($dayBackground) ?>" alt="">
</div>
<div id="calendar-heatmap" class="calendar-heatmap" data-payload="<?= e($chartPayload) ?>"></div>
</article>
<article class="glass-panel chart-card">
<div class="section-head">
<div>
<p class="eyebrow">Trend</p>
<h3>Tagesstimmung</h3>
</div>
<span class="chart-chip">letzte 30 Einträge</span>
</div>
<div class="line-chart" data-chart-type="line" data-series="mood" data-payload="<?= e($chartPayload) ?>"></div>
</article>
<article class="glass-panel chart-card">
<div class="section-head">
<div>
<p class="eyebrow">Belastung</p>
<h3>Stressverlauf</h3>
</div>
<span class="chart-chip chart-chip--warm">weniger ist besser</span>
</div>
<div class="line-chart" data-chart-type="line" data-series="stress" data-payload="<?= e($chartPayload) ?>"></div>
</article>
<?php if (!empty($settings['tracking']['pain_enabled'])): ?>
<article class="glass-panel chart-card">
<div class="section-head">
<div>
<p class="eyebrow">Körper</p>
<h3>Schmerzverlauf</h3>
</div>
<span class="chart-chip chart-chip--warm">weniger ist besser</span>
</div>
<div class="line-chart" data-chart-type="line" data-series="pain" data-payload="<?= e($chartPayload) ?>"></div>
</article>
<?php endif; ?>
<article class="glass-panel chart-card chart-card--wide">
<div class="section-head">
<div>
<p class="eyebrow">Aktivität</p>
<h3>Sport und Spaziergang</h3>
<header class="dashboard-topbar">
<nav class="dashboard-switcher glass-panel" aria-label="Ansicht wechseln">
<a class="<?= $dashboardView === 'day' && $dashboardDate === today() ? 'active' : '' ?>" href="/?view=day&amp;date=<?= e(rawurlencode(today())) ?>">Heute</a>
<a class="<?= $dashboardView === 'week' ? 'active' : '' ?>" href="/?view=week&amp;date=<?= e(rawurlencode(today())) ?>">Woche</a>
<a class="<?= $dashboardView === 'month' ? 'active' : '' ?>" href="/?view=month&amp;date=<?= e(rawurlencode(today())) ?>">Monat</a>
</nav>
<button class="dashboard-settings glass-panel" type="button" data-settings-menu-open aria-label="Optionen öffnen">
<img src="<?= e(icon_path('options')) ?>" alt="">
</button>
</header>
<?php if ($dashboardView === 'day'): ?>
<div class="dashboard-day" data-day-swipe data-prev-date="<?= e($dashboardPrevDate) ?>" data-next-date="<?= e($dashboardNextDate) ?>">
<div class="dashboard-day-slider" data-day-slider-shell>
<span class="day-slide-hint day-slide-hint--prev" data-day-slide-prev-hint><span class="day-slide-hint__arrow" aria-hidden="true"></span>Vorherigen Tag laden</span>
<span class="day-slide-hint day-slide-hint--next" data-day-slide-next-hint>Nächster Tag laden<span class="day-slide-hint__arrow" aria-hidden="true"></span></span>
<div class="dashboard-day__hero" data-day-slider>
<p class="dashboard-day__eyebrow"><?= e((string) $dayWeekday) ?></p>
<h1><?= e(format_display_date((string) $dayEntry['date'], false)) ?></h1>
<nav class="dashboard-compare-strip" aria-label="Tagesvergleich" data-day-strip>
<span class="score-scale score-scale--day" aria-hidden="true"><span>+2</span><span>+1</span><span>0</span><span>-1</span><span>-2</span></span>
<?php foreach ($dashboardCompareDays as $compareDay): ?>
<a class="compare-day offset-<?= e((string) ($compareDay['offset'] ?? 0)) ?><?= !empty($compareDay['is_current']) ? ' is-current' : '' ?><?= empty($compareDay['has_content']) ? ' is-empty' : '' ?>" href="/?view=day&amp;date=<?= e(rawurlencode((string) $compareDay['date'])) ?>">
<span class="compare-day__line<?= empty($compareDay['has_content']) ? ' is-empty' : '' ?><?= !empty($compareDay['is_current']) ? ' is-primary' : '' ?> score-<?= e((string) ($compareDay['score_level'] ?? 'empty')) ?> compare-tone-<?= e((string) ($compareDay['line_tone'] ?? 'empty')) ?>">
<span class="compare-day__marker"></span>
</span>
</a>
<?php endforeach; ?>
</nav>
</div>
</div>
<button class="day-summary-card glass-panel<?= $dashboardHasContent ? ' is-filled' : '' ?>" type="button" data-summary-overlay-open>
<span class="day-summary-card__label">Tagesbilanz</span>
<strong class="day-summary-card__title"><?= $summaryComment !== '' ? e($summaryComment) : 'Tagesbilanz' ?></strong>
<?php if ($daySteps > 0): ?>
<span class="day-summary-card__chips">
<span class="day-chip day-chip--score">Bilanz <?= e($formatBalanceValue($dayEntry)) ?></span>
<span class="day-chip"><?= e(number_format($daySteps, 0, ',', '.')) ?> Schritte</span>
<?php if ($dayStepBonus > 0): ?>
<span class="day-chip day-chip--bonus">+<?= e(format_points($dayStepBonus)) ?> Punkt</span>
<?php endif; ?>
</span>
<?php else: ?>
<span class="day-summary-card__chips"><span class="day-chip day-chip--score">Bilanz <?= e($formatBalanceValue($dayEntry)) ?></span></span>
<?php endif; ?>
</button>
<section class="dashboard-moments-block">
<div class="section-head section-head--compact section-head--dashboard">
<div>
<p class="eyebrow">Deine Momente</p>
<h2>Momente des Tages</h2>
</div>
</div>
<div class="timeline-list">
<?php if ($dashboardTimeline === []): ?>
<article class="timeline-card timeline-card--empty glass-panel">
<div class="timeline-card__body">
<h3>Noch keine Momente</h3>
<p>Du kannst auch vergangene Tage jederzeit nachtragen.</p>
</div>
</article>
<?php endif; ?>
<?php foreach ($dashboardTimeline as $item): ?>
<?php $eventType = (string) ($item['type'] ?? 'event'); ?>
<?php $eventComment = trim((string) ($item['comment'] ?? '')); ?>
<?php if (preg_match('/^\s*-\s*(?:Stimmung|Energie|Stress)\s*:\s*[+-]?\d+\s*$/u', $eventComment) === 1) { $eventComment = ''; } ?>
<?php $isImportedHealth = (string) ($item['source'] ?? '') === 'health_auto_export'; ?>
<?php $sportType = $eventType === 'sport' ? find_sport_type($settings, (string) ($item['sport_type_id'] ?? '')) : null; ?>
<?php $isImportedWalkSport = $isImportedHealth && $eventType === 'sport' && str_contains(strtolower((string) ($sportType['label'] ?? $eventComment)), 'spaziergang'); ?>
<?php $eventTone = signal_value_class(normalize_signal_value($item['mood'] ?? 0)); ?>
<?php $eventValueText = (float) $item['value'] > 0 ? ($eventType === 'sleep' ? format_duration_hours((float) $item['value']) : rtrim(rtrim(number_format((float) $item['value'], 2, ',', '.'), '0'), ',') . ' ' . (string) $item['unit']) : ''; ?>
<?php $eventTitle = match ($eventType) {
'sport' => $isImportedWalkSport ? 'Spaziergang' : (string) ($sportType['label'] ?? 'Sport'),
'walk' => 'Spaziergang',
'sleep' => 'Schlaf',
default => (string) ($item['comment'] !== '' ? $item['comment'] : day_event_type_label($eventType)),
}; ?>
<?php $eventDetail = match ($eventType) {
'sport' => trim($eventValueText),
'walk', 'sleep' => trim($eventValueText),
default => trim($eventValueText . ($sportType !== null ? ' · ' . (string) ($sportType['label'] ?? '') : '')),
}; ?>
<?php $showEventComment = in_array($eventType, ['sport', 'walk', 'sleep'], true) && $eventComment !== ''; ?>
<?php
$sleepPhases = ['deep' => (float) ($item['sleep_deep'] ?? 0), 'rem' => (float) ($item['sleep_rem'] ?? 0), 'core' => (float) ($item['sleep_core'] ?? 0)];
$sleepPhaseSource = trim($eventComment . ' ' . (string) ($item['duration_label'] ?? '') . ' ' . (string) ($item['distance_label'] ?? '') . ' ' . (string) ($item['energy_label'] ?? '') . ' ' . (string) ($item['heart_rate_label'] ?? ''));
if ($eventType === 'sleep' && array_sum($sleepPhases) <= 0 && $sleepPhaseSource !== '') {
if (preg_match('/(?:Tief|Tiefschlaf)\s*:?\s*([0-9]+(?:[,.][0-9]+)?)/u', $sleepPhaseSource, $match) === 1) {
$sleepPhases['deep'] = (float) str_replace(',', '.', $match[1]);
}
if (preg_match('/REM(?:-Schlaf)?\s*:?\s*([0-9]+(?:[,.][0-9]+)?)/u', $sleepPhaseSource, $match) === 1) {
$sleepPhases['rem'] = (float) str_replace(',', '.', $match[1]);
}
if (preg_match('/(?:Kern|Kernschlaf)\s*:?\s*([0-9]+(?:[,.][0-9]+)?)/u', $sleepPhaseSource, $match) === 1) {
$sleepPhases['core'] = (float) str_replace(',', '.', $match[1]);
}
}
$sleepPhaseTotal = max(0.0, array_sum($sleepPhases));
$sleepActualTotal = $eventType === 'sleep' ? max((float) ($item['value'] ?? 0), $sleepPhaseTotal) : 0.0;
$sleepBarTotal = $eventType === 'sleep' ? max($sleepActualTotal, $optimalSleepHours / 0.75) : 0.0;
$sleepUnclassified = max(0.0, $sleepActualTotal - $sleepPhaseTotal);
$sleepPhaseRemainder = max(0.0, $sleepBarTotal - $sleepActualTotal);
$sleepOptimalPercent = $sleepBarTotal > 0 ? max(0, min(100, ($optimalSleepHours / $sleepBarTotal) * 100)) : 0;
$sleepActualPercent = $sleepBarTotal > 0 ? max(0, min(100, ($sleepActualTotal / $sleepBarTotal) * 100)) : 0;
$sleepPhaseLeft = 0.0;
?>
<?php $eventStats = array_values(array_filter([
$eventType !== 'sleep' ? (string) ($item['duration_label'] ?? '') : '',
(string) ($item['distance_label'] ?? ''),
'',
(string) ($item['heart_rate_label'] ?? ''),
], static function (string $value): bool {
$value = trim($value);
return $value !== '' && !preg_match('/^-\s*(Distanz|Energie|Puls|Route|Tief|Tiefschlaf|REM|REM-Schlaf|Kern|Kernschlaf)(?:-?Label)?:?(?:\s*[0-9]+(?:[,.][0-9]+)?)?$/u', $value);
})); ?>
<?php $eventPayload = encode_payload([
'id' => (string) ($item['id'] ?? ''),
'type' => (string) ($item['type'] ?? 'event'),
'time' => (string) ($item['time'] ?? ''),
'comment' => (string) ($item['comment'] ?? ''),
'value' => (float) ($item['value'] ?? 0),
'unit' => (string) ($item['unit'] ?? ''),
'sport_type_id' => (string) ($item['sport_type_id'] ?? ''),
'image' => (string) ($item['image'] ?? ''),
'consumed' => !empty($item['consumed']),
'mood' => normalize_signal_value($item['mood'] ?? 0),
'energy' => normalize_signal_value($item['energy'] ?? 0),
'stress' => normalize_signal_value($item['stress'] ?? 0),
'source' => (string) ($item['source'] ?? ''),
'import_id' => (string) ($item['import_id'] ?? ''),
'duration_label' => (string) ($item['duration_label'] ?? ''),
'distance_label' => (string) ($item['distance_label'] ?? ''),
'energy_label' => (string) ($item['energy_label'] ?? ''),
'heart_rate_label' => (string) ($item['heart_rate_label'] ?? ''),
'sleep_deep' => (float) ($item['sleep_deep'] ?? 0),
'sleep_rem' => (float) ($item['sleep_rem'] ?? 0),
'sleep_core' => (float) ($item['sleep_core'] ?? 0),
]); ?>
<?php $hasEventImage = is_string($item['image_url'] ?? null); ?>
<?php $routeMap = is_array($item['route_map'] ?? null) ? $item['route_map'] : null; ?>
<article class="timeline-card timeline-card--event timeline-card--<?= e($eventTone) ?><?= $hasEventImage ? ' timeline-card--with-image' : '' ?> glass-panel" data-event-editable data-event-payload="<?= e($eventPayload) ?>">
<?php if ($hasEventImage): ?>
<button class="timeline-media-button" type="button" data-lightbox-src="<?= e((string) $item['image_url']) ?>" data-lightbox-kind="image" aria-label="Bild vergrößern">
<img class="timeline-card__image" src="<?= e((string) $item['image_url']) ?>" alt="">
</button>
<?php endif; ?>
<span class="timeline-card__time-chip"><?= e($item['time'] !== '' ? $item['time'] : '--:--') ?></span>
<div class="timeline-card__meta">
<div class="timeline-card__icon-wrap" title="<?= e(day_event_type_label((string) $item['type'])) ?>">
<img class="timeline-card__icon" src="<?= e(day_event_type_icon((string) $item['type'])) ?>" alt="">
</div>
<div>
<strong class="timeline-card__time"><?= e($item['time'] !== '' ? $item['time'] : '--:--') ?></strong>
</div>
</div>
<div class="timeline-card__body">
<h3><?= e($eventTitle) ?></h3>
<?php if ($showEventComment): ?>
<p class="timeline-card__comment"><?= e($eventComment) ?></p>
<?php endif; ?>
<?php if ((string) ($item['type'] ?? '') === 'alcohol'): ?>
<p class="timeline-card__value">
<?= !empty($item['consumed']) ? 'Heute getrunken' : 'Heute nicht getrunken' ?>
</p>
<?php elseif ($eventDetail !== ''): ?>
<p class="timeline-card__value"><?= e($eventDetail) ?></p>
<?php endif; ?>
<?php if ((string) ($item['comment'] ?? '') !== '' && (string) ($item['type'] ?? '') === 'alcohol'): ?>
<p class="timeline-card__value"><?= e((string) $item['comment']) ?></p>
<?php endif; ?>
<?php if ($eventType === 'sleep' && $sleepActualTotal > 0): ?>
<div class="sleep-phase-bar" aria-label="Schlafdauer" style="--sleep-optimal-left: <?= e((string) $sleepOptimalPercent) ?>%; --sleep-actual-width: <?= e((string) $sleepActualPercent) ?>%">
<span class="sleep-phase-bar__fill" aria-hidden="true"></span>
<span class="sleep-phase-bar__target"><span><?= e(format_duration_hours($optimalSleepHours)) ?></span></span>
<?php if ($sleepPhaseRemainder > 0): ?>
<span class="sleep-phase-bar__rest-label">noch <?= e(format_duration_hours($sleepPhaseRemainder)) ?> bis Skalenende</span>
<?php endif; ?>
</div>
<?php if ($sleepPhaseTotal > 0): ?>
<div class="sleep-phase-legend" aria-label="Schlafphasen">
<?php foreach (['deep' => ['Tief', 'deep'], 'rem' => ['REM', 'rem'], 'core' => ['Kern', 'core']] as $phase => [$label, $class]): ?>
<?php $phaseHours = max(0.0, (float) ($sleepPhases[$phase] ?? 0)); ?>
<?php if ($phaseHours <= 0) { continue; } ?>
<span class="sleep-phase-legend__item sleep-phase-legend__item--<?= e($class) ?>"><strong><?= e($label) ?></strong> <?= e(format_duration_hours($phaseHours)) ?></span>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php endif; ?>
<?php if ($eventStats !== []): ?>
<div class="timeline-card__stats" aria-label="Importdetails">
<?php foreach ($eventStats as $stat): ?>
<span><?= e($stat) ?></span>
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="signal-row">
<?php foreach (['mood' => 'Stimmung', 'energy' => 'Energie', 'stress' => 'Stress'] as $metric => $label): ?>
<?php $value = normalize_signal_value($item[$metric] ?? 0); ?>
<?php $valueTone = signal_value_class($metric === 'stress' ? -$value : $value); ?>
<?php if ($value === 0) { continue; } ?>
<span class="signal-pill signal-pill--<?= e(signal_badge_tone($value, $metric)) ?> signal-pill--<?= e($valueTone) ?>">
<strong><?= e($label) ?></strong>
<img class="signal-pill__icon" src="<?= e(icon_path($metric === 'mood' ? 'signal-mood' : ($metric === 'energy' ? 'signal-energy' : 'signal-stress'))) ?>" alt="<?= e($label) ?>">
<span><?= $value >= 0 ? '+' : '' ?><?= e((string) $value) ?></span>
</span>
<?php endforeach; ?>
</div>
</div>
<?php if ($routeMap !== null): ?>
<button class="timeline-route-map" type="button" data-lightbox-kind="html" aria-label="Route vergrößern">
<svg viewBox="0 0 <?= e((string) $routeMap['width']) ?> <?= e((string) $routeMap['height']) ?>" aria-hidden="true">
<?php foreach ($routeMap['tiles'] as $tile): ?>
<image href="<?= e((string) $tile['url']) ?>" x="<?= e((string) $tile['left']) ?>" y="<?= e((string) $tile['top']) ?>" width="256" height="256"></image>
<?php endforeach; ?>
<polyline points="<?= e((string) $routeMap['line']) ?>"></polyline>
</svg>
<span class="timeline-route-map__credit">© OpenStreetMap</span>
</button>
<?php endif; ?>
<form method="post" action="/" class="timeline-card__delete">
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="delete_event">
<input type="hidden" name="date" value="<?= e((string) $dayEntry['date']) ?>">
<input type="hidden" name="event_id" value="<?= e((string) $item['id']) ?>">
<button class="ghost-button ghost-button--small" type="submit" data-confirm-delete aria-label="Moment löschen">×</button>
</form>
</article>
<?php endforeach; ?>
</div>
</section>
<button class="dashboard-fab" type="button" data-moment-overlay-open aria-label="Neuen Moment hinzufügen">+</button>
<div class="dashboard-fab-menu glass-panel" data-fab-menu hidden>
<?php foreach ($dashboardEventTypes as $type => $meta): ?>
<?php if ($type === 'alcohol') { continue; } ?>
<button type="button" data-fab-moment-choice="<?= e($type) ?>">
<img src="<?= e((string) $meta['icon']) ?>" alt="">
<span><?= e((string) $meta['label']) ?></span>
</button>
<?php endforeach; ?>
</div>
<span class="chart-chip chart-chip--cool">Aktivität pro Tag</span>
</div>
<div class="bar-chart" data-chart-type="bars" data-series="sport" data-payload="<?= e($chartPayload) ?>"></div>
</article>
<div class="dashboard-overlay" data-summary-overlay hidden>
<div class="dashboard-overlay__backdrop" data-summary-overlay-close></div>
<section class="dashboard-modal glass-panel dashboard-modal--summary" role="dialog" aria-modal="true">
<div class="dashboard-modal__controls">
<button class="dashboard-modal__round" type="button" data-summary-overlay-close>×</button>
<button class="dashboard-modal__round dashboard-modal__round--confirm" type="submit" form="day-summary-form">✓</button>
</div>
<form method="post" action="/" enctype="multipart/form-data" class="dashboard-modal__form" id="day-summary-form">
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="save_day_summary">
<input type="hidden" name="date" value="<?= e((string) $dayEntry['date']) ?>">
<h2 class="dashboard-modal__title"><?= e(format_display_date((string) $dayEntry['date'], false)) ?></h2>
<p class="dashboard-modal__subtitle">Deine Tagesbilanz</p>
<label class="dashboard-modal__textarea">
<textarea name="summary_comment" rows="5" placeholder="Fasse deinen Tag zusammen"><?= e($summaryComment) ?></textarea>
</label>
<div class="overlay-signal-grid overlay-signal-grid--summary-row">
<?php foreach (['summary_mood' => ['Stimmung', $summaryMood], 'summary_energy' => ['Energie', $summaryEnergy], 'summary_stress' => ['Stress', $summaryStress]] as $field => [$label, $value]): ?>
<?php $metric = str_replace('summary_', '', $field); ?>
<div class="overlay-signal-card overlay-signal-card--inline" data-stepper data-stepper-metric="<?= e($metric) ?>">
<div>
<h3><?= e($label) ?></h3>
<p data-stepper-label>
<?= e(signal_labels_for_metric($metric)[$value]) ?>
</p>
</div>
<div class="overlay-signal-card__control">
<div class="overlay-signal-card__ring tone-<?= e(signal_value_class($value)) ?>">
<span data-stepper-value><?= $value >= 0 ? '+' : '' ?><?= e((string) $value) ?></span>
</div>
<div class="overlay-signal-card__buttons">
<button type="button" data-stepper-minus>-</button>
<button type="button" data-stepper-plus>+</button>
</div>
<div class="signal-scale" aria-hidden="true"><span>-2</span><span>-1</span><span>0</span><span>+1</span><span>+2</span></div>
<input type="hidden" name="<?= e($field) ?>" value="<?= e((string) $value) ?>" data-stepper-input>
</div>
</div>
<?php endforeach; ?>
</div>
<fieldset class="moment-alcohol-field moment-alcohol-field--summary">
<legend>Alkohol</legend>
<div class="moment-choice-row">
<label class="moment-choice-pill">
<input type="radio" name="summary_alcohol" value="1" <?= $summaryAlcohol ? 'checked' : '' ?>>
<span>Ja</span>
</label>
<label class="moment-choice-pill">
<input type="radio" name="summary_alcohol" value="0" <?= !$summaryAlcohol ? 'checked' : '' ?>>
<span>Nein</span>
</label>
</div>
</fieldset>
<label>
<span>Tagesbild</span>
<input type="file" name="background_image" accept="image/jpeg,image/png,image/webp">
</label>
</form>
<?php if ($dayBackground !== null): ?>
<form method="post" action="/" class="dashboard-modal__secondary-action">
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="remove_background">
<input type="hidden" name="date" value="<?= e((string) $dayEntry['date']) ?>">
<button class="ghost-button" type="submit">Bild entfernen</button>
</form>
<?php endif; ?>
</section>
</div>
<div class="dashboard-overlay" data-moment-overlay hidden>
<div class="dashboard-overlay__backdrop" data-moment-overlay-close></div>
<section class="dashboard-modal glass-panel dashboard-modal--moment" role="dialog" aria-modal="true" data-moment-modal>
<div class="dashboard-modal__controls">
<button class="dashboard-modal__round" type="button" data-moment-overlay-close>×</button>
<button class="dashboard-modal__round dashboard-modal__round--confirm" type="submit" form="moment-form" data-moment-submit disabled>✓</button>
</div>
<div data-moment-step="choose">
<h2 class="dashboard-modal__title">Neuer Moment</h2>
<div class="moment-type-grid">
<?php foreach ($dashboardEventTypes as $type => $meta): ?>
<?php if ($type === 'alcohol') { continue; } ?>
<button class="moment-type-card" type="button" data-moment-type-choice="<?= e($type) ?>">
<img src="<?= e((string) $meta['icon']) ?>" alt="">
<span><?= e((string) $meta['label']) ?></span>
</button>
<?php endforeach; ?>
</div>
</div>
<form method="post" action="/" enctype="multipart/form-data" class="dashboard-modal__form" id="moment-form" data-moment-step="form" hidden>
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="add_event" data-moment-form-name>
<input type="hidden" name="date" value="<?= e((string) $dayEntry['date']) ?>">
<input type="hidden" name="event_id" value="" data-moment-event-id>
<input type="hidden" name="event_type" value="event" data-moment-type-input>
<input type="hidden" name="event_unit" value="" data-event-unit>
<input type="hidden" name="event_walk_mode" value="time" data-walk-mode-input>
<div class="dashboard-modal__heading-row">
<div>
<p class="dashboard-modal__subtitle" data-moment-type-label>Neuer Moment</p>
<h2 class="dashboard-modal__title">Was ist passiert?</h2>
</div>
<button class="ghost-button ghost-button--small" type="button" data-moment-back>Typ ändern</button>
</div>
<label class="dashboard-modal__textarea">
<textarea name="event_comment" rows="4" placeholder="Was hast du erlebt?" data-moment-comment></textarea>
</label>
<label>
<span>Momentbild</span>
<input type="file" name="event_image" accept="image/jpeg,image/png,image/webp">
</label>
<div class="field-grid field-grid--two">
<label>
<span>Erfasst um</span>
<input type="time" name="event_time" value="<?= e(date('H:i')) ?>" required>
</label>
<label data-moment-value-field>
<span data-moment-value-label>Wert</span>
<input type="number" name="event_value" min="0" max="50000" step="0.01" placeholder="optional" data-moment-value-input>
</label>
</div>
<fieldset data-moment-sport-field hidden>
<legend>Sportart</legend>
<input type="hidden" name="event_sport_type_id" value="">
<div class="moment-type-grid moment-type-grid--sport">
<?php foreach ($dashboardSportTypes as $sportType): ?>
<button class="moment-type-card moment-type-card--sport" type="button" data-sport-choice="<?= e((string) ($sportType['id'] ?? '')) ?>">
<img src="<?= e(sport_icon_path((string) ($sportType['icon'] ?? 'run'))) ?>" alt="">
<span><?= e((string) ($sportType['label'] ?? '')) ?></span>
</button>
<?php endforeach; ?>
</div>
</fieldset>
<fieldset class="moment-alcohol-field" data-moment-walk-field hidden>
<legend>Spaziergang als</legend>
<div class="moment-choice-row">
<label class="moment-choice-pill"><input type="radio" name="event_walk_mode" value="time" checked><span>Dauer</span></label>
<label class="moment-choice-pill"><input type="radio" name="event_walk_mode" value="steps"><span>Schritte</span></label>
</div>
</fieldset>
<fieldset class="moment-alcohol-field" data-moment-alcohol-field hidden>
<legend>Heute Alkohol getrunken?</legend>
<div class="moment-choice-row">
<label class="moment-choice-pill">
<input type="radio" name="event_consumed" value="1" checked>
<span>Ja</span>
</label>
<label class="moment-choice-pill">
<input type="radio" name="event_consumed" value="0">
<span>Nein</span>
</label>
</div>
</fieldset>
<div class="overlay-signal-grid overlay-signal-grid--summary-row overlay-signal-grid--moment">
<?php foreach (['event_mood' => ['Stimmung', 0], 'event_energy' => ['Energie', 0], 'event_stress' => ['Stress', 0]] as $field => [$label, $value]): ?>
<?php $metric = str_replace('event_', '', $field); ?>
<div class="overlay-signal-card overlay-signal-card--inline overlay-signal-card--moment" data-stepper data-stepper-metric="<?= e($metric) ?>">
<div>
<h3><?= e($label) ?></h3>
</div>
<div class="overlay-signal-card__control">
<div class="overlay-signal-card__ring tone-zero">
<span data-stepper-value><?= $value >= 0 ? '+' : '' ?><?= e((string) $value) ?></span>
</div>
<div class="overlay-signal-card__buttons">
<button type="button" data-stepper-minus>-</button>
<button type="button" data-stepper-plus>+</button>
</div>
<div class="signal-scale" aria-hidden="true"><span>-2</span><span>-1</span><span>0</span><span>+1</span><span>+2</span></div>
<input type="hidden" name="<?= e($field) ?>" value="<?= e((string) $value) ?>" data-stepper-input>
</div>
</div>
<?php endforeach; ?>
</div>
</form>
<form method="post" action="/" class="dashboard-modal__secondary-action" data-moment-delete-form hidden>
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="delete_event">
<input type="hidden" name="date" value="<?= e((string) $dayEntry['date']) ?>">
<input type="hidden" name="event_id" value="" data-moment-delete-id>
<button class="ghost-button" type="submit">Moment löschen</button>
</form>
</section>
</div>
<?php elseif ($dashboardView === 'week'): ?>
<section class="dashboard-range-view dashboard-range-view--week">
<header class="dashboard-range-view__hero">
<p class="eyebrow">Wochenansicht</p>
<h1><?= e($dashboardWeek['title']) ?></h1>
<h2><?= e($dashboardWeek['range']) ?></h2>
</header>
<?php $weekInsights = is_array($dashboardWeek['insights'] ?? null) ? $dashboardWeek['insights'] : []; ?>
<?php if ((int) ($weekInsights['average_steps'] ?? 0) > 0 || (int) ($weekInsights['daily_sport_minutes'] ?? 0) > 0): ?>
<section class="week-insight-card glass-panel">
<?php if ((int) ($weekInsights['average_steps'] ?? 0) > 0): ?>
<p>
Du bist in dieser Woche durchschnittlich <strong><?= e(number_format((int) $weekInsights['average_steps'], 0, ',', '.')) ?> Schritte</strong> gegangen.
<?php if (!empty($weekInsights['has_step_comparison'])): ?>
Das sind <strong><?= e(number_format(abs((int) $weekInsights['step_difference']), 0, ',', '.')) ?> Schritte <?= e((string) $weekInsights['step_direction']) ?></strong> als im vergangenen Monat.
<?php endif; ?>
</p>
<?php endif; ?>
<?php if ((int) ($weekInsights['daily_sport_minutes'] ?? 0) > 0): ?>
<p>Täglich hast du im Schnitt <strong><?= e((string) $weekInsights['daily_sport_minutes']) ?> Minuten Sport</strong> gemacht.</p>
<?php endif; ?>
</section>
<?php endif; ?>
<div class="range-period-rail range-period-rail--week">
<?php foreach (($dashboardWeek['periods'] ?? [$dashboardWeek]) as $week): ?>
<article class="range-period-panel<?= !empty($week['is_selected']) ? ' is-selected' : '' ?>">
<header class="range-period-panel__head">
<a href="/?view=week&amp;date=<?= e(rawurlencode((string) ($week['key'] ?? $dashboardDate))) ?>">
<h3><?= e((string) $week['title']) ?></h3>
<p><?= e((string) $week['range']) ?></p>
</a>
</header>
<nav class="range-score-strip range-score-strip--week glass-panel" aria-label="Tage dieser Woche">
<span class="score-scale score-scale--range" aria-hidden="true"><span>+2</span><span>+1</span><span>0</span><span>-1</span><span>-2</span></span>
<?php foreach ($week['days'] as $day): ?>
<a class="range-score-day<?= !empty($day['is_current']) ? ' is-current' : '' ?><?= empty($day['has_content']) ? ' is-empty' : '' ?>" href="/?view=week&amp;date=<?= e(rawurlencode((string) $day['date'])) ?>" title="<?= e((string) $day['weekday']) ?>">
<span class="compare-day__line<?= !empty($day['is_current']) ? ' is-primary' : '' ?> score-<?= e((string) ($day['score_level'] ?? 'empty')) ?> compare-tone-<?= e((string) ($day['line_tone'] ?? 'empty')) ?>">
<span class="compare-day__marker"></span>
</span>
<span class="range-score-day__label"><?= e((string) $day['day']) ?></span>
</a>
<?php endforeach; ?>
</nav>
</article>
<?php endforeach; ?>
</div>
<?php $weekDetailDays = array_values(array_reverse(array_filter($dashboardWeek['days'], static fn (array $day): bool => !empty($day['has_content'])))); ?>
<?php if ($weekDetailDays !== []): ?>
<div class="range-day-list">
<?php foreach ($weekDetailDays as $day): ?>
<?php
$entry = is_array($day['entry'] ?? null) ? $day['entry'] : null;
$events = $entry !== null && is_array($entry['events'] ?? null) ? $entry['events'] : [];
$summaryText = $entry !== null ? trim((string) ($entry['summary']['comment'] ?? $entry['summary_comment'] ?? $entry['note'] ?? '')) : '';
$dayTone = (string) ($day['line_tone'] ?? 'empty');
$dayImage = $entry !== null && is_string($entry['background_image_url'] ?? null) ? (string) $entry['background_image_url'] : null;
?>
<a class="range-day-card range-day-card--<?= e($dayTone) ?><?= empty($day['has_content']) ? ' is-empty' : '' ?> glass-panel" href="/?view=day&amp;date=<?= e(rawurlencode((string) $day['date'])) ?>">
<?php if ($dayImage !== null): ?>
<img class="range-day-card__image" src="<?= e($dayImage) ?>" alt="">
<?php endif; ?>
<div class="range-day-card__body">
<p class="eyebrow"><?= e((string) $day['weekday']) ?></p>
<p class="range-day-card__score">Bilanz <?= e($formatBalanceValue($entry)) ?></p>
<p class="range-day-card__summary"><?= $summaryText !== '' ? e($summaryText) : 'Noch keine Tagesbilanz.' ?></p>
<?php if ($events !== []): ?>
<ul class="range-moment-list">
<?php foreach ($events as $event): ?>
<?php if (!is_array($event)) { continue; } ?>
<?php
$eventType = (string) ($event['type'] ?? 'event');
$eventScore = signal_combo_score($event['mood'] ?? 0, $event['energy'] ?? 0, $event['stress'] ?? 0);
$eventTone = signal_value_class($eventScore);
$sportType = $eventType === 'sport' ? find_sport_type($settings, (string) ($event['sport_type_id'] ?? '')) : null;
$eventValue = (float) ($event['value'] ?? 0);
$eventValueText = $eventValue > 0 ? ($eventType === 'sleep' ? format_duration_hours($eventValue) : rtrim(rtrim(number_format($eventValue, 2, ',', '.'), '0'), ',') . ' ' . (string) ($event['unit'] ?? '')) : '';
$eventComment = trim((string) ($event['comment'] ?? ''));
if (preg_match('/^\s*-\s*(?:Stimmung|Energie|Stress)\s*:\s*[+-]?\d+\s*$/u', $eventComment) === 1) {
$eventComment = '';
}
$eventTitle = day_event_type_label($eventType);
$eventDetails = array_values(array_filter([$eventValueText, $eventComment], static fn (string $value): bool => trim($value) !== ''));
if ($eventType === 'sport') {
$eventTitle = (string) ($sportType['label'] ?? 'Sport');
}
if ($eventType === 'sleep') {
$eventTitle = 'Schlaf';
} elseif ($eventType === 'walk') {
$eventTitle = 'Spaziergang';
}
?>
<li class="range-moment-list__item range-moment-list__item--<?= e($eventTone) ?>">
<span class="range-moment-list__bullet" aria-hidden="true"></span>
<span>
<strong><?= e($eventTitle) ?></strong>
<?php foreach ($eventDetails as $eventDetail): ?>
<span><?= e($eventDetail) ?></span>
<?php endforeach; ?>
</span>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
<?php else: ?>
<section class="dashboard-range-view dashboard-range-view--month">
<header class="dashboard-range-view__hero">
<p class="eyebrow">Monatsansicht</p>
<h1><?= e($dashboardMonth['title']) ?></h1>
</header>
<div class="range-period-rail range-period-rail--month">
<?php foreach (($dashboardMonth['periods'] ?? [$dashboardMonth]) as $month): ?>
<article class="range-period-panel<?= !empty($month['is_selected']) ? ' is-selected' : '' ?>">
<header class="range-period-panel__head">
<a href="/?view=month&amp;date=<?= e(rawurlencode((string) ($month['key'] ?? $dashboardDate))) ?>">
<h3><?= e((string) $month['title']) ?></h3>
</a>
</header>
<nav class="range-score-strip range-score-strip--month glass-panel" aria-label="Tage dieses Monats">
<span class="score-scale score-scale--range score-scale--month" aria-hidden="true"><span>+2</span><span>+1</span><span>0</span><span>-1</span><span>-2</span></span>
<?php foreach ($month['days'] as $day): ?>
<a class="range-score-day<?= empty($day['has_content']) ? ' is-empty' : '' ?><?= !empty($day['is_future']) ? ' is-future' : '' ?>" href="/?view=month&amp;date=<?= e(rawurlencode((string) $day['date'])) ?>" title="<?= e((string) $day['weekday']) ?>">
<span class="compare-day__line score-<?= e((string) ($day['score_level'] ?? 'empty')) ?> compare-tone-<?= e((string) ($day['line_tone'] ?? 'empty')) ?>">
<span class="compare-day__marker"></span>
</span>
</a>
<?php endforeach; ?>
</nav>
</article>
<?php endforeach; ?>
</div>
<?php $monthDetailDays = array_values(array_filter($dashboardMonth['days'], static function (array $day): bool {
$entry = is_array($day['entry'] ?? null) ? $day['entry'] : null;
$summaryText = $entry !== null ? trim((string) ($entry['summary']['comment'] ?? $entry['summary_comment'] ?? $entry['note'] ?? '')) : '';
return !empty($day['has_content']) || $summaryText !== '';
})); ?>
<?php $monthDetailDays = array_reverse($monthDetailDays); ?>
<?php if ($monthDetailDays !== []): ?>
<div class="range-day-list range-day-list--month">
<?php foreach ($monthDetailDays as $day): ?>
<?php
$entry = is_array($day['entry'] ?? null) ? $day['entry'] : null;
$summaryText = $entry !== null ? trim((string) ($entry['summary']['comment'] ?? $entry['summary_comment'] ?? $entry['note'] ?? '')) : '';
$dayTone = (string) ($day['line_tone'] ?? 'empty');
?>
<a class="range-day-card range-day-card--summary-only range-day-card--<?= e($dayTone) ?> glass-panel" href="/?view=day&amp;date=<?= e(rawurlencode((string) $day['date'])) ?>">
<div class="range-day-card__body">
<p class="eyebrow"><?= e((string) $day['weekday']) ?></p>
<p class="range-day-card__score">Bilanz <?= e($formatBalanceValue($entry)) ?></p>
<p class="range-day-card__summary"><?= $summaryText !== '' ? e($summaryText) : 'Tagesbilanz' ?></p>
</div>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
<?php endif; ?>
<div class="dashboard-overlay" data-settings-menu-overlay hidden>
<div class="dashboard-overlay__backdrop" data-settings-menu-close></div>
<section class="dashboard-modal dashboard-modal--settings glass-panel" role="dialog" aria-modal="true">
<div class="dashboard-modal__controls">
<button class="dashboard-modal__round" type="button" data-settings-menu-close>×</button>
</div>
<h2 class="dashboard-modal__title">Einstellungen und Bereiche</h2>
<div class="settings-menu-grid">
<a class="options-menu-card" href="/options?panel=sports"><strong>Sportarten anpassen</strong><span>Eigene Sportarten und Bonuspunkte</span></a>
<a class="options-menu-card" href="/options?panel=walk"><strong>Spaziergang anpassen</strong><span>Zeit oder Schritte auswerten</span></a>
<a class="options-menu-card" href="/options?panel=sleep"><strong>Schlaf anpassen</strong><span>Optimale Schlafmenge</span></a>
<a class="options-menu-card" href="/options?panel=health"><strong>Health Import</strong><span>Apple Health automatisch übernehmen</span></a>
<a class="options-menu-card" href="/options?panel=reminders"><strong>Erinnerungen setzen</strong><span>Push und tägliche Erinnerung</span></a>
<a class="options-menu-card" href="/options?panel=ratings"><strong>Bewertungsskala ändern</strong><span>Labels und Schutzregeln</span></a>
<a class="options-menu-card" href="/options?panel=stats"><strong>Statistik</strong><span>Verlauf und Aktivität</span></a>
<?php if (!empty($authUser['is_admin'])): ?>
<a class="options-menu-card" href="/options?panel=users"><strong>Neue Nutzer anlegen</strong><span>Accounts und Adminrechte</span></a>
<?php endif; ?>
<form method="post" action="/logout" class="options-logout-form">
<?= csrf_field() ?>
<button class="options-menu-card options-menu-card--danger" type="submit"><strong>Abmelden</strong><span>Sitzung sicher beenden</span></button>
</form>
</div>
</section>
</div>
<div class="media-lightbox" data-media-lightbox hidden>
<button class="media-lightbox__backdrop" type="button" data-media-lightbox-close aria-label="Ansicht schließen"></button>
<div class="media-lightbox__panel" role="dialog" aria-modal="true" aria-label="Medienansicht">
<button class="media-lightbox__close" type="button" data-media-lightbox-close aria-label="Ansicht schließen">×</button>
<div class="media-lightbox__content" data-media-lightbox-content></div>
</div>
</div>
</section>
+275 -417
View File
@@ -1,449 +1,307 @@
<section class="page-grid">
<article class="glass-panel form-panel form-panel--wide">
<div class="section-head">
<div>
<p class="eyebrow">Dein Account</p>
<h3>Score und Sportarten persönlich anpassen</h3>
<p class="helper-text">Alle Einstellungen auf dieser Seite gelten nur für deinen eigenen Account und beeinflussen keine anderen Nutzer.</p>
</div>
<span class="chart-chip">Maximal <?= e(format_points((float) $maxScore)) ?> Punkte</span>
</div>
<form method="post" action="/options" class="stack-form stack-form--spacious">
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="settings">
<div class="settings-section">
<h4>Multiplikatoren</h4>
<div class="field-grid field-grid--four">
<label><span>Stimmung</span><input type="number" name="settings[scoring][mood_multiplier]" value="<?= e((string) $settings['scoring']['mood_multiplier']) ?>" min="0" max="10"></label>
<label><span>Energie</span><input type="number" name="settings[scoring][energy_multiplier]" value="<?= e((string) $settings['scoring']['energy_multiplier']) ?>" min="0" max="10"></label>
<label><span>Stress</span><input type="number" name="settings[scoring][stress_multiplier]" value="<?= e((string) $settings['scoring']['stress_multiplier']) ?>" min="0" max="10"></label>
<label><span>Schlafgefühl</span><input type="number" name="settings[scoring][sleep_feeling_multiplier]" value="<?= e((string) $settings['scoring']['sleep_feeling_multiplier']) ?>" min="0" max="10"></label>
</div>
<section class="options-shell">
<div class="options-overlay" data-options-overlay data-options-standalone="1" data-open-panel="<?= e((string) ($optionsOpenPanel ?? '')) ?>">
<div class="options-overlay__backdrop" data-options-close></div>
<section class="options-modal glass-panel" role="dialog" aria-modal="true">
<div class="options-modal__controls">
<button class="dashboard-modal__round" type="button" data-options-back></button>
<button class="dashboard-modal__round" type="button" data-options-close>×</button>
</div>
<div class="settings-section">
<div class="section-head section-head--compact">
<div class="options-menu-panel" data-options-menu>
<div class="section-head">
<div>
<h4>Tracking-Felder</h4>
<p class="helper-text">Hier steuerst du, welche Zusatzfelder auf deiner Tracking-Seite sichtbar und in die Bewertung einbezogen werden.</p>
<p class="eyebrow">Optionen</p>
<h3>Einstellungen und Bereiche</h3>
</div>
</div>
<div class="field-grid field-grid--two">
<label class="checkbox-row checkbox-row--panel">
<input type="checkbox" name="settings[tracking][pain_enabled]" value="1" <?= !empty($settings['tracking']['pain_enabled']) ? 'checked' : '' ?>>
<span>
<strong>Schmerzen aktivieren</strong>
<small>1 bedeutet keine Schmerzen, 10 starke Schmerzen. Der Wert wird wie bei Stress positiv gewichtet, wenn die Schmerzen niedrig sind.</small>
</span>
</label>
<label>
<span>Schmerzfaktor</span>
<input type="number" name="settings[scoring][pain_multiplier]" value="<?= e((string) $settings['scoring']['pain_multiplier']) ?>" min="0" max="10">
</label>
<div class="options-menu-grid">
<button class="options-menu-card" type="button" data-options-open="sports"><strong>Sportarten anpassen</strong><span>Eigene Sportarten und Bonuspunkte</span></button>
<button class="options-menu-card" type="button" data-options-open="walk"><strong>Spaziergang anpassen</strong><span>Zeit oder Schritte auswerten</span></button>
<button class="options-menu-card" type="button" data-options-open="sleep"><strong>Schlaf anpassen</strong><span>Optimale Schlafmenge markieren</span></button>
<button class="options-menu-card" type="button" data-options-open="reminders"><strong>Erinnerungen setzen</strong><span>Push und tägliche Erinnerung</span></button>
<button class="options-menu-card" type="button" data-options-open="health"><strong>Health Import</strong><span>Apple Health automatisch übernehmen</span></button>
<button class="options-menu-card" type="button" data-options-open="ratings"><strong>Bewertungsskala ändern</strong><span>Labels und Schutzregeln</span></button>
<button class="options-menu-card" type="button" data-options-open="stats"><strong>Statistik</strong><span>Verlauf und Aktivität</span></button>
<?php if (!empty($authUser['is_admin'])): ?>
<button class="options-menu-card" type="button" data-options-open="users"><strong>Neue Nutzer anlegen</strong><span>Accounts und Adminrechte</span></button>
<?php endif; ?>
<button class="options-menu-card" type="button" data-options-open="security"><strong>Sicherheit</strong><span>Passwort und Backup</span></button>
<?php if (!empty($authUser['is_admin'])): ?>
<button class="options-menu-card" type="button" data-options-open="ai"><strong>KI</strong><span>OpenAI und Zusammenfassungen</span></button>
<?php endif; ?>
<form method="post" action="/logout" class="options-logout-form">
<?= csrf_field() ?>
<button class="options-menu-card options-menu-card--danger" type="submit"><strong>Abmelden</strong><span>Sitzung sicher beenden</span></button>
</form>
</div>
</div>
<div class="settings-section">
<h4>Schlafdauerpunkte</h4>
<div class="field-grid field-grid--four">
<?php foreach ($settings['scoring']['sleep_duration_points'] as $key => $value): ?>
<label>
<span><?= e($key) ?></span>
<input type="number" name="settings[scoring][sleep_duration_points][<?= e($key) ?>]" value="<?= e((string) $value) ?>" min="0" max="20">
</label>
<?php endforeach; ?>
</div>
</div>
<div class="settings-section">
<h4>Sport-Bänder</h4>
<div class="band-grid">
<?php foreach ($settings['scoring']['sport_bands'] as $index => $band): ?>
<div class="band-card">
<label><span>Min</span><input type="number" name="settings[scoring][sport_bands][<?= e((string) $index) ?>][min]" value="<?= e((string) $band['min']) ?>"></label>
<label><span>Max</span><input type="number" name="settings[scoring][sport_bands][<?= e((string) $index) ?>][max]" value="<?= e((string) $band['max']) ?>"></label>
<label><span>Punkte</span><input type="number" name="settings[scoring][sport_bands][<?= e((string) $index) ?>][points]" value="<?= e((string) $band['points']) ?>"></label>
<div class="options-panel" data-options-panel="sports" hidden>
<h2>Sportarten anpassen</h2>
<form method="post" action="/options" class="stack-form stack-form--spacious">
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="settings">
<div class="settings-section">
<div class="section-head section-head--compact">
<div>
<h4>Sportarten und Bonuspunkte</h4>
<p class="helper-text">Diese Sportarten stehen in deinen Momenten zur Auswahl.</p>
</div>
<button class="ghost-button" type="button" data-add-sport-type>Sportart hinzufügen</button>
</div>
<?php endforeach; ?>
</div>
</div>
<div class="settings-section">
<div class="section-head section-head--compact">
<div>
<h4>Spaziergang</h4>
<p class="helper-text">Du kannst Spaziergänge pro Account entweder über Zeit oder über Schritte bewerten lassen.</p>
</div>
</div>
<label>
<span>Spaziergang auswerten nach</span>
<select name="settings[walk][mode]">
<?php foreach ($walkModeOptions as $modeValue => $modeLabel): ?>
<option value="<?= e($modeValue) ?>" <?= ($settings['walk']['mode'] ?? 'time') === $modeValue ? 'selected' : '' ?>><?= e($modeLabel) ?></option>
<?php endforeach; ?>
</select>
</label>
<?php if (($settings['walk']['mode'] ?? 'time') === 'steps'): ?>
<div class="band-card">
<h5>Schritte mit Bestwert bei 10.000</h5>
<p class="helper-text">Bei Schritten liegt der beste Wert bei 10.000. Darunter steigt die Punktzahl schrittweise an, darüber fällt sie wieder sanft ab.</p>
<p class="helper-text">Aktueller Verlauf: 0 / 3.000 / 5.000 / 7.500 / 10.000 / 12.500 / 15.000 / 20.000 Schritte</p>
</div>
<?php else: ?>
<div class="band-grid">
<?php foreach ($settings['scoring']['walk_bands'] as $index => $band): ?>
<div class="band-card">
<label><span>Min</span><input type="number" name="settings[scoring][walk_bands][<?= e((string) $index) ?>][min]" value="<?= e((string) $band['min']) ?>"></label>
<label><span>Max</span><input type="number" name="settings[scoring][walk_bands][<?= e((string) $index) ?>][max]" value="<?= e((string) $band['max']) ?>"></label>
<label><span>Punkte</span><input type="number" name="settings[scoring][walk_bands][<?= e((string) $index) ?>][points]" value="<?= e((string) $band['points']) ?>"></label>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<div class="settings-section">
<div class="section-head section-head--compact">
<div>
<h4>Sportarten und Bonuspunkte</h4>
<p class="helper-text">Lege fest, welche Sportarten nur in deinem eigenen Tracking auswählbar sind. Entfernte Sportarten kannst du darunter jederzeit wieder für deinen Account hinzufügen.</p>
</div>
<button class="ghost-button" type="button" data-add-sport-type>Sportart hinzufügen</button>
</div>
<input type="hidden" name="settings[sport_types_present]" value="1">
<?php if (!empty($sportTypePresets)): ?>
<div class="preset-list">
<?php foreach ($sportTypePresets as $preset): ?>
<button
class="preset-pill"
type="button"
data-sport-preset
data-id="<?= e($preset['id']) ?>"
data-label="<?= e($preset['label']) ?>"
data-icon="<?= e($preset['icon']) ?>"
data-location="<?= e($preset['location'] ?? '') ?>"
data-recovery-group="<?= e($preset['recovery_group']) ?>"
data-bonus-points="<?= e((string) $preset['bonus_points']) ?>"
data-allow-consecutive="<?= !empty($preset['allow_consecutive']) ? '1' : '0' ?>"
>
<img src="<?= e(sport_icon_path($preset['icon'])) ?>" alt="">
<span><?= e($preset['label']) ?></span>
</button>
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="sport-type-list" data-sport-type-list>
<?php foreach ($settings['sport_types'] as $index => $sportType): ?>
<div class="sport-type-card band-card" data-sport-type-row>
<input type="hidden" name="settings[sport_types][<?= e((string) $index) ?>][id]" value="<?= e($sportType['id']) ?>" data-name-template="settings[sport_types][__INDEX__][id]">
<div class="field-grid field-grid--four">
<label>
<span>Bezeichnung</span>
<input type="text" name="settings[sport_types][<?= e((string) $index) ?>][label]" value="<?= e($sportType['label']) ?>" data-name-template="settings[sport_types][__INDEX__][label]">
</label>
<label>
<span>Icon</span>
<select name="settings[sport_types][<?= e((string) $index) ?>][icon]" data-name-template="settings[sport_types][__INDEX__][icon]">
<?php foreach (sport_icon_options() as $iconValue => $iconLabel): ?>
<option value="<?= e($iconValue) ?>" <?= $sportType['icon'] === $iconValue ? 'selected' : '' ?>><?= e($iconLabel) ?></option>
<?php endforeach; ?>
</select>
</label>
<label>
<span>Ort</span>
<select name="settings[sport_types][<?= e((string) $index) ?>][location]" data-name-template="settings[sport_types][__INDEX__][location]">
<?php foreach ($sportLocationOptions as $locationValue => $locationLabel): ?>
<option value="<?= e($locationValue) ?>" <?= ($sportType['location'] ?? '') === $locationValue ? 'selected' : '' ?>><?= e($locationLabel) ?></option>
<?php endforeach; ?>
</select>
</label>
<label>
<span>Erholungsgruppe optional</span>
<input type="text" name="settings[sport_types][<?= e((string) $index) ?>][recovery_group]" value="<?= e($sportType['recovery_group']) ?>" data-name-template="settings[sport_types][__INDEX__][recovery_group]">
</label>
</div>
<div class="field-grid field-grid--four">
<label>
<span>Bonuspunkte</span>
<input type="number" name="settings[sport_types][<?= e((string) $index) ?>][bonus_points]" value="<?= e((string) $sportType['bonus_points']) ?>" min="0" max="20" data-name-template="settings[sport_types][__INDEX__][bonus_points]">
</label>
</div>
<label class="checkbox-row">
<input type="checkbox" name="settings[sport_types][<?= e((string) $index) ?>][allow_consecutive]" value="1" <?= !empty($sportType['allow_consecutive']) ? 'checked' : '' ?> data-name-template="settings[sport_types][__INDEX__][allow_consecutive]">
<span>Darf an aufeinanderfolgenden Tagen Bonus geben</span>
</label>
<p class="helper-text">Die Erholungsgruppe brauchst du nur, wenn mehrere Varianten dieselbe Belastung teilen sollen, zum Beispiel Krafttraining Zuhause und Krafttraining Auswärts. Wenn du nichts Besonderes einträgst, reicht die Sportart selbst.</p>
<div class="sport-type-card__actions">
<span class="sport-pill sport-pill--soft">
<img src="<?= e(sport_icon_path($sportType['icon'])) ?>" alt="">
<span><?= e($sportType['label']) ?><?= !empty($sportType['location']) ? ' · ' . e(sport_location_label((string) $sportType['location'])) : '' ?></span>
</span>
<button class="ghost-button ghost-button--small" type="button" data-remove-sport-type>Entfernen</button>
<input type="hidden" name="settings[sport_types_present]" value="1">
<?php if (!empty($sportTypePresets)): ?>
<div class="preset-list">
<?php foreach ($sportTypePresets as $preset): ?>
<button class="preset-pill" type="button" data-sport-preset data-id="<?= e($preset['id']) ?>" data-label="<?= e($preset['label']) ?>" data-icon="<?= e($preset['icon']) ?>" data-location="<?= e($preset['location'] ?? '') ?>" data-recovery-group="<?= e($preset['recovery_group']) ?>" data-bonus-points="<?= e((string) $preset['bonus_points']) ?>" data-allow-consecutive="<?= !empty($preset['allow_consecutive']) ? '1' : '0' ?>">
<img src="<?= e(sport_icon_path($preset['icon'])) ?>" alt=""><span><?= e($preset['label']) ?></span>
</button>
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="sport-type-list" data-sport-type-list>
<?php foreach ($settings['sport_types'] as $index => $sportType): ?>
<div class="sport-type-card band-card" data-sport-type-row>
<input type="hidden" name="settings[sport_types][<?= e((string) $index) ?>][id]" value="<?= e($sportType['id']) ?>" data-name-template="settings[sport_types][__INDEX__][id]">
<div class="field-grid field-grid--four">
<label><span>Bezeichnung</span><input type="text" name="settings[sport_types][<?= e((string) $index) ?>][label]" value="<?= e($sportType['label']) ?>" data-name-template="settings[sport_types][__INDEX__][label]"></label>
<label><span>Icon</span><select name="settings[sport_types][<?= e((string) $index) ?>][icon]" data-name-template="settings[sport_types][__INDEX__][icon]"><?php foreach (sport_icon_options() as $iconValue => $iconLabel): ?><option value="<?= e($iconValue) ?>" <?= $sportType['icon'] === $iconValue ? 'selected' : '' ?>><?= e($iconLabel) ?></option><?php endforeach; ?></select></label>
<label><span>Ort</span><select name="settings[sport_types][<?= e((string) $index) ?>][location]" data-name-template="settings[sport_types][__INDEX__][location]"><?php foreach ($sportLocationOptions as $locationValue => $locationLabel): ?><option value="<?= e($locationValue) ?>" <?= ($sportType['location'] ?? '') === $locationValue ? 'selected' : '' ?>><?= e($locationLabel) ?></option><?php endforeach; ?></select></label>
<label><span>Erholungsgruppe</span><input type="text" name="settings[sport_types][<?= e((string) $index) ?>][recovery_group]" value="<?= e($sportType['recovery_group']) ?>" data-name-template="settings[sport_types][__INDEX__][recovery_group]"></label>
</div>
<div class="field-grid field-grid--four"><label><span>Bonuspunkte</span><input type="number" name="settings[sport_types][<?= e((string) $index) ?>][bonus_points]" value="<?= e((string) $sportType['bonus_points']) ?>" min="0" max="20" data-name-template="settings[sport_types][__INDEX__][bonus_points]"></label></div>
<label class="checkbox-row"><input type="checkbox" name="settings[sport_types][<?= e((string) $index) ?>][allow_consecutive]" value="1" <?= !empty($sportType['allow_consecutive']) ? 'checked' : '' ?> data-name-template="settings[sport_types][__INDEX__][allow_consecutive]"><span>Darf an Folgetagen Bonus geben</span></label>
<div class="sport-type-card__actions"><button class="ghost-button ghost-button--small" type="button" data-remove-sport-type>Entfernen</button></div>
</div>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
</div>
<div class="section-actions">
<template id="sport-type-row-template">
<div class="sport-type-card band-card" data-sport-type-row>
<input type="hidden" value="" data-name-template="settings[sport_types][__INDEX__][id]">
<div class="field-grid field-grid--four">
<label><span>Bezeichnung</span><input type="text" value="" data-name-template="settings[sport_types][__INDEX__][label]"></label>
<label><span>Icon</span><select data-name-template="settings[sport_types][__INDEX__][icon]"><?php foreach (sport_icon_options() as $iconValue => $iconLabel): ?><option value="<?= e($iconValue) ?>"><?= e($iconLabel) ?></option><?php endforeach; ?></select></label>
<label><span>Ort</span><select data-name-template="settings[sport_types][__INDEX__][location]"><?php foreach ($sportLocationOptions as $locationValue => $locationLabel): ?><option value="<?= e($locationValue) ?>"><?= e($locationLabel) ?></option><?php endforeach; ?></select></label>
<label><span>Erholungsgruppe</span><input type="text" value="" data-name-template="settings[sport_types][__INDEX__][recovery_group]"></label>
</div>
<div class="field-grid field-grid--four"><label><span>Bonuspunkte</span><input type="number" value="2" min="0" max="20" data-name-template="settings[sport_types][__INDEX__][bonus_points]"></label></div>
<label class="checkbox-row"><input type="checkbox" value="1" data-name-template="settings[sport_types][__INDEX__][allow_consecutive]"><span>Darf an Folgetagen Bonus geben</span></label>
<div class="sport-type-card__actions"><button class="ghost-button ghost-button--small" type="button" data-remove-sport-type>Entfernen</button></div>
</div>
</template>
</div>
<button class="primary-button" type="submit">Sportarten speichern</button>
</div>
<template id="sport-type-row-template">
<div class="sport-type-card band-card" data-sport-type-row>
<input type="hidden" value="" data-name-template="settings[sport_types][__INDEX__][id]">
<div class="field-grid field-grid--four">
<label>
<span>Bezeichnung</span>
<input type="text" value="" placeholder="z. B. Krafttraining Zuhause" data-name-template="settings[sport_types][__INDEX__][label]">
</label>
<label>
<span>Icon</span>
<select data-name-template="settings[sport_types][__INDEX__][icon]">
<?php foreach (sport_icon_options() as $iconValue => $iconLabel): ?>
<option value="<?= e($iconValue) ?>"><?= e($iconLabel) ?></option>
<?php endforeach; ?>
</select>
</label>
<label>
<span>Ort</span>
<select data-name-template="settings[sport_types][__INDEX__][location]">
<?php foreach ($sportLocationOptions as $locationValue => $locationLabel): ?>
<option value="<?= e($locationValue) ?>"><?= e($locationLabel) ?></option>
<?php endforeach; ?>
</select>
</label>
<label>
<span>Erholungsgruppe optional</span>
<input type="text" value="" placeholder="z. B. krafttraining" data-name-template="settings[sport_types][__INDEX__][recovery_group]">
</label>
</div>
<div class="field-grid field-grid--four">
<label>
<span>Bonuspunkte</span>
<input type="number" value="2" min="0" max="20" data-name-template="settings[sport_types][__INDEX__][bonus_points]">
</label>
</div>
<label class="checkbox-row">
<input type="checkbox" value="1" data-name-template="settings[sport_types][__INDEX__][allow_consecutive]">
<span>Darf an aufeinanderfolgenden Tagen Bonus geben</span>
</label>
<p class="helper-text">Die Erholungsgruppe ist nur dann nützlich, wenn mehrere Varianten sportlich gleich belastend sind und denselben Bonusrhythmus teilen sollen.</p>
<div class="sport-type-card__actions">
<span class="sport-pill sport-pill--soft">
<img src="<?= e(sport_icon_path('run')) ?>" alt="">
<span>Neue Sportart</span>
</span>
<button class="ghost-button ghost-button--small" type="button" data-remove-sport-type>Entfernen</button>
</div>
</div>
</template>
</form>
</div>
<div class="settings-section">
<div class="section-head section-head--compact">
<div>
<h4>Erinnerungen</h4>
<p class="helper-text">Diese Erinnerungen gelten nur für deinen Account. Auf dem iPhone funktioniert Push nach dem Hinzufügen zum Home-Bildschirm.</p>
<div class="options-panel" data-options-panel="walk" hidden>
<h2>Spaziergang und Schritte</h2>
<form method="post" action="/options" class="stack-form">
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="settings">
<p class="helper-text">Spaziergänge werden als Momente angezeigt. Punkte kommen nicht mehr aus einzelnen Spaziergängen, sondern aus der täglichen Gesamtschrittzahl.</p>
<label><span>Spaziergang anzeigen nach</span><select name="settings[walk][mode]"><?php foreach ($walkModeOptions as $modeValue => $modeLabel): ?><option value="<?= e($modeValue) ?>" <?= ($settings['walk']['mode'] ?? 'time') === $modeValue ? 'selected' : '' ?>><?= e($modeLabel) ?></option><?php endforeach; ?></select></label>
<div class="settings-section">
<h4>Schritte-Bonus</h4>
<div class="field-grid field-grid--three">
<label><span>Mehr als</span><input type="number" name="settings[scoring][step_bonus][min]" value="<?= e((string) ($settings['scoring']['step_bonus']['min'] ?? 10000)) ?>" min="0" max="100000"></label>
<label><span>Bis einschließlich</span><input type="number" name="settings[scoring][step_bonus][max]" value="<?= e((string) ($settings['scoring']['step_bonus']['max'] ?? 15000)) ?>" min="0" max="100000"></label>
<label><span>Bonuspunkte</span><input type="number" name="settings[scoring][step_bonus][points]" value="<?= e((string) ($settings['scoring']['step_bonus']['points'] ?? 1)) ?>" min="0" max="20"></label>
</div>
</div>
<span class="chart-chip"><?= e((string) $pushSubscriptionCount) ?> Gerät<?= $pushSubscriptionCount === 1 ? '' : 'e' ?></span>
</div>
<div class="settings-section">
<h4>Schritte-Zielkurve</h4>
<p class="helper-text">Diese Punkte fließen als kleiner Bonus oder Malus in die Tagesbilanz ein. Positive Werte motivieren, negative Werte markieren zu wenig oder zu viel Bewegung.</p>
<div class="band-grid">
<?php foreach (($settings['scoring']['walk_step_targets'] ?? []) as $index => $target): ?>
<div class="band-card">
<label><span>Schritte</span><input type="number" name="settings[scoring][walk_step_targets][<?= e((string) $index) ?>][steps]" value="<?= e((string) ($target['steps'] ?? 0)) ?>" min="0" max="100000"></label>
<label><span>Punkte</span><input type="number" name="settings[scoring][walk_step_targets][<?= e((string) $index) ?>][points]" value="<?= e((string) ($target['points'] ?? 0)) ?>" min="-20" max="20"></label>
</div>
<?php endforeach; ?>
</div>
</div>
<button class="primary-button" type="submit">Schritte speichern</button>
</form>
</div>
<div class="field-grid field-grid--two">
<label class="checkbox-row checkbox-row--panel">
<input type="checkbox" name="settings[notifications][enabled]" value="1" <?= !empty($settings['notifications']['enabled']) ? 'checked' : '' ?>>
<span>Tägliche Push-Erinnerung aktivieren</span>
</label>
<div class="options-panel" data-options-panel="sleep" hidden>
<h2>Schlaf anpassen</h2>
<form method="post" action="/options" class="stack-form">
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="settings">
<p class="helper-text">Diese Zielmenge wird im importierten Schlafbalken als horizontale Markierung angezeigt und fließt in die automatische Stimmung/Energie/Stress-Einschätzung ein.</p>
<label><span>Optimale Schlafdauer</span><input type="number" name="settings[sleep][optimal_hours]" value="<?= e((string) ($settings['sleep']['optimal_hours'] ?? 7.0)) ?>" min="1" max="16" step="0.1"></label>
<button class="primary-button" type="submit">Schlaf speichern</button>
</form>
</div>
<label>
<span>Uhrzeit der Erinnerung</span>
<input type="time" name="settings[notifications][time]" value="<?= e((string) ($settings['notifications']['time'] ?? '20:30')) ?>">
</label>
</div>
<div class="options-panel" data-options-panel="reminders" hidden>
<h2>Erinnerungen setzen</h2>
<form method="post" action="/options" class="stack-form">
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="settings">
<div class="field-grid field-grid--two">
<label class="checkbox-row checkbox-row--panel"><input type="checkbox" name="settings[notifications][enabled]" value="1" <?= !empty($settings['notifications']['enabled']) ? 'checked' : '' ?>><span>Tägliche Push-Erinnerung aktivieren</span></label>
<label><span>Uhrzeit der Erinnerung</span><input type="time" name="settings[notifications][time]" value="<?= e((string) ($settings['notifications']['time'] ?? '20:30')) ?>"></label>
</div>
<div class="push-panel band-card" data-push-panel data-push-ready="<?= !empty($pushAvailable) && !empty($pushPublicKey) ? '1' : '0' ?>">
<div><h5>Push auf diesem Gerät</h5><p class="helper-text" data-push-status><?php if (!empty($pushAvailable) && !empty($pushPublicKey)): ?>Installiere die App auf Wunsch als PWA und aktiviere dann Push direkt auf diesem Gerät.<?php else: ?>Push ist auf diesem Server gerade noch nicht verfügbar.<?php endif; ?></p></div>
<div class="push-actions"><button class="ghost-button" type="button" data-push-enable <?= empty($pushAvailable) || empty($pushPublicKey) ? 'disabled' : '' ?>>Push aktivieren</button><button class="ghost-button" type="button" data-push-disable <?= empty($pushAvailable) || empty($pushPublicKey) ? 'disabled' : '' ?>>Auf diesem Gerät entfernen</button><button class="ghost-button" type="button" data-push-test <?= empty($pushAvailable) || empty($pushPublicKey) ? 'disabled' : '' ?>>Test senden</button></div>
</div>
<button class="primary-button" type="submit">Erinnerungen speichern</button>
</form>
</div>
<div
class="push-panel band-card"
data-push-panel
data-push-ready="<?= !empty($pushAvailable) && !empty($pushPublicKey) ? '1' : '0' ?>"
>
<div>
<h5>Push auf diesem Gerät</h5>
<p class="helper-text" data-push-status>
<?php if (!empty($pushAvailable) && !empty($pushPublicKey)): ?>
Installiere die App auf Wunsch als PWA und aktiviere dann Push direkt auf diesem Gerät.
<div class="options-panel" data-options-panel="health" hidden>
<h2>Health Import</h2>
<article class="detail-card detail-card--overlay">
<p class="eyebrow">REST-Endpunkt</p>
<div class="stack-form">
<label><span>URL in Health Auto Export</span><input type="text" value="<?= e((string) $healthImportUrl) ?>" readonly></label>
<label><span>HTTP-Header</span><input type="text" value="Authorization: Bearer <?= !empty($healthImportConfig['token_prefix']) ? e((string) $healthImportConfig['token_prefix']) . '…' : 'TOKEN' ?>" readonly></label>
</div>
<p class="helper-text">Lege in Health Auto Export zwei REST-API-Automationen an: Gesundheitsmetriken mit <code>step_count</code> und <code>sleep_analysis</code>, sowie Workouts mit JSON Version 2 und Routendaten.</p>
</article>
<?php if (!empty($healthImportToken)): ?>
<article class="detail-card detail-card--overlay health-token-card">
<p class="eyebrow">Neuer Token</p>
<label><span>Nur jetzt sichtbar</span><input type="text" value="<?= e((string) $healthImportToken) ?>" readonly></label>
<p class="helper-text">Kopiere diesen Token als Bearer-Token in Health Auto Export. Danach wird nur noch der Anfang angezeigt.</p>
</article>
<?php endif; ?>
<article class="detail-card detail-card--overlay" data-health-import-status>
<p class="eyebrow">Status</p>
<div class="health-import-progress" data-health-progress-wrap data-progress-done="<?= e((string) ($healthImportConfig['progress_done'] ?? 0)) ?>" data-progress-total="<?= e((string) ($healthImportConfig['progress_total'] ?? 0)) ?>">
<progress class="health-import-progress__bar" data-health-progress-bar max="100" value="0">0%</progress>
<p class="helper-text" data-health-progress-text>
<?php if (($healthImportConfig['last_status'] ?? '') === 'running'): ?>
Import läuft: <?= e((string) ($healthImportConfig['progress_done'] ?? 0)) ?> von <?= e((string) ($healthImportConfig['progress_total'] ?? 0)) ?> verarbeitet.
<?php elseif (!empty($healthImportConfig['last_message'])): ?>
<?= e((string) $healthImportConfig['last_message']) ?>
<?php else: ?>
Push ist auf diesem Server gerade noch nicht verfügbar.
Noch kein Import gelaufen.
<?php endif; ?>
</p>
</div>
<div class="push-actions">
<button class="ghost-button" type="button" data-push-enable <?= empty($pushAvailable) || empty($pushPublicKey) ? 'disabled' : '' ?>>Push aktivieren</button>
<button class="ghost-button" type="button" data-push-disable <?= empty($pushAvailable) || empty($pushPublicKey) ? 'disabled' : '' ?>>Auf diesem Gerät entfernen</button>
<button class="ghost-button" type="button" data-push-test <?= empty($pushAvailable) || empty($pushPublicKey) ? 'disabled' : '' ?>>Test senden</button>
</div>
</div>
<div class="section-actions">
<button class="primary-button" type="submit">Erinnerungen speichern</button>
</div>
</div>
<div class="settings-section">
<h4>Bewertungsskala</h4>
<p class="helper-text">Diese Score-Grenzen und Schutzregeln gelten ebenfalls nur für deinen eigenen Account.</p>
<div class="band-grid">
<?php foreach ($settings['ratings'] as $index => $rating): ?>
<div class="band-card">
<label><span>Label</span><input type="text" name="settings[ratings][<?= e((string) $index) ?>][label]" value="<?= e($rating['label']) ?>"></label>
<label><span>Min</span><input type="number" name="settings[ratings][<?= e((string) $index) ?>][min]" value="<?= e((string) $rating['min']) ?>"></label>
<label><span>Max</span><input type="number" name="settings[ratings][<?= e((string) $index) ?>][max]" value="<?= e((string) $rating['max']) ?>"></label>
</div>
<?php endforeach; ?>
</div>
</div>
<div class="settings-section">
<h4>Schutzregeln</h4>
<div class="band-grid">
<?php foreach ($settings['guardrails'] as $index => $guardrail): ?>
<div class="band-card">
<label><span>Stimmung max</span><input type="number" name="settings[guardrails][<?= e((string) $index) ?>][mood_max]" value="<?= e((string) $guardrail['mood_max']) ?>" min="1" max="10"></label>
<label><span>Energie max</span><input type="number" name="settings[guardrails][<?= e((string) $index) ?>][energy_max]" value="<?= e((string) ($guardrail['energy_max'] ?? '')) ?>" min="1" max="10"></label>
<label><span>Maximales Label</span><input type="text" name="settings[guardrails][<?= e((string) $index) ?>][cap_label]" value="<?= e($guardrail['cap_label']) ?>"></label>
</div>
<?php endforeach; ?>
</div>
</div>
<label>
<span>Tagebuchpunkte bei nicht-leerer Notiz</span>
<input type="number" name="settings[scoring][journal_points]" value="<?= e((string) $settings['scoring']['journal_points']) ?>" min="0" max="20">
</label>
<button class="primary-button" type="submit">Bewertung speichern</button>
</form>
</article>
<aside class="stack-column">
<article class="glass-panel detail-card">
<p class="eyebrow">Backup</p>
<h3>Eigene Einträge sichern</h3>
<p class="helper-text">Deine Tagesdateien liegen auf dem Server verschlüsselt. Beim Download bekommst du automatisch ein lesbares Backup mit allen Tagen als `.txt`-Dateien in einer ZIP-Datei. Beim Import werden diese Dateien wieder still im Hintergrund geschützt gespeichert.</p>
<form method="post" action="/options" class="stack-form">
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="export_backup">
<button class="primary-button" type="submit" <?= empty($backupAvailable) ? 'disabled' : '' ?>>Backup herunterladen</button>
<?php if (empty($backupAvailable)): ?>
<p class="helper-text">Auf diesem Server fehlt gerade die ZIP-Erweiterung für den Download.</p>
<?php endif; ?>
</form>
<form method="post" action="/options" enctype="multipart/form-data" class="stack-form">
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="import_backup">
<label>
<span>Backup importieren</span>
<input type="file" name="backup_files[]" accept=".zip,.txt" multiple>
</label>
<p class="helper-text">Du kannst ein komplettes ZIP-Backup oder einzelne Tagesdateien wie `2026-04-13.txt` importieren. Vorhandene Tage mit demselben Datum werden dabei aktualisiert.</p>
<button class="ghost-button" type="submit">Backup importieren</button>
</form>
</article>
<article class="glass-panel detail-card">
<p class="eyebrow">Sicherheit</p>
<h3>Passwort ändern</h3>
<form method="post" action="/options" class="stack-form">
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="password">
<label><span>Aktuelles Passwort</span><input type="password" name="current_password" required></label>
<label><span>Neues Passwort</span><input type="password" name="new_password" minlength="10" required></label>
<label><span>Neues Passwort wiederholen</span><input type="password" name="new_password_confirm" minlength="10" required></label>
<button class="primary-button" type="submit">Passwort aktualisieren</button>
</form>
</article>
<?php if (!empty($authUser['is_admin'])): ?>
<article class="glass-panel detail-card">
<p class="eyebrow">KI</p>
<h3>OpenAI für Zusammenfassungen</h3>
<p class="helper-text">Diese Einstellung gilt zentral für alle Nutzer. Der API-Key bleibt in der Umgebung des Servers und wird hier bewusst nicht gespeichert.</p>
<?php if (!empty($aiStatus)): ?>
<div class="user-list">
<div class="user-row">
<strong>API-Key</strong>
<span><?= !empty($aiStatus['has_api_key']) ? 'vorhanden' : 'fehlt' ?></span>
<div class="user-row"><strong>Token</strong><span data-health-token-state><?= !empty($healthImportConfig['enabled']) ? 'aktiv' : 'nicht eingerichtet' ?></span></div>
<?php if (!empty($healthImportConfig['last_import_at'])): ?>
<div class="user-row"><strong>Letzter Import</strong><span data-health-last-import><?= e(format_display_datetime((string) $healthImportConfig['last_import_at'])) ?></span></div>
<?php else: ?>
<div class="user-row"><strong>Letzter Import</strong><span data-health-last-import>-</span></div>
<?php endif; ?>
<div class="user-row"><strong>Statusmeldung</strong><span data-health-last-message><?= !empty($healthImportConfig['last_message']) ? e((string) $healthImportConfig['last_message']) : '-' ?></span></div>
</div>
<form method="post" action="/options" class="stack-form">
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="health_import_token">
<button class="primary-button" type="submit"><?= !empty($healthImportConfig['enabled']) ? 'Token neu erstellen' : 'Token erstellen' ?></button>
</form>
<?php if (!empty($healthImportConfig['enabled'])): ?>
<form method="post" action="/options" class="stack-form">
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="health_import_revoke">
<button class="ghost-button" type="submit">Token deaktivieren</button>
</form>
<?php endif; ?>
</article>
</div>
<div class="options-panel" data-options-panel="ratings" hidden>
<h2>Bewertungsskala ändern</h2>
<form method="post" action="/options" class="stack-form stack-form--spacious">
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="settings">
<div class="settings-section">
<h4>Tagesbilanz als Hauptmetrik</h4>
<p class="helper-text">Stimmung, Energie und Stress bilden die Basis. Schlaf, Schritte, Sport, Spaziergang und Notizen verschieben den Tag nur gedeckelt in eine positivere oder negativere Richtung.</p>
<div class="field-grid field-grid--three">
<label><span>Gewicht Stimmung</span><input type="number" name="settings[day_balance][mood_weight]" value="<?= e((string) ($settings['day_balance']['mood_weight'] ?? 3)) ?>" min="0" max="10"></label>
<label><span>Gewicht Energie</span><input type="number" name="settings[day_balance][energy_weight]" value="<?= e((string) ($settings['day_balance']['energy_weight'] ?? 2)) ?>" min="0" max="10"></label>
<label><span>Gewicht Stress</span><input type="number" name="settings[day_balance][stress_weight]" value="<?= e((string) ($settings['day_balance']['stress_weight'] ?? 2)) ?>" min="0" max="10"></label>
</div>
<div class="user-row">
<strong>Aktuelles Modell</strong>
<span><?= e((string) ($aiStatus['model'] ?? '')) ?></span>
</div>
<div class="user-row">
<strong>Timeout</strong>
<span><?= e((string) ($aiStatus['timeout'] ?? '')) ?> s</span>
<div class="field-grid field-grid--two">
<label><span>Max. Bonus/Malus in Stufen</span><input type="number" name="settings[day_balance][adjustment_cap]" value="<?= e((string) ($settings['day_balance']['adjustment_cap'] ?? 1.0)) ?>" min="0" max="2" step="0.1"></label>
<label><span>Punkte pro Stufenverschiebung</span><input type="number" name="settings[day_balance][points_per_step]" value="<?= e((string) ($settings['day_balance']['points_per_step'] ?? 12)) ?>" min="1" max="50"></label>
</div>
</div>
<?php endif; ?>
<form method="post" action="/options" class="stack-form">
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="ai_config">
<label><span>OpenAI-Modell</span><input type="text" name="ai[model]" value="<?= e((string) ($aiConfig['model'] ?? 'gpt-4o-mini')) ?>" required></label>
<label><span>Timeout in Sekunden</span><input type="number" name="ai[timeout]" value="<?= e((string) ($aiConfig['timeout'] ?? 25)) ?>" min="5" max="120" required></label>
<button class="primary-button" type="submit">KI-Konfiguration speichern</button>
<div class="settings-section"><h4>Bewertungsskala</h4><div class="band-grid"><?php foreach ($settings['ratings'] as $index => $rating): ?><div class="band-card"><label><span>Label</span><input type="text" name="settings[ratings][<?= e((string) $index) ?>][label]" value="<?= e($rating['label']) ?>"></label><label><span>Min</span><input type="number" name="settings[ratings][<?= e((string) $index) ?>][min]" value="<?= e((string) $rating['min']) ?>"></label><label><span>Max</span><input type="number" name="settings[ratings][<?= e((string) $index) ?>][max]" value="<?= e((string) $rating['max']) ?>"></label></div><?php endforeach; ?></div></div>
<div class="settings-section"><h4>Schutzregeln</h4><div class="band-grid"><?php foreach ($settings['guardrails'] as $index => $guardrail): ?><div class="band-card"><label><span>Stimmung max</span><input type="number" name="settings[guardrails][<?= e((string) $index) ?>][mood_max]" value="<?= e((string) $guardrail['mood_max']) ?>" min="1" max="10"></label><label><span>Energie max</span><input type="number" name="settings[guardrails][<?= e((string) $index) ?>][energy_max]" value="<?= e((string) ($guardrail['energy_max'] ?? '')) ?>" min="1" max="10"></label><label><span>Maximales Label</span><input type="text" name="settings[guardrails][<?= e((string) $index) ?>][cap_label]" value="<?= e($guardrail['cap_label']) ?>"></label></div><?php endforeach; ?></div></div>
<label><span>Tagebuchpunkte bei nicht-leerer Notiz</span><input type="number" name="settings[scoring][journal_points]" value="<?= e((string) $settings['scoring']['journal_points']) ?>" min="0" max="20"></label>
<button class="primary-button" type="submit">Bewertung speichern</button>
</form>
</article>
</div>
<article class="glass-panel detail-card">
<p class="eyebrow">Mehrere Accounts</p>
<h3>Neuen Nutzer anlegen</h3>
<form method="post" action="/options" class="stack-form">
<div class="options-panel" data-options-panel="stats" hidden>
<h2>Statistik</h2>
<form method="post" action="/options" class="stack-form stack-form--spacious stats-settings-form">
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="create_user">
<label><span>Benutzername</span><input type="text" name="username" required></label>
<label><span>Startpasswort</span><input type="password" name="password" minlength="10" required></label>
<label class="checkbox-row"><input type="checkbox" name="is_admin" value="1"><span>Als Admin anlegen</span></label>
<button class="primary-button" type="submit">Account erstellen</button>
</form>
<?php if ($users !== []): ?>
<div class="user-list">
<?php foreach ($users as $account): ?>
<div class="user-row">
<strong><?= e($account['username']) ?></strong>
<span><?= !empty($account['is_admin']) ? 'Admin' : 'Nutzer' ?></span>
</div>
<?php endforeach; ?>
<input type="hidden" name="form_name" value="settings">
<div class="settings-section">
<h4>Statistik-Darstellung</h4>
<label><span>Hauptwert anzeigen als</span><select name="settings[display][score_mode]">
<?php foreach (['scale' => '5-Stufen-Bilanz', 'percent' => 'Prozentwert', 'points' => 'Punkte'] as $mode => $label): ?>
<option value="<?= e($mode) ?>" <?= ($settings['display']['score_mode'] ?? 'scale') === $mode ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select></label>
</div>
<?php endif; ?>
</article>
<?php endif; ?>
</aside>
<button class="primary-button" type="submit">Statistik speichern</button>
</form>
<section class="stats-grid">
<article class="metric-card glass-panel"><span>Getrackte Tage</span><strong><?= e((string) $statsSummary['tracked_days']) ?></strong></article>
<article class="metric-card glass-panel"><span>Ø Score</span><strong><?= e(format_points((float) $statsSummary['average_score'])) ?></strong></article>
<article class="metric-card glass-panel"><span>Ø Bilanz</span><strong><?= ((float) $statsSummary['average_balance']) > 0 ? '+' : '' ?><?= e(format_points((float) $statsSummary['average_balance'])) ?></strong></article>
<article class="metric-card glass-panel"><span>Ø Stimmung</span><strong><?= e(format_points((float) $statsSummary['average_mood'])) ?>/10</strong></article>
<article class="metric-card glass-panel"><span>Ø Stress</span><strong><?= e(format_points((float) $statsSummary['average_stress'])) ?>/10</strong></article>
<article class="metric-card glass-panel"><span>Serie</span><strong><?= e((string) $statsSummary['streak']) ?> Tage</strong></article>
</section>
<section class="dashboard-grid dashboard-grid--embedded-stats">
<article class="glass-panel chart-card chart-card--calendar"><div class="section-head"><div><p class="eyebrow">Kalender</p><h3>Gesamtstimmung pro Tag</h3></div></div><div id="calendar-heatmap" class="calendar-heatmap" data-payload="<?= e($statsChartPayload) ?>"></div></article>
<article class="glass-panel chart-card"><div class="section-head"><div><p class="eyebrow">Trend</p><h3>Errechnete Tagesbilanz</h3></div></div><div class="line-chart" data-chart-type="line" data-series="balance" data-payload="<?= e($statsChartPayload) ?>"></div></article>
<article class="glass-panel chart-card"><div class="section-head"><div><p class="eyebrow">Belastung</p><h3>Stressverlauf</h3></div></div><div class="line-chart" data-chart-type="line" data-series="stress" data-payload="<?= e($statsChartPayload) ?>"></div></article>
<article class="glass-panel chart-card chart-card--wide"><div class="section-head"><div><p class="eyebrow">Aktivität</p><h3>Sport und Spaziergang</h3></div></div><div class="bar-chart" data-chart-type="bars" data-series="sport" data-payload="<?= e($statsChartPayload) ?>"></div></article>
</section>
</div>
<?php if (!empty($authUser['is_admin'])): ?>
<div class="options-panel" data-options-panel="users" hidden>
<h2>Neue Nutzer anlegen</h2>
<form method="post" action="/options" class="stack-form">
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="create_user">
<label><span>Benutzername</span><input type="text" name="username" required></label>
<label><span>Startpasswort</span><input type="password" name="password" minlength="10" required></label>
<label class="checkbox-row"><input type="checkbox" name="is_admin" value="1"><span>Als Admin anlegen</span></label>
<button class="primary-button" type="submit">Account erstellen</button>
</form>
<?php if ($users !== []): ?><div class="user-list"><?php foreach ($users as $account): ?><div class="user-row"><strong><?= e($account['username']) ?></strong><span><?= !empty($account['is_admin']) ? 'Admin' : 'Nutzer' ?></span></div><?php endforeach; ?></div><?php endif; ?>
</div>
<?php endif; ?>
<div class="options-panel" data-options-panel="security" hidden>
<h2>Sicherheit</h2>
<article class="detail-card detail-card--overlay">
<p class="eyebrow">Backup</p>
<form method="post" action="/options" class="stack-form"><?= csrf_field() ?><input type="hidden" name="form_name" value="export_backup"><button class="primary-button" type="submit" <?= empty($backupAvailable) ? 'disabled' : '' ?>>Backup herunterladen</button></form>
<form method="post" action="/options" enctype="multipart/form-data" class="stack-form"><?= csrf_field() ?><input type="hidden" name="form_name" value="import_backup"><label><span>Backup importieren</span><input type="file" name="backup_files[]" accept=".zip,.txt" multiple></label><button class="ghost-button" type="submit">Backup importieren</button></form>
</article>
<article class="detail-card detail-card--overlay">
<p class="eyebrow">Passwort</p>
<form method="post" action="/options" class="stack-form"><?= csrf_field() ?><input type="hidden" name="form_name" value="password"><label><span>Aktuelles Passwort</span><input type="password" name="current_password" required></label><label><span>Neues Passwort</span><input type="password" name="new_password" minlength="10" required></label><label><span>Neues Passwort wiederholen</span><input type="password" name="new_password_confirm" minlength="10" required></label><button class="primary-button" type="submit">Passwort aktualisieren</button></form>
</article>
</div>
<?php if (!empty($authUser['is_admin'])): ?>
<div class="options-panel" data-options-panel="ai" hidden>
<h2>KI</h2>
<?php if (!empty($aiStatus)): ?><div class="user-list"><div class="user-row"><strong>API-Key</strong><span><?= !empty($aiStatus['has_api_key']) ? 'vorhanden' : 'fehlt' ?></span></div><div class="user-row"><strong>Aktuelles Modell</strong><span><?= e((string) ($aiStatus['model'] ?? '')) ?></span></div><div class="user-row"><strong>Timeout</strong><span><?= e((string) ($aiStatus['timeout'] ?? '')) ?> s</span></div></div><?php endif; ?>
<form method="post" action="/options" class="stack-form"><?= csrf_field() ?><input type="hidden" name="form_name" value="ai_config"><label><span>OpenAI-Modell</span><input type="text" name="ai[model]" value="<?= e((string) ($aiConfig['model'] ?? 'gpt-4o-mini')) ?>" required></label><label><span>Timeout in Sekunden</span><input type="number" name="ai[timeout]" value="<?= e((string) ($aiConfig['timeout'] ?? 25)) ?>" min="5" max="120" required></label><button class="primary-button" type="submit">KI-Konfiguration speichern</button></form>
</div>
<?php endif; ?>
</section>
</div>
</section>