feat(dashboard): add immersive day range views

This commit is contained in:
2026-05-18 16:32:22 +02:00
parent e953d0fd42
commit 83b4686b6f
12 changed files with 3724 additions and 567 deletions
+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.
+1261
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

+535 -3
View File
@@ -39,6 +39,10 @@
return `/assets/icons/mood-${sentiment}.svg`; return `/assets/icons/mood-${sentiment}.svg`;
} }
function dashboardDayPath(date) {
return `/?view=day&date=${encodeURIComponent(date)}`;
}
function sportIconPath(icon) { function sportIconPath(icon) {
return `/assets/icons/sport-${icon}.svg`; return `/assets/icons/sport-${icon}.svg`;
} }
@@ -705,7 +709,7 @@
} }
return ` 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> <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> <title>${title}</title>
</a> </a>
@@ -730,7 +734,7 @@
<span data-calendar-score>${formatNumber(Number(latestVisibleEntry.score))}</span> <span data-calendar-score>${formatNumber(Number(latestVisibleEntry.score))}</span>
<small>Punkte</small> <small>Punkte</small>
</div> </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 = ` container.innerHTML = `
@@ -773,7 +777,7 @@
detailDate.textContent = formatDateLabel(entry.date); detailDate.textContent = formatDateLabel(entry.date);
detailLabel.textContent = entry.label; detailLabel.textContent = entry.label;
detailScore.textContent = formatNumber(Number(entry.score)); 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 => { container.querySelectorAll(".calendar-cell--selected").forEach(cell => {
cell.classList.remove("calendar-cell--selected"); cell.classList.remove("calendar-cell--selected");
@@ -971,6 +975,532 @@
syncPresets(); syncPresets();
} }
function initDashboardExperience() {
const summaryOverlay = document.querySelector("[data-summary-overlay]");
const openSummary = document.querySelector("[data-summary-overlay-open]");
const closeSummary = [...document.querySelectorAll("[data-summary-overlay-close]")];
const momentOverlay = document.querySelector("[data-moment-overlay]");
const settingsMenuOverlay = document.querySelector("[data-settings-menu-overlay]");
const openSettingsMenu = document.querySelector("[data-settings-menu-open]");
const closeSettingsMenu = [...document.querySelectorAll("[data-settings-menu-close]")];
const openMoment = document.querySelector("[data-moment-overlay-open]");
const closeMoment = [...document.querySelectorAll("[data-moment-overlay-close]")];
const chooseStep = document.querySelector('[data-moment-step="choose"]');
const formStep = document.querySelector('[data-moment-step="form"]');
const momentSubmit = document.querySelector("[data-moment-submit]");
const typeInput = document.querySelector("[data-moment-type-input]");
const formNameInput = document.querySelector("[data-moment-form-name]");
const eventIdInput = document.querySelector("[data-moment-event-id]");
const typeLabel = document.querySelector("[data-moment-type-label]");
const valueField = document.querySelector("[data-moment-value-field]");
const valueLabel = document.querySelector("[data-moment-value-label]");
const valueInput = document.querySelector("[data-moment-value-input]");
const walkField = document.querySelector("[data-moment-walk-field]");
const walkModeInput = document.querySelector("[data-walk-mode-input]");
const sportField = document.querySelector("[data-moment-sport-field]");
const alcoholField = document.querySelector("[data-moment-alcohol-field]");
const momentComment = document.querySelector("[data-moment-comment]");
const backButton = document.querySelector("[data-moment-back]");
const deleteForm = document.querySelector("[data-moment-delete-form]");
const deleteIdInput = document.querySelector("[data-moment-delete-id]");
const typeSelect = document.querySelector("[data-event-type-select]");
const unitInput = document.querySelector("[data-event-unit]");
const swipeContainer = document.querySelector("[data-day-swipe]");
const periodRail = document.querySelector(".range-period-rail");
const walkMode = document.body.dataset.walkMode || "time";
const walkUnit = walkMode === "steps" ? "steps" : "min";
const hoistOverlay = overlay => {
if (!(overlay instanceof HTMLElement)) {
return;
}
if (overlay.parentElement !== document.body) {
document.body.appendChild(overlay);
}
};
hoistOverlay(summaryOverlay);
hoistOverlay(momentOverlay);
hoistOverlay(settingsMenuOverlay);
if (periodRail instanceof HTMLElement) {
const params = new URLSearchParams(window.location.search);
const view = params.get("view") || "day";
const storageKey = `mood:${view}:period-scroll`;
const storedScroll = window.sessionStorage.getItem(storageKey);
if (storedScroll !== null) {
periodRail.scrollLeft = Number(storedScroll) || 0;
}
periodRail.addEventListener("click", event => {
const link = event.target.closest("a[href]");
if (!link) {
return;
}
window.sessionStorage.setItem(storageKey, String(periodRail.scrollLeft));
});
}
const stepperConfigs = {
event: { label: "Ereignis", valueLabel: "Wert", unit: "", placeholder: "optional", showValue: false, showSport: false, showAlcohol: false, commentPlaceholder: "Was hast du erlebt?" },
walk: { label: "Spaziergang", valueLabel: "Spaziergang", unit: walkUnit, placeholder: walkMode === "steps" ? "Schritte" : "Minuten", showValue: true, showSport: false, showAlcohol: false, showWalk: true, commentPlaceholder: "Was war dabei besonders?" },
sport: { label: "Sport", valueLabel: "Dauer", unit: "min", placeholder: "Minuten", showValue: true, showSport: true, showAlcohol: false, commentPlaceholder: "Was hast du gemacht?" },
sleep: { label: "Schlaf", valueLabel: "Dauer", unit: "h", placeholder: "Stunden", showValue: true, showSport: false, showAlcohol: false, commentPlaceholder: "Wie war der Schlaf?" },
alcohol: { label: "Alkohol", valueLabel: "", unit: "", placeholder: "", showValue: false, showSport: false, showAlcohol: true, commentPlaceholder: "Optionaler Kommentar" },
};
const toneClass = (value, metric = "mood") => {
const current = Math.max(-2, Math.min(2, Number(value || 0)));
if (metric === "stress") {
if (current <= -2) return "tone-pos2";
if (current === -1) return "tone-pos1";
if (current === 1) return "tone-neg1";
if (current >= 2) return "tone-neg2";
return "tone-zero";
}
if (current <= -2) return "tone-neg2";
if (current === -1) return "tone-neg1";
if (current === 1) return "tone-pos1";
if (current >= 2) return "tone-pos2";
return "tone-zero";
};
const setHidden = (element, hidden) => {
if (!element) {
return;
}
if (hidden) {
element.setAttribute("hidden", "hidden");
} else {
element.removeAttribute("hidden");
}
};
const setOverlay = (overlay, open) => {
if (!overlay) {
return;
}
setHidden(overlay, !open);
document.body.classList.toggle("is-dashboard-overlay-open", open);
if (open) {
const focusTarget = overlay.querySelector("input, textarea, select, button");
if (focusTarget instanceof HTMLElement) {
window.setTimeout(() => focusTarget.focus(), 10);
}
}
};
document.querySelectorAll("[data-stepper]").forEach(stepper => {
const input = stepper.querySelector("[data-stepper-input]");
const value = stepper.querySelector("[data-stepper-value]");
const minus = stepper.querySelector("[data-stepper-minus]");
const plus = stepper.querySelector("[data-stepper-plus]");
if (!input || !value || !minus || !plus) {
return;
}
const render = () => {
const current = Math.max(-2, Math.min(2, Number(input.value || 0)));
const metric = stepper.dataset.stepperMetric || "mood";
input.value = String(current);
value.textContent = `${current > 0 ? "+" : ""}${current}`;
minus.disabled = current <= -2;
plus.disabled = current >= 2;
const ring = stepper.querySelector(".overlay-signal-card__ring");
if (ring) {
ring.classList.remove("tone-neg2", "tone-neg1", "tone-zero", "tone-pos1", "tone-pos2");
ring.classList.add(toneClass(current, metric));
}
};
minus.addEventListener("click", () => {
input.value = String(Number(input.value || 0) - 1);
render();
});
plus.addEventListener("click", () => {
input.value = String(Number(input.value || 0) + 1);
render();
});
render();
});
if (openSummary) {
openSummary.addEventListener("click", event => {
event.preventDefault();
setOverlay(summaryOverlay, true);
});
}
closeSummary.forEach(button => {
button.addEventListener("click", event => {
event.preventDefault();
setOverlay(summaryOverlay, false);
});
});
if (openSettingsMenu) {
openSettingsMenu.addEventListener("click", event => {
event.preventDefault();
setOverlay(settingsMenuOverlay, true);
});
}
closeSettingsMenu.forEach(button => {
button.addEventListener("click", event => {
event.preventDefault();
setOverlay(settingsMenuOverlay, false);
});
});
const setMomentEditMode = payload => {
if (!formNameInput || !eventIdInput) {
return;
}
if (payload) {
formNameInput.value = "update_event";
eventIdInput.value = payload.id || "";
if (deleteForm && deleteIdInput) {
deleteIdInput.value = payload.id || "";
setHidden(deleteForm, false);
}
return;
}
formNameInput.value = "add_event";
eventIdInput.value = "";
if (deleteForm && deleteIdInput) {
deleteIdInput.value = "";
setHidden(deleteForm, true);
}
};
const showMomentChoose = () => {
setMomentEditMode(null);
setHidden(chooseStep, false);
setHidden(formStep, true);
document.querySelectorAll("[data-sport-choice]").forEach(item => item.classList.remove("is-selected"));
if (momentSubmit) {
momentSubmit.disabled = true;
}
};
const showMomentForm = type => {
const config = stepperConfigs[type] || stepperConfigs.event;
if (typeInput) typeInput.value = type;
if (typeLabel) typeLabel.textContent = config.label;
if (valueLabel) valueLabel.textContent = config.valueLabel || "Wert";
if (valueInput) {
valueInput.placeholder = config.placeholder;
valueInput.required = !!config.showValue;
valueInput.value = config.showValue ? valueInput.value : "";
valueInput.step = type === "sleep" ? "0.25" : "1";
}
if (unitInput) {
unitInput.value = config.unit;
}
if (walkModeInput) {
walkModeInput.value = walkMode;
}
if (momentComment) {
momentComment.placeholder = config.commentPlaceholder;
momentComment.required = type !== "alcohol";
}
if (sportField) setHidden(sportField, !config.showSport);
if (alcoholField) setHidden(alcoholField, !config.showAlcohol);
if (valueField) setHidden(valueField, !config.showValue);
if (walkField) setHidden(walkField, !config.showWalk);
if (momentSubmit) {
momentSubmit.disabled = false;
}
if (config.showWalk) {
document.querySelectorAll('input[name="event_walk_mode"]').forEach(radio => {
radio.checked = radio.value === walkMode;
});
}
setHidden(chooseStep, true);
setHidden(formStep, false);
};
const populateMomentForm = payload => {
showMomentForm(payload.type || "event");
setMomentEditMode(payload);
const form = document.querySelector("#moment-form");
if (!form) {
return;
}
const setValue = (selector, value) => {
const field = form.querySelector(selector);
if (field) {
field.value = value;
}
};
setValue('input[name="event_time"]', payload.time || "");
setValue('[name="event_comment"]', payload.comment || "");
setValue('[name="event_value"]', payload.value || "");
setValue('[name="event_sport_type_id"]', payload.sport_type_id || "");
setValue('[name="event_unit"]', payload.unit || "");
setValue('[name="event_walk_mode"]', payload.unit === "steps" ? "steps" : "time");
setValue('[name="event_mood"]', payload.mood ?? 0);
setValue('[name="event_energy"]', payload.energy ?? 0);
setValue('[name="event_stress"]', payload.stress ?? 0);
const consumedYes = form.querySelector('input[name="event_consumed"][value="1"]');
const consumedNo = form.querySelector('input[name="event_consumed"][value="0"]');
if (consumedYes && consumedNo) {
if (payload.consumed) {
consumedYes.checked = true;
} else {
consumedNo.checked = true;
}
}
form.querySelectorAll('input[name="event_walk_mode"]').forEach(radio => {
radio.checked = radio.value === (payload.unit === "steps" ? "steps" : "time");
});
form.querySelectorAll("[data-stepper]").forEach(stepper => {
const input = stepper.querySelector("[data-stepper-input]");
const value = stepper.querySelector("[data-stepper-value]");
const minus = stepper.querySelector("[data-stepper-minus]");
const plus = stepper.querySelector("[data-stepper-plus]");
if (!input || !value || !minus || !plus) return;
const current = Math.max(-2, Math.min(2, Number(input.value || 0)));
const metric = stepper.dataset.stepperMetric || "mood";
input.value = String(current);
value.textContent = `${current > 0 ? "+" : ""}${current}`;
minus.disabled = current <= -2;
plus.disabled = current >= 2;
const ring = stepper.querySelector(".overlay-signal-card__ring");
if (ring) {
ring.classList.remove("tone-neg2", "tone-neg1", "tone-zero", "tone-pos1", "tone-pos2");
ring.classList.add(toneClass(current, metric));
}
});
form.querySelectorAll("[data-sport-choice]").forEach(button => {
button.classList.toggle("is-selected", button.dataset.sportChoice === (payload.sport_type_id || ""));
});
};
if (openMoment) {
openMoment.addEventListener("click", event => {
event.preventDefault();
showMomentChoose();
setOverlay(momentOverlay, true);
});
}
closeMoment.forEach(button => {
button.addEventListener("click", event => {
event.preventDefault();
setOverlay(momentOverlay, false);
showMomentChoose();
});
});
document.querySelectorAll("[data-moment-type-choice]").forEach(button => {
button.addEventListener("click", event => {
event.preventDefault();
showMomentForm(button.dataset.momentTypeChoice || "event");
});
});
if (backButton) {
backButton.addEventListener("click", event => {
event.preventDefault();
showMomentChoose();
});
}
document.querySelectorAll("[data-sport-choice]").forEach(button => {
button.addEventListener("click", event => {
event.preventDefault();
const hidden = document.querySelector('input[name="event_sport_type_id"]');
if (!hidden) {
return;
}
hidden.value = button.dataset.sportChoice || "";
document.querySelectorAll("[data-sport-choice]").forEach(item => item.classList.remove("is-selected"));
button.classList.add("is-selected");
});
});
document.querySelectorAll('input[name="event_walk_mode"]').forEach(radio => {
radio.addEventListener("change", () => {
if (!valueInput || !valueLabel || !unitInput || !walkModeInput) {
return;
}
const mode = radio.checked ? radio.value : walkModeInput.value;
walkModeInput.value = mode;
unitInput.value = mode === "steps" ? "steps" : "min";
valueLabel.textContent = mode === "steps" ? "Schritte" : "Dauer";
valueInput.placeholder = mode === "steps" ? "Schritte" : "Minuten";
valueInput.step = "1";
});
});
document.querySelectorAll("[data-confirm-delete]").forEach(button => {
button.addEventListener("click", event => {
if (!window.confirm("Diesen Moment wirklich löschen?")) {
event.preventDefault();
event.stopPropagation();
}
});
});
document.querySelectorAll("[data-event-editable]").forEach(card => {
card.addEventListener("click", event => {
if (event.target.closest("form") || event.target.closest("button")) {
return;
}
const payload = decodePayload(card.dataset.eventPayload || "");
if (!payload) {
return;
}
populateMomentForm(payload);
setOverlay(momentOverlay, true);
});
});
if (typeSelect && unitInput) {
const syncUnit = () => {
const option = typeSelect.options[typeSelect.selectedIndex];
unitInput.value = option?.dataset.defaultUnit || "";
};
syncUnit();
typeSelect.addEventListener("change", syncUnit);
}
if (swipeContainer) {
let pointerStartX = 0;
let pointerStartY = 0;
let dragging = false;
const handleSwipe = (deltaX, deltaY) => {
if (Math.abs(deltaX) < 70 || Math.abs(deltaY) > 50) {
return;
}
if (deltaX < 0 && swipeContainer.dataset.nextDate) {
window.location.href = dashboardDayPath(swipeContainer.dataset.nextDate);
} else if (deltaX > 0 && swipeContainer.dataset.prevDate) {
window.location.href = dashboardDayPath(swipeContainer.dataset.prevDate);
}
};
swipeContainer.addEventListener("pointerdown", event => {
dragging = true;
pointerStartX = event.clientX;
pointerStartY = event.clientY;
});
swipeContainer.addEventListener("pointerup", event => {
if (!dragging) {
return;
}
dragging = false;
handleSwipe(event.clientX - pointerStartX, event.clientY - pointerStartY);
});
swipeContainer.addEventListener("pointercancel", () => {
dragging = false;
});
window.addEventListener("keydown", event => {
if (document.body.classList.contains("is-dashboard-overlay-open")) {
return;
}
if (event.key === "ArrowLeft" && swipeContainer.dataset.prevDate) {
window.location.href = dashboardDayPath(swipeContainer.dataset.prevDate);
}
if (event.key === "ArrowRight" && swipeContainer.dataset.nextDate) {
window.location.href = dashboardDayPath(swipeContainer.dataset.nextDate);
}
});
}
}
function initOptionsPanels() {
const overlay = document.querySelector("[data-options-overlay]");
if (!overlay) {
return;
}
if (overlay.parentElement !== document.body) {
document.body.appendChild(overlay);
}
const panels = [...overlay.querySelectorAll("[data-options-panel]")];
const closeButtons = [...overlay.querySelectorAll("[data-options-close]")];
const backButtons = [...overlay.querySelectorAll("[data-options-back]")];
const initialPanel = overlay.dataset.openPanel || null;
const setOpen = (panelName) => {
overlay.hidden = panelName === null;
document.body.classList.toggle("is-dashboard-overlay-open", panelName !== null);
panels.forEach(panel => {
panel.hidden = panel.dataset.optionsPanel !== panelName;
});
if (panelName === "stats") {
window.setTimeout(() => {
initDashboardCharts();
}, 40);
}
};
document.querySelectorAll("[data-options-open]").forEach(button => {
button.addEventListener("click", event => {
event.preventDefault();
setOpen(button.dataset.optionsOpen || null);
});
});
closeButtons.forEach(button => {
button.addEventListener("click", event => {
event.preventDefault();
setOpen(null);
});
});
backButtons.forEach(button => {
button.addEventListener("click", event => {
event.preventDefault();
setOpen(null);
});
});
if (initialPanel) {
setOpen(initialPanel);
}
}
function csrfToken() { function csrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") || ""; return document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") || "";
} }
@@ -1300,6 +1830,8 @@
initTrackPreview(); initTrackPreview();
initArchiveMobileDetail(); initArchiveMobileDetail();
initDashboardCharts(); initDashboardCharts();
initDashboardExperience();
initOptionsPanels();
initSportTypeManager(); initSportTypeManager();
initPwaShell(); initPwaShell();
initPullToRefresh(); initPullToRefresh();
+573 -2
View File
@@ -87,7 +87,11 @@ final class App
redirect('/login'); redirect('/login');
case '/': case '/':
$this->showDashboard(); $method === 'POST' ? $this->handleDashboard() : $this->showDashboard();
return;
case '/day-image':
$this->serveDayImage();
return; return;
case '/track': case '/track':
@@ -232,21 +236,584 @@ final class App
$settings = $this->hydrateSettings($this->settings->forUser($user['username'])); $settings = $this->hydrateSettings($this->settings->forUser($user['username']));
$entries = $this->entries->all($user['username']); $entries = $this->entries->all($user['username']);
$evaluatedEntries = $this->evaluateEntriesWithContext($entries, $settings); $evaluatedEntries = $this->evaluateEntriesWithContext($entries, $settings);
$evaluatedEntries = array_map(
fn (array $entry): array => $this->withDashboardImageState($user['username'], $entry),
$evaluatedEntries
);
$dashboardView = $this->normalizeDashboardView((string) ($_GET['view'] ?? 'day'));
$dashboardDate = (string) ($_GET['date'] ?? today());
if (!$this->isValidDate($dashboardDate)) {
$dashboardDate = today();
}
$entryMap = [];
foreach ($evaluatedEntries as $entry) {
$entryMap[(string) ($entry['date'] ?? '')] = $entry;
}
$selectedEntry = $entryMap[$dashboardDate] ?? $this->withDashboardImageState($user['username'], $this->buildEmptyDashboardEntry($dashboardDate, $settings, $entryMap[shift_date($dashboardDate, -1)] ?? null));
$summary = $this->buildDashboardSummary($evaluatedEntries); $summary = $this->buildDashboardSummary($evaluatedEntries);
$chartData = $this->buildDashboardCharts($evaluatedEntries); $chartData = $this->buildDashboardCharts($evaluatedEntries);
View::render('dashboard', [ View::render('dashboard', [
'pageTitle' => 'Dashboard', 'pageTitle' => 'Mood',
'page' => 'dashboard', 'page' => 'dashboard',
'pageBodyClass' => 'page-dashboard-immersive',
'authUser' => $user, 'authUser' => $user,
'settings' => $settings, 'settings' => $settings,
'summary' => $summary, 'summary' => $summary,
'entries' => array_reverse($evaluatedEntries), 'entries' => array_reverse($evaluatedEntries),
'chartPayload' => encode_payload($chartData), 'chartPayload' => encode_payload($chartData),
'dashboardView' => $dashboardView,
'dashboardDate' => $dashboardDate,
'dayEntry' => $selectedEntry,
'dashboardEventTypes' => day_event_type_options(),
'dashboardSignals' => signal_scale_options(),
'dashboardTimeline' => $this->buildDashboardTimeline($selectedEntry),
'dashboardCompareDays' => $this->buildDashboardCompareDays($dashboardDate, $entryMap, $settings),
'dashboardWeek' => $this->buildDashboardWeekView($dashboardDate, $entryMap),
'dashboardMonth' => $this->buildDashboardMonthView($dashboardDate, $entryMap),
'dashboardPrevDate' => shift_date($dashboardDate, -1),
'dashboardNextDate' => shift_date($dashboardDate, 1),
'dashboardSportTypes' => normalized_sport_types($settings),
'dashboardWalkMode' => (string) ($settings['walk']['mode'] ?? 'time'),
'dashboardHasContent' => $this->entryHasContent($selectedEntry, isset($entryMap[$dashboardDate])),
'dashboardVisualScore' => $this->dashboardVisualScore($selectedEntry, isset($entryMap[$dashboardDate])),
'dashboardLineLevel' => $this->dashboardLineLevel($selectedEntry, isset($entryMap[$dashboardDate])),
'dashboardLineTone' => $this->dashboardLineTone($selectedEntry, isset($entryMap[$dashboardDate])),
]); ]);
} }
private function handleDashboard(): void
{
$this->enforceCsrf();
$user = $this->requireUser();
$settings = $this->hydrateSettings($this->settings->forUser($user['username']));
$form = (string) ($_POST['form_name'] ?? '');
$date = (string) ($_POST['date'] ?? today());
if (!$this->isValidDate($date)) {
flash('error', 'Bitte wähle einen gültigen Tag.');
redirect('/');
}
$entries = $this->entries->all($user['username']);
$entryMap = [];
foreach ($entries as $existingEntry) {
$entryMap[(string) ($existingEntry['date'] ?? '')] = $existingEntry;
}
$current = $entryMap[$date] ?? $this->buildEmptyDashboardEntry($date, $settings, $entryMap[shift_date($date, -1)] ?? null);
try {
if ($form === 'save_day_summary') {
$current['summary'] = [
'comment' => trim((string) ($_POST['summary_comment'] ?? '')),
'mood' => normalize_signal_value($_POST['summary_mood'] ?? 0),
'energy' => normalize_signal_value($_POST['summary_energy'] ?? 0),
'stress' => normalize_signal_value($_POST['summary_stress'] ?? 0),
'alcohol' => isset($_POST['summary_alcohol']) && (string) $_POST['summary_alcohol'] === '1',
];
$current['summary_comment'] = $current['summary']['comment'];
$current['summary_mood'] = $current['summary']['mood'];
$current['summary_energy'] = $current['summary']['energy'];
$current['summary_stress'] = $current['summary']['stress'];
$current['summary_alcohol'] = !empty($current['summary']['alcohol']);
$current['note'] = $current['summary']['comment'];
$current['alcohol'] = !empty($current['summary']['alcohol']);
$upload = uploaded_files('background_image')[0] ?? null;
if (is_array($upload) && (int) ($upload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) {
$this->deleteDashboardBackgroundImage($user['username'], $date, (string) ($current['background_image'] ?? ''));
$current['background_image'] = $this->storeDashboardBackgroundImage($user['username'], $date, $upload);
}
$entryMap[$date] = $current;
$this->persistUserEntries($user['username'], $settings, array_values($entryMap));
flash('success', 'Die Tagesbilanz wurde gespeichert.');
redirect('/?view=day&date=' . rawurlencode($date));
}
if ($form === 'add_event') {
$event = $this->dashboardEventFromPost($_POST);
$events = is_array($current['events'] ?? null) ? $current['events'] : [];
$events[] = $event;
$current['events'] = $events;
$entryMap[$date] = $current;
$this->persistUserEntries($user['username'], $settings, array_values($entryMap));
flash('success', 'Die Aktivität wurde hinzugefügt.');
redirect('/?view=day&date=' . rawurlencode($date));
}
if ($form === 'update_event') {
$eventID = trim((string) ($_POST['event_id'] ?? ''));
$updatedEvent = $this->dashboardEventFromPost($_POST);
$updatedEvent['id'] = $eventID !== '' ? $eventID : $updatedEvent['id'];
$events = [];
foreach (is_array($current['events'] ?? null) ? $current['events'] : [] as $event) {
if (!is_array($event)) {
continue;
}
if ((string) ($event['id'] ?? '') === $eventID) {
$events[] = $updatedEvent;
continue;
}
$events[] = $event;
}
$current['events'] = $events;
$entryMap[$date] = $current;
$this->persistUserEntries($user['username'], $settings, array_values($entryMap));
flash('success', 'Der Moment wurde aktualisiert.');
redirect('/?view=day&date=' . rawurlencode($date));
}
if ($form === 'delete_event') {
$eventID = trim((string) ($_POST['event_id'] ?? ''));
$current['events'] = array_values(array_filter(
is_array($current['events'] ?? null) ? $current['events'] : [],
static fn (array $event): bool => (string) ($event['id'] ?? '') !== $eventID
));
$entryMap[$date] = $current;
$this->persistUserEntries($user['username'], $settings, array_values($entryMap));
flash('success', 'Die Aktivität wurde entfernt.');
redirect('/?view=day&date=' . rawurlencode($date));
}
if ($form === 'remove_background') {
$this->deleteDashboardBackgroundImage($user['username'], $date, (string) ($current['background_image'] ?? ''));
$current['background_image'] = '';
$entryMap[$date] = $current;
$this->persistUserEntries($user['username'], $settings, array_values($entryMap));
flash('success', 'Das Tagesbild wurde entfernt.');
redirect('/?view=day&date=' . rawurlencode($date));
}
} catch (RuntimeException $exception) {
flash('error', $exception->getMessage());
redirect('/?view=day&date=' . rawurlencode($date));
}
redirect('/?view=day&date=' . rawurlencode($date));
}
private function normalizeDashboardView(string $view): string
{
return in_array($view, ['day', 'week', 'month'], true) ? $view : 'day';
}
private function buildEmptyDashboardEntry(string $date, array $settings, ?array $previousEntry = null): array
{
$entry = $this->scoring->normalize([
'date' => $date,
'pain_enabled' => !empty($settings['tracking']['pain_enabled']),
'walk_mode' => (string) ($settings['walk']['mode'] ?? 'time'),
'summary' => [
'comment' => '',
'mood' => 0,
'energy' => 0,
'stress' => 0,
'alcohol' => false,
],
'events' => [],
'background_image' => '',
]);
return array_merge($entry, [
'evaluation' => $this->scoring->evaluate($entry, $settings, $previousEntry),
'sport_type_meta' => [],
]);
}
private function buildDashboardTimeline(array $entry): array
{
$timeline = [];
foreach (is_array($entry['events'] ?? null) ? $entry['events'] : [] as $event) {
if (!is_array($event)) {
continue;
}
$timeline[] = [
'kind' => 'event',
'id' => (string) ($event['id'] ?? ''),
'type' => (string) ($event['type'] ?? 'event'),
'time' => (string) ($event['time'] ?? ''),
'comment' => (string) ($event['comment'] ?? ''),
'value' => (float) ($event['value'] ?? 0),
'unit' => (string) ($event['unit'] ?? ''),
'sport_type_id' => (string) ($event['sport_type_id'] ?? ''),
'consumed' => !empty($event['consumed']),
'mood' => normalize_signal_value($event['mood'] ?? 0),
'energy' => normalize_signal_value($event['energy'] ?? 0),
'stress' => normalize_signal_value($event['stress'] ?? 0),
];
}
return $timeline;
}
private function buildDashboardCompareDays(string $date, array $entryMap, array $settings): array
{
$days = [];
for ($offset = -3; $offset <= 1; $offset++) {
$dayDate = shift_date($date, $offset);
$entry = $entryMap[$dayDate] ?? $this->buildEmptyDashboardEntry($dayDate, $settings, $entryMap[shift_date($dayDate, -1)] ?? null);
$isPersisted = isset($entryMap[$dayDate]);
$hasContent = $isPersisted || $this->entryHasContent($entry);
$visualScore = $this->dashboardVisualScore($entry, $isPersisted);
$days[] = [
'date' => $dayDate,
'short' => (new DateTimeImmutable($dayDate))->format('D'),
'day' => format_compact_date($dayDate),
'offset' => $offset,
'is_current' => $dayDate === $date,
'has_content' => $hasContent,
'visual_score' => $visualScore,
'score_level' => $visualScore,
'line_level' => $this->dashboardLineLevel($entry, $isPersisted),
'line_tone' => $visualScore === null ? 'empty' : signal_value_class($visualScore),
];
}
return $days;
}
private function buildDashboardWeekView(string $date, array $entryMap): array
{
$current = new DateTimeImmutable($date);
$selectedStart = $current->modify('monday this week');
$selectedKey = $selectedStart->format('Y-m-d');
$currentStart = (new DateTimeImmutable(today()))->modify('monday this week');
$currentKey = $currentStart->format('Y-m-d');
$weekKeys = [$currentKey => true, $selectedKey => true];
foreach (array_keys($entryMap) as $entryDate) {
if (!$this->isValidDate((string) $entryDate)) {
continue;
}
$weekKeys[(new DateTimeImmutable((string) $entryDate))->modify('monday this week')->format('Y-m-d')] = true;
}
unset($weekKeys[$currentKey]);
$otherWeekKeys = array_keys($weekKeys);
rsort($otherWeekKeys, SORT_STRING);
$orderedWeekKeys = array_merge([$currentKey], $otherWeekKeys);
$periods = [];
foreach ($orderedWeekKeys as $weekKey) {
$periods[] = $this->buildDashboardWeekPeriod(new DateTimeImmutable((string) $weekKey), $date, $entryMap, $weekKey === $selectedKey);
}
$selectedPeriod = $this->buildDashboardWeekPeriod($selectedStart, $date, $entryMap, true);
return array_merge($selectedPeriod, [
'periods' => $periods,
]);
}
private function buildDashboardWeekPeriod(DateTimeImmutable $start, string $selectedDate, array $entryMap, bool $isSelected): array
{
$days = [];
for ($index = 0; $index < 7; $index++) {
$day = $start->modify('+' . $index . ' day');
$iso = $day->format('Y-m-d');
$entry = $entryMap[$iso] ?? null;
$hasContent = $entry !== null && $this->entryHasContent($entry);
$visualScore = $entry !== null ? $this->dashboardVisualScore($entry, true) : null;
$days[] = [
'date' => $iso,
'weekday' => format_display_date($iso, true),
'short' => $day->format('D'),
'day' => $day->format('j'),
'entry' => $entry,
'has_content' => $hasContent,
'score_level' => $visualScore,
'line_tone' => $visualScore === null ? 'empty' : signal_value_class($visualScore),
'is_current' => $iso === $selectedDate,
];
}
return [
'key' => $start->format('Y-m-d'),
'title' => 'Woche ' . $start->format('W'),
'range' => $start->format('j.n.Y') . ' - ' . $start->modify('+6 day')->format('j.n.Y'),
'is_selected' => $isSelected,
'days' => $days,
];
}
private function buildDashboardMonthView(string $date, array $entryMap): array
{
$current = new DateTimeImmutable($date);
$selectedStart = $current->modify('first day of this month');
$selectedKey = $selectedStart->format('Y-m-d');
$currentStart = (new DateTimeImmutable(today()))->modify('first day of this month');
$currentKey = $currentStart->format('Y-m-d');
$monthKeys = [$currentKey => true, $selectedKey => true];
foreach (array_keys($entryMap) as $entryDate) {
if (!$this->isValidDate((string) $entryDate)) {
continue;
}
$monthKeys[(new DateTimeImmutable((string) $entryDate))->modify('first day of this month')->format('Y-m-d')] = true;
}
unset($monthKeys[$currentKey]);
$otherMonthKeys = array_keys($monthKeys);
rsort($otherMonthKeys, SORT_STRING);
$orderedMonthKeys = array_merge([$currentKey], $otherMonthKeys);
$periods = [];
foreach ($orderedMonthKeys as $monthKey) {
$periods[] = $this->buildDashboardMonthPeriod(new DateTimeImmutable((string) $monthKey), $date, $entryMap, $monthKey === $selectedKey);
}
$selectedPeriod = $this->buildDashboardMonthPeriod($selectedStart, $date, $entryMap, true);
return array_merge($selectedPeriod, [
'periods' => $periods,
]);
}
private function buildDashboardMonthPeriod(DateTimeImmutable $start, string $selectedDate, array $entryMap, bool $isSelected): array
{
$end = $start->modify('last day of this month');
$days = [];
for ($day = $start; $day <= $end; $day = $day->modify('+1 day')) {
$iso = $day->format('Y-m-d');
$entry = $entryMap[$iso] ?? null;
$hasContent = $entry !== null && $this->entryHasContent($entry);
$visualScore = $entry !== null ? $this->dashboardVisualScore($entry, true) : null;
$days[] = [
'date' => $iso,
'day' => $day->format('j'),
'weekday' => format_display_date($iso, true),
'entry' => $entry,
'has_content' => $hasContent,
'score_level' => $visualScore,
'line_tone' => $visualScore === null ? 'empty' : signal_value_class($visualScore),
'is_future' => $iso > $selectedDate,
];
}
return [
'key' => $start->format('Y-m-d'),
'title' => month_label($start->format('Y-m')),
'is_selected' => $isSelected,
'days' => $days,
];
}
private function entryHasContent(array $entry, bool $isPersisted = false): bool
{
if ($isPersisted) {
return true;
}
if (trim((string) ($entry['summary']['comment'] ?? '')) !== '') {
return true;
}
if (!empty($entry['summary']['alcohol'] ?? $entry['summary_alcohol'] ?? false)) {
return true;
}
if (trim((string) ($entry['background_image'] ?? '')) !== '') {
return true;
}
return count(array_filter(is_array($entry['events'] ?? null) ? $entry['events'] : [], 'is_array')) > 0;
}
private function dashboardVisualScore(array $entry, bool $isPersisted = false): ?int
{
if (!$this->entryHasContent($entry, $isPersisted)) {
return null;
}
$summary = is_array($entry['summary'] ?? null) ? $entry['summary'] : [];
$mood = normalize_signal_value($summary['mood'] ?? $entry['summary_mood'] ?? legacy_to_signal_scale($entry['mood'] ?? 5));
$energy = normalize_signal_value($summary['energy'] ?? $entry['summary_energy'] ?? legacy_to_signal_scale($entry['energy'] ?? 5));
$stress = normalize_signal_value($summary['stress'] ?? $entry['summary_stress'] ?? legacy_to_signal_scale($entry['stress'] ?? 5));
return signal_combo_score($mood, $energy, $stress);
}
private function dashboardLineLevel(array $entry, bool $isPersisted = false): ?int
{
if (!$this->entryHasContent($entry, $isPersisted)) {
return null;
}
$percentage = max(0.0, min(100.0, (float) ($entry['evaluation']['percentage'] ?? 0)));
return (int) round($percentage / 5);
}
private function dashboardLineTone(array $entry, bool $isPersisted = false): string
{
return signal_value_class($this->dashboardVisualScore($entry, $isPersisted) ?? 0);
}
private function dashboardEventFromPost(array $input): array
{
$type = trim((string) ($input['event_type'] ?? 'event'));
if (!array_key_exists($type, day_event_type_options())) {
$type = 'event';
}
$time = trim((string) ($input['event_time'] ?? ''));
if (!$this->isValidTime($time)) {
$time = date('H:i');
}
$comment = trim((string) ($input['event_comment'] ?? ''));
$value = max(0, min(50000, (float) ($input['event_value'] ?? 0)));
if (in_array($type, ['walk', 'sport', 'sleep'], true) && $value <= 0) {
throw new RuntimeException('Für diese Aktivität braucht es einen Wert oder eine Dauer.');
}
$sportTypeID = trim((string) ($input['event_sport_type_id'] ?? ''));
if ($type === 'sport' && $sportTypeID === '') {
throw new RuntimeException('Bitte wähle eine Sportart.');
}
$unit = trim((string) ($input['event_unit'] ?? day_event_type_unit($type)));
if ($type === 'walk') {
$walkMode = trim((string) ($input['event_walk_mode'] ?? 'time'));
$unit = $walkMode === 'steps' ? 'steps' : 'min';
}
return [
'id' => 'evt-' . substr(sha1((string) microtime(true) . $type . $time . (string) ($input['event_comment'] ?? '')), 0, 12),
'type' => $type,
'time' => $time,
'comment' => $comment,
'value' => $value,
'unit' => $unit,
'sport_type_id' => $sportTypeID,
'consumed' => isset($input['event_consumed']) ? ((string) $input['event_consumed'] === '1') : true,
'mood' => normalize_signal_value($input['event_mood'] ?? 0),
'energy' => normalize_signal_value($input['event_energy'] ?? 0),
'stress' => normalize_signal_value($input['event_stress'] ?? 0),
];
}
private function dashboardMediaDirectory(string $username): string
{
return storage_path('users/' . normalize_username($username) . '/media');
}
private function withDashboardImageState(string $username, array $entry): array
{
$fileName = trim((string) ($entry['background_image'] ?? ''));
$date = (string) ($entry['date'] ?? '');
$entry['background_image_url'] = null;
if ($fileName === '' || !$this->isValidDate($date)) {
return $entry;
}
$path = $this->dashboardMediaDirectory($username) . '/' . basename($fileName);
if (is_file($path)) {
$entry['background_image_url'] = '/day-image?date=' . rawurlencode($date);
}
return $entry;
}
private function storeDashboardBackgroundImage(string $username, string $date, array $upload): string
{
$error = (int) ($upload['error'] ?? UPLOAD_ERR_NO_FILE);
if ($error !== UPLOAD_ERR_OK) {
throw new RuntimeException('Das Tagesbild konnte nicht hochgeladen werden.');
}
$tmpName = (string) ($upload['tmp_name'] ?? '');
if ($tmpName === '' || !is_uploaded_file($tmpName)) {
throw new RuntimeException('Die hochgeladene Bilddatei ist ungültig.');
}
$mime = mime_content_type($tmpName) ?: '';
$extension = match ($mime) {
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/webp' => 'webp',
default => '',
};
if ($extension === '') {
throw new RuntimeException('Bitte nutze JPG, PNG oder WebP als Tagesbild.');
}
$directory = $this->dashboardMediaDirectory($username);
if (!is_dir($directory)) {
mkdir($directory, 0775, true);
}
$fileName = $date . '-' . substr(sha1((string) microtime(true) . $username), 0, 10) . '.' . $extension;
$target = $directory . '/' . $fileName;
if (!move_uploaded_file($tmpName, $target)) {
throw new RuntimeException('Das Tagesbild konnte nicht gespeichert werden.');
}
return $fileName;
}
private function deleteDashboardBackgroundImage(string $username, string $date, string $fileName): void
{
$path = $this->dashboardMediaDirectory($username) . '/' . basename($fileName);
if (is_file($path)) {
@unlink($path);
}
}
private function serveDayImage(): void
{
$user = $this->requireUser();
$date = (string) ($_GET['date'] ?? '');
if (!$this->isValidDate($date)) {
http_response_code(404);
exit('Nicht gefunden');
}
$entry = $this->entries->find($user['username'], $date);
$fileName = trim((string) ($entry['background_image'] ?? ''));
if ($fileName === '') {
http_response_code(404);
exit('Nicht gefunden');
}
$path = $this->dashboardMediaDirectory($user['username']) . '/' . basename($fileName);
if (!is_file($path)) {
http_response_code(404);
exit('Nicht gefunden');
}
$mime = mime_content_type($path) ?: 'application/octet-stream';
header('Content-Type: ' . $mime);
header('Content-Length: ' . (string) filesize($path));
header('Cache-Control: private, max-age=3600');
readfile($path);
exit;
}
private function showTrack(): void private function showTrack(): void
{ {
$user = $this->requireUser(); $user = $this->requireUser();
@@ -507,6 +1074,7 @@ final class App
{ {
$user = $this->requireUser(); $user = $this->requireUser();
$settings = $this->hydrateSettings($this->settings->forUser($user['username'])); $settings = $this->hydrateSettings($this->settings->forUser($user['username']));
$evaluatedEntries = $this->evaluateEntriesWithContext($this->entries->all($user['username']), $settings);
$sportTypePresets = array_values(array_filter( $sportTypePresets = array_values(array_filter(
Defaults::settings()['sport_types'], Defaults::settings()['sport_types'],
static function (array $preset) use ($settings): bool { static function (array $preset) use ($settings): bool {
@@ -534,6 +1102,7 @@ final class App
'pageTitle' => 'Optionen', 'pageTitle' => 'Optionen',
'page' => 'options', 'page' => 'options',
'authUser' => $user, 'authUser' => $user,
'optionsOpenPanel' => trim((string) ($_GET['panel'] ?? '')),
'settings' => $settings, 'settings' => $settings,
'sportTypePresets' => $sportTypePresets, 'sportTypePresets' => $sportTypePresets,
'sportLocationOptions' => sport_location_options(), 'sportLocationOptions' => sport_location_options(),
@@ -545,6 +1114,8 @@ final class App
'aiConfig' => $user['is_admin'] ? $this->aiConfig->get() : null, 'aiConfig' => $user['is_admin'] ? $this->aiConfig->get() : null,
'aiStatus' => $user['is_admin'] ? $this->openAi->configuration() : null, 'aiStatus' => $user['is_admin'] ? $this->openAi->configuration() : null,
'users' => $user['is_admin'] ? $this->users->all() : [], 'users' => $user['is_admin'] ? $this->users->all() : [],
'statsSummary' => $this->buildDashboardSummary($evaluatedEntries),
'statsChartPayload' => encode_payload($this->buildDashboardCharts($evaluatedEntries)),
'maxScore' => $this->scoring->evaluate([ 'maxScore' => $this->scoring->evaluate([
'mood' => 10, 'mood' => 10,
'energy' => 10, 'energy' => 10,
+137 -3
View File
@@ -96,6 +96,10 @@ final class EntryRepository
private function parse(string $content, string $fallbackDate): ?array private function parse(string $content, string $fallbackDate): ?array
{ {
if (str_contains($content, '<!-- mood-tracker:v3 -->')) {
return $this->parseV3($content, $fallbackDate);
}
$sportTypes = []; $sportTypes = [];
$sportTypesRaw = (string) ($this->extract('/^- Sportarten:\s*(.*)$/mu', $content) ?? ''); $sportTypesRaw = (string) ($this->extract('/^- Sportarten:\s*(.*)$/mu', $content) ?? '');
if ($sportTypesRaw !== '') { if ($sportTypesRaw !== '') {
@@ -134,6 +138,19 @@ final class EntryRepository
'walk_steps' => $walkMode === 'steps' ? $walkValue : 0, 'walk_steps' => $walkMode === 'steps' ? $walkValue : 0,
'alcohol' => in_array($alcoholRaw, ['ja', 'yes', 'true', '1'], true), 'alcohol' => in_array($alcoholRaw, ['ja', 'yes', 'true', '1'], true),
'note' => $this->extractNote($content), 'note' => $this->extractNote($content),
'summary' => [
'comment' => $this->extractNote($content),
'mood' => legacy_to_signal_scale((int) ($this->extract('/^- Stimmung:\s*(.+)$/m', $content) ?? 5)),
'energy' => legacy_to_signal_scale((int) ($this->extract('/^- Energie:\s*(.+)$/m', $content) ?? 5)),
'stress' => legacy_to_signal_scale((int) ($this->extract('/^- Stress:\s*(.+)$/m', $content) ?? 5)),
'alcohol' => in_array($alcoholRaw, ['ja', 'yes', 'true', '1'], true),
],
'summary_comment' => $this->extractNote($content),
'summary_mood' => legacy_to_signal_scale((int) ($this->extract('/^- Stimmung:\s*(.+)$/m', $content) ?? 5)),
'summary_energy' => legacy_to_signal_scale((int) ($this->extract('/^- Energie:\s*(.+)$/m', $content) ?? 5)),
'summary_stress' => legacy_to_signal_scale((int) ($this->extract('/^- Stress:\s*(.+)$/m', $content) ?? 5)),
'background_image' => '',
'events' => [],
]; ];
return $entry; return $entry;
@@ -163,18 +180,56 @@ final class EntryRepository
private function toMarkdown(string $username, string $date, array $entry, array $evaluation): string private function toMarkdown(string $username, string $date, array $entry, array $evaluation): string
{ {
$summary = is_array($entry['summary'] ?? null) ? $entry['summary'] : [
'comment' => (string) ($entry['summary_comment'] ?? $entry['note'] ?? ''),
'mood' => normalize_signal_value($entry['summary_mood'] ?? legacy_to_signal_scale($entry['mood'] ?? 5)),
'energy' => normalize_signal_value($entry['summary_energy'] ?? legacy_to_signal_scale($entry['energy'] ?? 5)),
'stress' => normalize_signal_value($entry['summary_stress'] ?? legacy_to_signal_scale($entry['stress'] ?? 5)),
];
$events = is_array($entry['events'] ?? null) ? $entry['events'] : [];
$sportTypes = $evaluation['sport_types'] ?? []; $sportTypes = $evaluation['sport_types'] ?? [];
$sportTypeValues = array_map( $sportTypeValues = array_map(
static fn (array $type): string => (string) $type['label'] . ' [' . (string) $type['id'] . ']', static fn (array $type): string => (string) $type['label'] . ' [' . (string) $type['id'] . ']',
array_filter($sportTypes, 'is_array') array_filter($sportTypes, 'is_array')
); );
$eventLines = [];
foreach ($events as $event) {
if (!is_array($event)) {
continue;
}
$eventLines[] = '### ' . ((string) ($event['id'] ?? 'evt'));
$eventLines[] = '- Typ: ' . day_event_type_label((string) ($event['type'] ?? 'event')) . ' [' . (string) ($event['type'] ?? 'event') . ']';
$eventLines[] = '- Uhrzeit: ' . (string) ($event['time'] ?? '');
$eventLines[] = '- Wert: ' . (string) ($event['value'] ?? 0);
$eventLines[] = '- Einheit: ' . (string) ($event['unit'] ?? '');
$eventLines[] = '- Sportart: ' . (string) ($event['sport_type_id'] ?? '');
$eventLines[] = '- Getrunken: ' . (!empty($event['consumed']) ? 'ja' : 'nein');
$eventLines[] = '- Kommentar: ' . (string) ($event['comment'] ?? '');
$eventLines[] = '- Stimmung: ' . (string) ($event['mood'] ?? 0);
$eventLines[] = '- Energie: ' . (string) ($event['energy'] ?? 0);
$eventLines[] = '- Stress: ' . (string) ($event['stress'] ?? 0);
$eventLines[] = '';
}
$lines = [ $lines = [
'<!-- mood-tracker:v2 -->', '<!-- mood-tracker:v3 -->',
'# Stimmungstracker', '# Stimmungstracker Tag',
'Datum: ' . $date, 'Datum: ' . $date,
'Benutzer: ' . normalize_username($username), 'Benutzer: ' . normalize_username($username),
'Hintergrundbild: ' . trim((string) ($entry['background_image'] ?? '')),
'', '',
'## Tagesbilanz',
'- Kommentar: ' . trim((string) ($summary['comment'] ?? '')),
'- Stimmung: ' . normalize_signal_value($summary['mood'] ?? 0),
'- Energie: ' . normalize_signal_value($summary['energy'] ?? 0),
'- Stress: ' . normalize_signal_value($summary['stress'] ?? 0),
'- Alkohol: ' . (!empty($summary['alcohol']) ? 'ja' : 'nein'),
'',
'## Ereignisse',
...($eventLines !== [] ? $eventLines : ['- Keine Ereignisse', '']),
'## Tracking',
'## Werte', '## Werte',
'- Stimmung: ' . $entry['mood'], '- Stimmung: ' . $entry['mood'],
'- Energie: ' . $entry['energy'], '- Energie: ' . $entry['energy'],
@@ -202,14 +257,93 @@ final class EntryRepository
'- Sport: ' . format_points((float) $evaluation['components']['sport_minutes']), '- Sport: ' . format_points((float) $evaluation['components']['sport_minutes']),
'- Sportbonus: ' . format_points((float) ($evaluation['components']['sport_bonus'] ?? 0)), '- Sportbonus: ' . format_points((float) ($evaluation['components']['sport_bonus'] ?? 0)),
'- Spaziergang: ' . format_points((float) $evaluation['components']['walk_minutes']), '- Spaziergang: ' . format_points((float) $evaluation['components']['walk_minutes']),
'- Momente: ' . format_points((float) ($evaluation['components']['events'] ?? 0)),
'- Alkohol: ' . format_points((float) ($evaluation['components']['alcohol'] ?? 0)), '- Alkohol: ' . format_points((float) ($evaluation['components']['alcohol'] ?? 0)),
'- Notiz: ' . format_points((float) $evaluation['components']['note']), '- Notiz: ' . format_points((float) $evaluation['components']['note']),
'', '',
'## Notiz', '## Notiz',
trim((string) $entry['note']), trim((string) ($summary['comment'] ?? $entry['note'] ?? '')),
'', '',
]; ];
return implode("\n", $lines); return implode("\n", $lines);
} }
private function parseV3(string $content, string $fallbackDate): ?array
{
$date = $this->extract('/^Datum:\s*(\d{4}-\d{2}-\d{2})$/m', $content) ?? $fallbackDate;
$backgroundImage = trim((string) ($this->extract('/^Hintergrundbild:[ \t]*([^\r\n]*)$/mu', $content) ?? ''));
if (preg_match('/^[A-Za-z0-9._-]+\.(?:jpe?g|png|webp)$/i', $backgroundImage) !== 1) {
$backgroundImage = '';
}
$summarySection = $this->extractSection($content, '## Tagesbilanz', '## Ereignisse');
$eventsSection = $this->extractSection($content, '## Ereignisse', '## 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) ?? ''),
'consumed' => in_array(strtolower((string) ($this->extract('/^- Getrunken:\s*(.*)$/mu', $block) ?? 'ja')), ['ja', 'yes', 'true', '1'], true),
'comment' => (string) ($this->extract('/^- Kommentar:\s*(.*)$/mu', $block) ?? ''),
'mood' => normalize_signal_value($this->extract('/^- Stimmung:\s*(-?\d+)$/m', $block) ?? 0),
'energy' => normalize_signal_value($this->extract('/^- Energie:\s*(-?\d+)$/m', $block) ?? 0),
'stress' => normalize_signal_value($this->extract('/^- Stress:\s*(-?\d+)$/m', $block) ?? 0),
];
}
$base['date'] = $date;
$base['background_image'] = $backgroundImage;
$base['summary'] = $summary;
$base['summary_comment'] = $summary['comment'];
$base['summary_mood'] = $summary['mood'];
$base['summary_energy'] = $summary['energy'];
$base['summary_stress'] = $summary['stress'];
$base['summary_alcohol'] = !empty($summary['alcohol']);
$base['events'] = $events;
$base['alcohol'] = !empty($summary['alcohol']);
$base['note'] = $summary['comment'];
return $base;
}
private function extractSection(string $content, string $startHeading, string $endHeading): ?string
{
$pattern = '/' . preg_quote($startHeading, '/') . '\s*(.*?)\s*' . preg_quote($endHeading, '/') . '/su';
if (preg_match($pattern, $content, $matches) !== 1) {
return null;
}
return trim((string) ($matches[1] ?? ''));
}
} }
+180 -12
View File
@@ -6,25 +6,47 @@ final class ScoringService
{ {
public function normalize(array $input): array public function normalize(array $input): array
{ {
$sportTypes = normalize_sport_type_selection($input['sport_types'] ?? ($input['sport_type'] ?? [])); $hasSummaryInput = is_array($input['summary'] ?? null)
|| array_key_exists('summary_mood', $input)
|| array_key_exists('summary_energy', $input)
|| array_key_exists('summary_stress', $input);
$hasEventInput = is_array($input['events'] ?? null) && $input['events'] !== [];
$summary = $this->normalizeSummary($input['summary'] ?? [
'comment' => $input['summary_comment'] ?? ($input['note'] ?? ''),
'mood' => $input['summary_mood'] ?? legacy_to_signal_scale($input['mood'] ?? 5),
'energy' => $input['summary_energy'] ?? legacy_to_signal_scale($input['energy'] ?? 5),
'stress' => $input['summary_stress'] ?? legacy_to_signal_scale($input['stress'] ?? 5),
'alcohol' => !empty($input['summary_alcohol'] ?? $input['alcohol'] ?? false),
]);
$events = $this->normalizeEvents($input['events'] ?? []);
$derived = $this->deriveLegacyFieldsFromEvents($events, $summary, $input);
$sportTypes = normalize_sport_type_selection($hasEventInput ? ($derived['sport_types'] ?? []) : ($input['sport_types'] ?? ($derived['sport_types'] ?? ($input['sport_type'] ?? []))));
return [ return [
'date' => $input['date'] ?? today(), 'date' => $input['date'] ?? today(),
'mood' => max(1, min(10, (int) ($input['mood'] ?? 5))), 'mood' => max(1, min(10, (int) ($hasSummaryInput ? $derived['mood'] : ($input['mood'] ?? $derived['mood'])))),
'energy' => max(1, min(10, (int) ($input['energy'] ?? 5))), 'energy' => max(1, min(10, (int) ($hasSummaryInput ? $derived['energy'] : ($input['energy'] ?? $derived['energy'])))),
'stress' => max(1, min(10, (int) ($input['stress'] ?? 5))), 'stress' => max(1, min(10, (int) ($hasSummaryInput ? $derived['stress'] : ($input['stress'] ?? $derived['stress'])))),
'pain' => max(1, min(10, (int) ($input['pain'] ?? 1))), 'pain' => max(1, min(10, (int) ($input['pain'] ?? 1))),
'pain_enabled' => $this->normalizeBoolean($input['pain_enabled'] ?? false), 'pain_enabled' => $this->normalizeBoolean($input['pain_enabled'] ?? false),
'sleep_hours' => max(0, min(24, (float) ($input['sleep_hours'] ?? 0))), 'sleep_hours' => max(0, min(24, (float) ($hasEventInput ? $derived['sleep_hours'] : ($input['sleep_hours'] ?? $derived['sleep_hours'])))),
'sleep_feeling' => max(1, min(5, (int) ($input['sleep_feeling'] ?? 3))), 'sleep_feeling' => max(1, min(5, (int) ($hasSummaryInput ? $derived['sleep_feeling'] : ($input['sleep_feeling'] ?? $derived['sleep_feeling'])))),
'sport_minutes' => max(0, min(1440, (int) ($input['sport_minutes'] ?? 0))), 'sport_minutes' => max(0, min(1440, (int) ($hasEventInput ? $derived['sport_minutes'] : ($input['sport_minutes'] ?? $derived['sport_minutes'])))),
'sport_type' => $sportTypes[0] ?? '', 'sport_type' => $sportTypes[0] ?? '',
'sport_types' => $sportTypes, 'sport_types' => $sportTypes,
'walk_mode' => $this->normalizeWalkMode((string) ($input['walk_mode'] ?? ((isset($input['walk_steps']) && !isset($input['walk_minutes'])) ? 'steps' : 'time'))), 'walk_mode' => $this->normalizeWalkMode((string) ($input['walk_mode'] ?? $derived['walk_mode'] ?? ((isset($input['walk_steps']) && !isset($input['walk_minutes'])) ? 'steps' : 'time'))),
'walk_minutes' => max(0, min(1440, (int) ($input['walk_minutes'] ?? 0))), 'walk_minutes' => max(0, min(1440, (int) ($hasEventInput ? $derived['walk_minutes'] : ($input['walk_minutes'] ?? $derived['walk_minutes'])))),
'walk_steps' => max(0, min(50000, (int) ($input['walk_steps'] ?? 0))), 'walk_steps' => max(0, min(50000, (int) ($hasEventInput ? $derived['walk_steps'] : ($input['walk_steps'] ?? $derived['walk_steps'])))),
'alcohol' => $this->normalizeBoolean($input['alcohol'] ?? false), 'alcohol' => $this->normalizeBoolean($hasSummaryInput ? $derived['alcohol'] : ($input['alcohol'] ?? $derived['alcohol'])),
'note' => trim((string) ($input['note'] ?? '')), 'note' => trim((string) ($input['note'] ?? $summary['comment'])),
'summary' => $summary,
'summary_comment' => $summary['comment'],
'summary_mood' => $summary['mood'],
'summary_energy' => $summary['energy'],
'summary_stress' => $summary['stress'],
'summary_alcohol' => !empty($summary['alcohol']),
'background_image' => trim((string) ($input['background_image'] ?? '')),
'events' => $events,
]; ];
} }
@@ -36,6 +58,7 @@ final class ScoringService
$ratings = $this->sortedRatings($settings['ratings'] ?? []); $ratings = $this->sortedRatings($settings['ratings'] ?? []);
$sportTypes = find_sport_types($settings, $entry['sport_types']); $sportTypes = find_sport_types($settings, $entry['sport_types']);
$sportBonus = $this->sportBonusPoints($entry, $previousEntry, $settings, $sportTypes); $sportBonus = $this->sportBonusPoints($entry, $previousEntry, $settings, $sportTypes);
$eventSignalPoints = $this->eventSignalPoints($entry['events']);
$painEnabled = !empty($settings['tracking']['pain_enabled']); $painEnabled = !empty($settings['tracking']['pain_enabled']);
$components = [ $components = [
@@ -47,6 +70,7 @@ final class ScoringService
'sport_minutes' => $this->bandPoints((int) $entry['sport_minutes'], $scoring['sport_bands']), 'sport_minutes' => $this->bandPoints((int) $entry['sport_minutes'], $scoring['sport_bands']),
'sport_bonus' => $sportBonus, 'sport_bonus' => $sportBonus,
'walk_minutes' => $this->walkPoints($entry, $settings), 'walk_minutes' => $this->walkPoints($entry, $settings),
'events' => $eventSignalPoints,
'alcohol' => !empty($entry['alcohol']) ? ((float) abs((float) ($scoring['alcohol_penalty'] ?? 5)) * -1) : 0.0, 'alcohol' => !empty($entry['alcohol']) ? ((float) abs((float) ($scoring['alcohol_penalty'] ?? 5)) * -1) : 0.0,
'note' => $entry['note'] === '' ? 0 : (float) $scoring['journal_points'], 'note' => $entry['note'] === '' ? 0 : (float) $scoring['journal_points'],
]; ];
@@ -66,6 +90,7 @@ final class ScoringService
$this->maxBandPoints($scoring['sport_bands']) + $this->maxBandPoints($scoring['sport_bands']) +
$this->maxSportBonusPoints($settings) + $this->maxSportBonusPoints($settings) +
$this->maxWalkPoints($entry, $settings) + $this->maxWalkPoints($entry, $settings) +
($eventSignalPoints !== 0.0 ? 8.0 : 0.0) +
(float) $scoring['journal_points'], (float) $scoring['journal_points'],
1 1
); );
@@ -100,6 +125,28 @@ final class ScoringService
]; ];
} }
private function eventSignalPoints(array $events): float
{
if ($events === []) {
return 0.0;
}
$scores = [];
foreach ($events as $event) {
if (!is_array($event)) {
continue;
}
$scores[] = signal_combo_score($event['mood'] ?? 0, $event['energy'] ?? 0, $event['stress'] ?? 0);
}
if ($scores === []) {
return 0.0;
}
return round(max(-8.0, min(8.0, (array_sum($scores) / count($scores)) * 4.0)), 1);
}
private function sleepDurationPoints(float $hours, array $points): float private function sleepDurationPoints(float $hours, array $points): float
{ {
if ($hours < 4) { if ($hours < 4) {
@@ -304,6 +351,127 @@ final class ScoringService
return $total; return $total;
} }
private function normalizeSummary(mixed $summary): array
{
$summary = is_array($summary) ? $summary : [];
return [
'comment' => trim((string) ($summary['comment'] ?? '')),
'mood' => normalize_signal_value($summary['mood'] ?? 0),
'energy' => normalize_signal_value($summary['energy'] ?? 0),
'stress' => normalize_signal_value($summary['stress'] ?? 0),
'alcohol' => $this->normalizeBoolean($summary['alcohol'] ?? false),
];
}
private function normalizeEvents(mixed $events): array
{
if (!is_array($events)) {
return [];
}
$normalized = [];
foreach ($events as $event) {
if (!is_array($event)) {
continue;
}
$type = trim((string) ($event['type'] ?? 'event'));
if (!array_key_exists($type, day_event_type_options())) {
$type = 'event';
}
$time = trim((string) ($event['time'] ?? ''));
if (preg_match('/^(?:[01]\d|2[0-3]):[0-5]\d$/', $time) !== 1) {
$time = '';
}
$unit = trim((string) ($event['unit'] ?? day_event_type_unit($type)));
$value = is_numeric($event['value'] ?? null) ? (float) $event['value'] : 0.0;
$normalized[] = [
'id' => trim((string) ($event['id'] ?? '')) ?: ('evt-' . substr(sha1(json_encode($event) . microtime(true)), 0, 12)),
'type' => $type,
'time' => $time,
'comment' => trim(preg_replace('/\s+/u', ' ', (string) ($event['comment'] ?? '')) ?? ''),
'value' => max(0, min(50000, $value)),
'unit' => $unit,
'sport_type_id' => trim((string) ($event['sport_type_id'] ?? '')),
'consumed' => $this->normalizeBoolean($event['consumed'] ?? true),
'mood' => normalize_signal_value($event['mood'] ?? 0),
'energy' => normalize_signal_value($event['energy'] ?? 0),
'stress' => normalize_signal_value($event['stress'] ?? 0),
];
}
usort($normalized, static function (array $left, array $right): int {
return strcmp((string) ($left['time'] ?? ''), (string) ($right['time'] ?? ''));
});
return $normalized;
}
private function deriveLegacyFieldsFromEvents(array $events, array $summary, array $input): array
{
$sportMinutes = 0;
$walkMinutes = 0;
$walkSteps = 0;
$sleepHours = 0.0;
$alcohol = false;
$walkMode = $this->normalizeWalkMode((string) ($input['walk_mode'] ?? 'time'));
$sportTypes = [];
foreach ($events as $event) {
$type = (string) ($event['type'] ?? 'event');
$unit = (string) ($event['unit'] ?? '');
$value = (float) ($event['value'] ?? 0);
if ($type === 'sport') {
$sportMinutes += (int) round($value);
$sportTypeID = trim((string) ($event['sport_type_id'] ?? ''));
if ($sportTypeID !== '') {
$sportTypes[$sportTypeID] = true;
}
}
if ($type === 'walk') {
if ($unit === 'steps') {
$walkMode = 'steps';
$walkSteps += (int) round($value);
} else {
$walkMinutes += (int) round($value);
}
}
if ($type === 'sleep') {
$sleepHours += $unit === 'min' ? ($value / 60) : $value;
}
if ($type === 'alcohol') {
$alcohol = !empty($event['consumed']);
}
}
if (!empty($summary['alcohol'])) {
$alcohol = true;
}
return [
'mood' => signal_to_legacy_scale($summary['mood']),
'energy' => signal_to_legacy_scale($summary['energy']),
'stress' => signal_to_legacy_scale($summary['stress']),
'sleep_hours' => $sleepHours,
'sleep_feeling' => max(1, min(5, 3 + $summary['energy'])),
'sport_minutes' => $sportMinutes,
'walk_mode' => $walkMode,
'walk_minutes' => $walkMinutes,
'walk_steps' => $walkSteps,
'alcohol' => $alcohol,
'sport_types' => array_keys($sportTypes),
];
}
private function sortedRatings(array $ratings): array private function sortedRatings(array $ratings): array
{ {
usort($ratings, static fn (array $a, array $b): int => ((int) $a['min']) <=> ((int) $b['min'])); usort($ratings, static fn (array $a, array $b): int => ((int) $a['min']) <=> ((int) $b['min']));
+165
View File
@@ -634,3 +634,168 @@ function find_sport_types(array $settings, array $ids): array
return $types; return $types;
} }
function signal_scale_options(): array
{
return [
-2 => 'sehr niedrig',
-1 => 'niedrig',
0 => 'neutral',
1 => 'hoch',
2 => 'sehr hoch',
];
}
function signal_labels_for_metric(string $metric): array
{
return match ($metric) {
'stress' => [
-2 => 'sehr ruhig',
-1 => 'ruhig',
0 => 'neutral',
1 => 'angespannt',
2 => 'sehr angespannt',
],
'energy' => [
-2 => 'leer',
-1 => 'matt',
0 => 'okay',
1 => 'wach',
2 => 'kraftvoll',
],
default => [
-2 => 'sehr niedrig',
-1 => 'niedrig',
0 => 'neutral',
1 => 'hoch',
2 => 'sehr hoch',
],
};
}
function normalize_signal_value(mixed $value): int
{
return max(-2, min(2, (int) $value));
}
function signal_to_legacy_scale(mixed $value): int
{
return match (normalize_signal_value($value)) {
-2 => 1,
-1 => 3,
0 => 5,
1 => 7,
2 => 9,
};
}
function legacy_to_signal_scale(mixed $value): int
{
$legacy = max(1, min(10, (int) $value));
return match (true) {
$legacy <= 2 => -2,
$legacy <= 4 => -1,
$legacy <= 6 => 0,
$legacy <= 8 => 1,
default => 2,
};
}
function day_event_type_options(): array
{
return [
'event' => [
'label' => 'Ereignis',
'icon' => '/assets/icons/activity-event.svg',
'unit' => '',
],
'walk' => [
'label' => 'Spaziergang',
'icon' => sport_icon_path('hike'),
'unit' => 'min',
],
'sport' => [
'label' => 'Sport',
'icon' => sport_icon_path('run'),
'unit' => 'min',
],
'sleep' => [
'label' => 'Schlaf',
'icon' => '/assets/icons/activity-sleep.svg',
'unit' => 'h',
],
'alcohol' => [
'label' => 'Alkohol',
'icon' => icon_path('alcohol'),
'unit' => '',
],
];
}
function day_event_type_label(string $type): string
{
return day_event_type_options()[$type]['label'] ?? day_event_type_options()['event']['label'];
}
function day_event_type_icon(string $type): string
{
return day_event_type_options()[$type]['icon'] ?? day_event_type_options()['event']['icon'];
}
function day_event_type_unit(string $type): string
{
return day_event_type_options()[$type]['unit'] ?? '';
}
function signal_badge_tone(int $value, string $metric): string
{
$value = normalize_signal_value($value);
if ($metric === 'stress') {
return match (true) {
$value <= -1 => 'good',
$value === 0 => 'neutral',
default => 'warn',
};
}
return match (true) {
$value <= -1 => 'warn',
$value === 0 => 'neutral',
default => 'good',
};
}
function signal_combo_score(mixed $mood, mixed $energy, mixed $stress): int
{
return max(-2, min(2, (int) round((
normalize_signal_value($mood) +
normalize_signal_value($energy) -
normalize_signal_value($stress)
) / 3)));
}
function day_entry_has_content(array $entry): bool
{
if (trim((string) ($entry['summary']['comment'] ?? '')) !== '') {
return true;
}
if (trim((string) ($entry['background_image'] ?? '')) !== '') {
return true;
}
return count(array_filter(is_array($entry['events'] ?? null) ? $entry['events'] : [], 'is_array')) > 0;
}
function signal_value_class(int $value): string
{
return match (normalize_signal_value($value)) {
-2 => 'neg2',
-1 => 'neg1',
0 => 'zero',
1 => 'pos1',
2 => 'pos2',
};
}
+16 -19
View File
@@ -3,7 +3,7 @@
declare(strict_types=1); declare(strict_types=1);
$brandSubtitle = match ($page) { $brandSubtitle = match ($page) {
'dashboard' => 'Statistiken und Verlauf', 'dashboard' => '',
'track' => 'Tag erfassen und bewerten', 'track' => 'Tag erfassen und bewerten',
'archive' => '', 'archive' => '',
'options' => 'Logik, Erinnerungen, Sicherheit und Accounts', 'options' => 'Logik, Erinnerungen, Sicherheit und Accounts',
@@ -11,6 +11,9 @@ $brandSubtitle = match ($page) {
'setup' => 'Erstkonfiguration', 'setup' => 'Erstkonfiguration',
default => 'Stimmungstracker', default => 'Stimmungstracker',
}; };
$immersiveDashboard = $page === 'dashboard';
$cssVersion = is_file(base_path('assets/css/app.css')) ? (string) filemtime(base_path('assets/css/app.css')) : '1';
$jsVersion = is_file(base_path('assets/js/app.js')) ? (string) filemtime(base_path('assets/js/app.js')) : '1';
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="de"> <html lang="de">
@@ -33,15 +36,15 @@ $brandSubtitle = match ($page) {
<link rel="shortcut icon" href="/favicon.ico?v=20260412"> <link rel="shortcut icon" href="/favicon.ico?v=20260412">
<link rel="apple-touch-icon" sizes="180x180" href="/assets/branding/apple-touch-icon.png?v=20260412"> <link rel="apple-touch-icon" sizes="180x180" href="/assets/branding/apple-touch-icon.png?v=20260412">
<link rel="manifest" href="/manifest.webmanifest"> <link rel="manifest" href="/manifest.webmanifest">
<link rel="stylesheet" href="/assets/css/app.css"> <link rel="stylesheet" href="/assets/css/app.css?v=<?= e($cssVersion) ?>">
<script defer src="/assets/js/app.js"></script> <script defer src="/assets/js/app.js?v=<?= e($jsVersion) ?>"></script>
</head> </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-one"></div>
<div class="aurora aurora-two"></div> <div class="aurora aurora-two"></div>
<div class="pull-refresh-indicator glass-panel" data-pull-refresh-indicator aria-hidden="true">Zum Aktualisieren ziehen</div> <div class="pull-refresh-indicator glass-panel" data-pull-refresh-indicator aria-hidden="true">Zum Aktualisieren ziehen</div>
<div class="shell"> <div class="shell<?= $immersiveDashboard ? ' shell--dashboard' : '' ?>">
<?php if ($authUser !== null): ?> <?php if ($authUser !== null && !$immersiveDashboard): ?>
<aside class="sidebar glass-panel"> <aside class="sidebar glass-panel">
<div class="brand-block"> <div class="brand-block">
<div class="brand-mark"> <div class="brand-mark">
@@ -56,11 +59,7 @@ $brandSubtitle = match ($page) {
<nav class="main-nav" aria-label="Hauptnavigation"> <nav class="main-nav" aria-label="Hauptnavigation">
<a class="<?= is_active_path('/') ? 'active' : '' ?>" href="/"> <a class="<?= is_active_path('/') ? 'active' : '' ?>" href="/">
<img class="nav-icon" src="<?= e(icon_path('dashboard')) ?>" alt=""> <img class="nav-icon" src="<?= e(icon_path('dashboard')) ?>" alt="">
<span>Dashboard</span> <span>Start</span>
</a>
<a class="<?= is_active_path('/track') ? 'active' : '' ?>" href="/track">
<img class="nav-icon" src="<?= e(icon_path('track')) ?>" alt="">
<span>Tracken</span>
</a> </a>
<a class="<?= is_active_path('/archive') ? 'active' : '' ?>" href="/archive"> <a class="<?= is_active_path('/archive') ? 'active' : '' ?>" href="/archive">
<img class="nav-icon" src="<?= e(icon_path('archive')) ?>" alt=""> <img class="nav-icon" src="<?= e(icon_path('archive')) ?>" alt="">
@@ -86,7 +85,7 @@ $brandSubtitle = match ($page) {
<?php endif; ?> <?php endif; ?>
<main class="content"> <main class="content">
<?php if ($authUser !== null): ?> <?php if ($authUser !== null && !$immersiveDashboard): ?>
<header class="topbar glass-panel"> <header class="topbar glass-panel">
<div> <div>
<?php if ($brandSubtitle !== ''): ?> <?php if ($brandSubtitle !== ''): ?>
@@ -115,21 +114,19 @@ $brandSubtitle = match ($page) {
<?= $content ?> <?= $content ?>
<?php if (!$immersiveDashboard): ?>
<footer class="site-footer glass-panel"> <footer class="site-footer glass-panel">
<a class="site-footer__link" href="https://git.hnz.io/hnzio/mood-tracking/releases" target="_blank" rel="noreferrer">Version 1.2.1</a> <a class="site-footer__link" href="https://git.hnz.io/hnzio/mood-tracking/releases" target="_blank" rel="noreferrer">Version 1.5</a>
<a class="site-footer__link" href="https://hnz.io" target="_blank" rel="noreferrer">(c) 2026 @hnz.io</a> <a class="site-footer__link" href="https://hnz.io" target="_blank" rel="noreferrer">(c) 2026 @hnz.io</a>
</footer> </footer>
<?php endif; ?>
</main> </main>
<?php if ($authUser !== null): ?> <?php if ($authUser !== null && !$immersiveDashboard): ?>
<nav class="mobile-nav glass-panel" aria-label="Mobile Hauptnavigation"> <nav class="mobile-nav glass-panel" aria-label="Mobile Hauptnavigation">
<a class="<?= is_active_path('/') ? 'active' : '' ?>" href="/"> <a class="<?= is_active_path('/') ? 'active' : '' ?>" href="/">
<img class="nav-icon" src="<?= e(icon_path('dashboard')) ?>" alt=""> <img class="nav-icon" src="<?= e(icon_path('dashboard')) ?>" alt="">
<span>Dashboard</span> <span>Start</span>
</a>
<a class="<?= is_active_path('/track') ? 'active' : '' ?>" href="/track">
<img class="nav-icon" src="<?= e(icon_path('track')) ?>" alt="">
<span>Tracken</span>
</a> </a>
<a class="<?= is_active_path('/archive') ? 'active' : '' ?>" href="/archive"> <a class="<?= is_active_path('/archive') ? 'active' : '' ?>" href="/archive">
<img class="nav-icon" src="<?= e(icon_path('archive')) ?>" alt=""> <img class="nav-icon" src="<?= e(icon_path('archive')) ?>" alt="">
+505 -90
View File
@@ -1,99 +1,514 @@
<section class="hero-grid"> <?php
<article class="hero-card hero-card--wide glass-panel"> $dayDateLabel = format_display_date((string) $dayEntry['date']);
<p class="eyebrow">Stimmung im Blick</p> $dayWeekday = strtok($dayDateLabel, ',');
<h3>Dein Dashboard verbindet Verlauf, Score und Bewegungsdaten in einer schnellen Übersicht.</h3> $dayBackground = is_string($dayEntry['background_image_url'] ?? null) ? (string) $dayEntry['background_image_url'] : null;
<p class="hero-copy">Die Bewertung basiert auf deiner konfigurierbaren Logik. Sobald du die Regeln in den Optionen änderst, spiegeln Archiv und Statistiken die neue Gewichtung wider.</p> $summaryComment = trim((string) ($dayEntry['summary']['comment'] ?? $dayEntry['summary_comment'] ?? ''));
</article> $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);
?>
<article class="hero-card glass-panel"> <section class="dashboard-shell<?= $dayBackground !== null ? ' dashboard-shell--with-image' : '' ?>" data-dashboard-root>
<p class="eyebrow">Heute</p> <?php if ($dayBackground !== null): ?>
<?php if ($summary['today'] !== null): ?> <div class="dashboard-shell__background" aria-hidden="true">
<div class="hero-score"><?= e(format_points((float) $summary['today']['evaluation']['total'])) ?></div> <img src="<?= e($dayBackground) ?>" alt="">
<p class="hero-label"><?= e($summary['today']['evaluation']['label']) ?></p> </div>
<?php endif; ?>
<header class="dashboard-topbar">
<nav class="dashboard-switcher glass-panel" aria-label="Ansicht wechseln">
<a class="<?= $dashboardView === 'day' ? 'active' : '' ?>" href="/?view=day&amp;date=<?= e(rawurlencode($dashboardDate)) ?>">Tag</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__hero">
<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">
<?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>
<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>
</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 $sportType = ($item['type'] ?? '') === 'sport' ? find_sport_type($settings, (string) ($item['sport_type_id'] ?? '')) : null; ?>
<?php $eventTone = signal_value_class(normalize_signal_value($item['mood'] ?? 0)); ?>
<?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'] ?? ''),
'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),
]); ?>
<article class="timeline-card timeline-card--event timeline-card--<?= e($eventTone) ?> glass-panel" data-event-editable data-event-payload="<?= e($eventPayload) ?>">
<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><?= e($item['time'] !== '' ? $item['time'] : '--:--') ?></strong>
</div>
</div>
<div class="timeline-card__body">
<h3>
<?php if ((string) ($item['type'] ?? '') === 'alcohol'): ?>
<?= !empty($item['consumed']) ? 'Heute getrunken' : 'Heute nicht getrunken' ?>
<?php else: ?> <?php else: ?>
<div class="hero-score">-</div> <?= e($item['comment'] !== '' ? $item['comment'] : day_event_type_label((string) $item['type'])) ?>
<p class="hero-label">Noch kein Eintrag für heute</p>
<?php endif; ?> <?php endif; ?>
</article> </h3>
</section> <?php if ((float) $item['value'] > 0): ?>
<p class="timeline-card__value">
<section class="stats-grid"> <?= e(rtrim(rtrim(number_format((float) $item['value'], 2, ',', '.'), '0'), ',')) ?>
<article class="metric-card glass-panel"> <?= e((string) $item['unit']) ?>
<span>Getrackte Tage</span> <?php if ($sportType !== null): ?>
<strong><?= e((string) $summary['tracked_days']) ?></strong> · <?= e((string) ($sportType['label'] ?? '')) ?>
</article> <?php endif; ?>
<article class="metric-card glass-panel"> </p>
<span>Ø Score</span> <?php elseif ($sportType !== null): ?>
<strong><?= e(format_points((float) $summary['average_score'])) ?></strong> <p class="timeline-card__value"><?= e((string) ($sportType['label'] ?? '')) ?></p>
</article>
<article class="metric-card glass-panel">
<span>Ø Stimmung</span>
<strong><?= e(format_points((float) $summary['average_mood'])) ?>/10</strong>
</article>
<article class="metric-card glass-panel">
<span>Ø Stress</span>
<strong><?= e(format_points((float) $summary['average_stress'])) ?>/10</strong>
</article>
<article class="metric-card glass-panel">
<span>Serie</span>
<strong><?= e((string) $summary['streak']) ?> Tage</strong>
</article>
</section>
<section class="dashboard-grid">
<article class="glass-panel chart-card chart-card--calendar">
<div class="section-head">
<div>
<p class="eyebrow">Kalender</p>
<h3>Gesamtstimmung pro Tag</h3>
</div>
</div>
<div id="calendar-heatmap" class="calendar-heatmap" data-payload="<?= e($chartPayload) ?>"></div>
</article>
<article class="glass-panel chart-card">
<div class="section-head">
<div>
<p class="eyebrow">Trend</p>
<h3>Tagesstimmung</h3>
</div>
<span class="chart-chip">letzte 30 Einträge</span>
</div>
<div class="line-chart" data-chart-type="line" data-series="mood" data-payload="<?= e($chartPayload) ?>"></div>
</article>
<article class="glass-panel chart-card">
<div class="section-head">
<div>
<p class="eyebrow">Belastung</p>
<h3>Stressverlauf</h3>
</div>
<span class="chart-chip chart-chip--warm">weniger ist besser</span>
</div>
<div class="line-chart" data-chart-type="line" data-series="stress" data-payload="<?= e($chartPayload) ?>"></div>
</article>
<?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; ?> <?php endif; ?>
<article class="glass-panel chart-card chart-card--wide"> <?php if ((string) ($item['comment'] ?? '') !== '' && (string) ($item['type'] ?? '') === 'alcohol'): ?>
<div class="section-head"> <p class="timeline-card__value"><?= e((string) $item['comment']) ?></p>
<div> <?php endif; ?>
<p class="eyebrow">Aktivität</p>
<h3>Sport und Spaziergang</h3> <div class="signal-row">
<?php foreach (['mood' => 'Stimmung', 'energy' => 'Energie', 'stress' => 'Stress'] as $metric => $label): ?>
<?php $value = normalize_signal_value($item[$metric] ?? 0); ?>
<span class="signal-pill signal-pill--<?= e(signal_badge_tone($value, $metric)) ?>">
<strong><?= e($label) ?></strong>
<span><?= $value >= 0 ? '+' : '' ?><?= e((string) $value) ?></span>
</span>
<?php endforeach; ?>
</div> </div>
<span class="chart-chip chart-chip--cool">Aktivität pro Tag</span>
</div> </div>
<div class="bar-chart" data-chart-type="bars" data-series="sport" data-payload="<?= e($chartPayload) ?>"></div>
<form method="post" action="/" class="timeline-card__delete">
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="delete_event">
<input type="hidden" name="date" value="<?= e((string) $dayEntry['date']) ?>">
<input type="hidden" name="event_id" value="<?= e((string) $item['id']) ?>">
<button class="ghost-button ghost-button--small" type="submit" data-confirm-delete aria-label="Moment löschen">×</button>
</form>
</article> </article>
<?php endforeach; ?>
</div>
</section>
<button class="dashboard-fab" type="button" data-moment-overlay-open aria-label="Neuen Moment hinzufügen">+</button>
</div>
<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>
<?= 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>
<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="/" 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>
<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.25" 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>
<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>
<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">
<?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_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'] ?? '')) : '';
$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__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 ? rtrim(rtrim(number_format($eventValue, 2, ',', '.'), '0'), ',') . ' ' . (string) ($event['unit'] ?? '') : '';
$eventTitle = trim((string) ($event['comment'] ?? '')) !== '' ? trim((string) $event['comment']) : day_event_type_label($eventType);
$eventDetail = $eventValueText;
if ($eventType === 'sport') {
$eventTitle = (string) ($sportType['label'] ?? 'Sport');
}
if ($eventType === 'sleep') {
$eventTitle = 'Schlaf';
}
?>
<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 if ($eventDetail !== ''): ?>
<span><?= e($eventDetail) ?></span>
<?php endif; ?>
</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">
<?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'] ?? '')) : '';
return !empty($day['has_content']) || $summaryText !== '';
})); ?>
<?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'] ?? '')) : '';
$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__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=score"><strong>Score anpassen</strong><span>Multiplikatoren und Tageslogik</span></a>
<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=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>
</section> </section>
+166 -358
View File
@@ -1,18 +1,76 @@
<section class="page-grid"> <section class="options-shell">
<article class="glass-panel form-panel form-panel--wide"> <article class="glass-panel options-menu-panel">
<div class="section-head"> <div class="section-head">
<div> <div>
<p class="eyebrow">Dein Account</p> <p class="eyebrow">Optionen</p>
<h3>Score und Sportarten persönlich anpassen</h3> <h3>Einstellungen und Bereiche</h3>
<p class="helper-text">Alle Einstellungen auf dieser Seite gelten nur für deinen eigenen Account und beeinflussen keine anderen Nutzer.</p>
</div> </div>
<span class="chart-chip">Maximal <?= e(format_points((float) $maxScore)) ?> Punkte</span>
</div> </div>
<div class="options-menu-grid">
<button class="options-menu-card" type="button" data-options-open="score">
<strong>Score anpassen</strong>
<span>Multiplikatoren und Tageslogik</span>
</button>
<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="reminders">
<strong>Erinnerungen setzen</strong>
<span>Push und tägliche Erinnerung</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>
</article>
<div class="options-overlay" data-options-overlay<?= !empty($optionsOpenPanel) ? '' : ' hidden' ?> 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="options-panel" data-options-panel="score" hidden>
<h2>Score anpassen</h2>
<form method="post" action="/options" class="stack-form stack-form--spacious"> <form method="post" action="/options" class="stack-form stack-form--spacious">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="form_name" value="settings"> <input type="hidden" name="form_name" value="settings">
<div class="settings-section"> <div class="settings-section">
<h4>Multiplikatoren</h4> <h4>Multiplikatoren</h4>
<div class="field-grid field-grid--four"> <div class="field-grid field-grid--four">
@@ -22,408 +80,146 @@
<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> <label><span>Schlafgefühl</span><input type="number" name="settings[scoring][sleep_feeling_multiplier]" value="<?= e((string) $settings['scoring']['sleep_feeling_multiplier']) ?>" min="0" max="10"></label>
</div> </div>
</div> </div>
<div class="settings-section"> <div class="settings-section">
<div class="section-head section-head--compact">
<div>
<h4>Tracking-Felder</h4> <h4>Tracking-Felder</h4>
<p class="helper-text">Hier steuerst du, welche Zusatzfelder auf deiner Tracking-Seite sichtbar und in die Bewertung einbezogen werden.</p>
</div>
</div>
<div class="field-grid field-grid--two"> <div class="field-grid field-grid--two">
<label class="checkbox-row checkbox-row--panel"> <label class="checkbox-row checkbox-row--panel">
<input type="checkbox" name="settings[tracking][pain_enabled]" value="1" <?= !empty($settings['tracking']['pain_enabled']) ? 'checked' : '' ?>> <input type="checkbox" name="settings[tracking][pain_enabled]" value="1" <?= !empty($settings['tracking']['pain_enabled']) ? 'checked' : '' ?>>
<span> <span><strong>Schmerzen aktivieren</strong><small>Schmerzen werden weiter in den Score einbezogen.</small></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> </label>
<label><span>Schmerzfaktor</span><input type="number" name="settings[scoring][pain_multiplier]" value="<?= e((string) $settings['scoring']['pain_multiplier']) ?>" min="0" max="10"></label>
</div> </div>
</div> </div>
<div class="settings-section"> <div class="settings-section">
<h4>Schlafdauerpunkte</h4> <h4>Schlafdauerpunkte</h4>
<div class="field-grid field-grid--four"> <div class="field-grid field-grid--four">
<?php foreach ($settings['scoring']['sleep_duration_points'] as $key => $value): ?> <?php foreach ($settings['scoring']['sleep_duration_points'] as $key => $value): ?>
<label> <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>
<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; ?> <?php endforeach; ?>
</div> </div>
</div> </div>
<button class="primary-button" type="submit">Score speichern</button>
<div class="settings-section"> </form>
<h4>Sport-Bänder</h4>
<div class="band-grid">
<?php foreach ($settings['scoring']['sport_bands'] as $index => $band): ?>
<div class="band-card">
<label><span>Min</span><input type="number" name="settings[scoring][sport_bands][<?= e((string) $index) ?>][min]" value="<?= e((string) $band['min']) ?>"></label>
<label><span>Max</span><input type="number" name="settings[scoring][sport_bands][<?= e((string) $index) ?>][max]" value="<?= e((string) $band['max']) ?>"></label>
<label><span>Punkte</span><input type="number" name="settings[scoring][sport_bands][<?= e((string) $index) ?>][points]" value="<?= e((string) $band['points']) ?>"></label>
</div>
<?php endforeach; ?>
</div>
</div>
<div class="settings-section">
<div class="section-head section-head--compact">
<div>
<h4>Spaziergang</h4>
<p class="helper-text">Du kannst Spaziergänge pro Account entweder über Zeit oder über Schritte bewerten lassen.</p>
</div>
</div>
<label>
<span>Spaziergang auswerten nach</span>
<select name="settings[walk][mode]">
<?php foreach ($walkModeOptions as $modeValue => $modeLabel): ?>
<option value="<?= e($modeValue) ?>" <?= ($settings['walk']['mode'] ?? 'time') === $modeValue ? 'selected' : '' ?>><?= e($modeLabel) ?></option>
<?php endforeach; ?>
</select>
</label>
<?php if (($settings['walk']['mode'] ?? 'time') === 'steps'): ?>
<div class="band-card">
<h5>Schritte mit Bestwert bei 10.000</h5>
<p class="helper-text">Bei Schritten liegt der beste Wert bei 10.000. Darunter steigt die Punktzahl schrittweise an, darüber fällt sie wieder sanft ab.</p>
<p class="helper-text">Aktueller Verlauf: 0 / 3.000 / 5.000 / 7.500 / 10.000 / 12.500 / 15.000 / 20.000 Schritte</p>
</div>
<?php else: ?>
<div class="band-grid">
<?php foreach ($settings['scoring']['walk_bands'] as $index => $band): ?>
<div class="band-card">
<label><span>Min</span><input type="number" name="settings[scoring][walk_bands][<?= e((string) $index) ?>][min]" value="<?= e((string) $band['min']) ?>"></label>
<label><span>Max</span><input type="number" name="settings[scoring][walk_bands][<?= e((string) $index) ?>][max]" value="<?= e((string) $band['max']) ?>"></label>
<label><span>Punkte</span><input type="number" name="settings[scoring][walk_bands][<?= e((string) $index) ?>][points]" value="<?= e((string) $band['points']) ?>"></label>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div> </div>
<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="settings-section">
<div class="section-head section-head--compact"> <div class="section-head section-head--compact">
<div> <div>
<h4>Sportarten und Bonuspunkte</h4> <h4>Sportarten und Bonuspunkte</h4>
<p class="helper-text">Lege fest, welche Sportarten nur in deinem eigenen Tracking auswählbar sind. Entfernte Sportarten kannst du darunter jederzeit wieder für deinen Account hinzufügen.</p> <p class="helper-text">Diese Sportarten stehen in deinen Momenten zur Auswahl.</p>
</div> </div>
<button class="ghost-button" type="button" data-add-sport-type>Sportart hinzufügen</button> <button class="ghost-button" type="button" data-add-sport-type>Sportart hinzufügen</button>
</div> </div>
<input type="hidden" name="settings[sport_types_present]" value="1"> <input type="hidden" name="settings[sport_types_present]" value="1">
<?php if (!empty($sportTypePresets)): ?> <?php if (!empty($sportTypePresets)): ?>
<div class="preset-list"> <div class="preset-list">
<?php foreach ($sportTypePresets as $preset): ?> <?php foreach ($sportTypePresets as $preset): ?>
<button <button class="preset-pill" type="button" data-sport-preset data-id="<?= e($preset['id']) ?>" data-label="<?= e($preset['label']) ?>" data-icon="<?= e($preset['icon']) ?>" data-location="<?= e($preset['location'] ?? '') ?>" data-recovery-group="<?= e($preset['recovery_group']) ?>" data-bonus-points="<?= e((string) $preset['bonus_points']) ?>" data-allow-consecutive="<?= !empty($preset['allow_consecutive']) ? '1' : '0' ?>">
class="preset-pill" <img src="<?= e(sport_icon_path($preset['icon'])) ?>" alt=""><span><?= e($preset['label']) ?></span>
type="button"
data-sport-preset
data-id="<?= e($preset['id']) ?>"
data-label="<?= e($preset['label']) ?>"
data-icon="<?= e($preset['icon']) ?>"
data-location="<?= e($preset['location'] ?? '') ?>"
data-recovery-group="<?= e($preset['recovery_group']) ?>"
data-bonus-points="<?= e((string) $preset['bonus_points']) ?>"
data-allow-consecutive="<?= !empty($preset['allow_consecutive']) ? '1' : '0' ?>"
>
<img src="<?= e(sport_icon_path($preset['icon'])) ?>" alt="">
<span><?= e($preset['label']) ?></span>
</button> </button>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
<div class="sport-type-list" data-sport-type-list> <div class="sport-type-list" data-sport-type-list>
<?php foreach ($settings['sport_types'] as $index => $sportType): ?> <?php foreach ($settings['sport_types'] as $index => $sportType): ?>
<div class="sport-type-card band-card" data-sport-type-row> <div class="sport-type-card band-card" data-sport-type-row>
<input type="hidden" name="settings[sport_types][<?= e((string) $index) ?>][id]" value="<?= e($sportType['id']) ?>" data-name-template="settings[sport_types][__INDEX__][id]"> <input type="hidden" name="settings[sport_types][<?= e((string) $index) ?>][id]" value="<?= e($sportType['id']) ?>" data-name-template="settings[sport_types][__INDEX__][id]">
<div class="field-grid field-grid--four"> <div class="field-grid field-grid--four">
<label> <label><span>Bezeichnung</span><input type="text" name="settings[sport_types][<?= e((string) $index) ?>][label]" value="<?= e($sportType['label']) ?>" data-name-template="settings[sport_types][__INDEX__][label]"></label>
<span>Bezeichnung</span> <label><span>Icon</span><select name="settings[sport_types][<?= e((string) $index) ?>][icon]" data-name-template="settings[sport_types][__INDEX__][icon]"><?php foreach (sport_icon_options() as $iconValue => $iconLabel): ?><option value="<?= e($iconValue) ?>" <?= $sportType['icon'] === $iconValue ? 'selected' : '' ?>><?= e($iconLabel) ?></option><?php endforeach; ?></select></label>
<input type="text" name="settings[sport_types][<?= e((string) $index) ?>][label]" value="<?= e($sportType['label']) ?>" data-name-template="settings[sport_types][__INDEX__][label]"> <label><span>Ort</span><select name="settings[sport_types][<?= e((string) $index) ?>][location]" data-name-template="settings[sport_types][__INDEX__][location]"><?php foreach ($sportLocationOptions as $locationValue => $locationLabel): ?><option value="<?= e($locationValue) ?>" <?= ($sportType['location'] ?? '') === $locationValue ? 'selected' : '' ?>><?= e($locationLabel) ?></option><?php endforeach; ?></select></label>
</label> <label><span>Erholungsgruppe</span><input type="text" name="settings[sport_types][<?= e((string) $index) ?>][recovery_group]" value="<?= e($sportType['recovery_group']) ?>" data-name-template="settings[sport_types][__INDEX__][recovery_group]"></label>
<label>
<span>Icon</span>
<select name="settings[sport_types][<?= e((string) $index) ?>][icon]" data-name-template="settings[sport_types][__INDEX__][icon]">
<?php foreach (sport_icon_options() as $iconValue => $iconLabel): ?>
<option value="<?= e($iconValue) ?>" <?= $sportType['icon'] === $iconValue ? 'selected' : '' ?>><?= e($iconLabel) ?></option>
<?php endforeach; ?>
</select>
</label>
<label>
<span>Ort</span>
<select name="settings[sport_types][<?= e((string) $index) ?>][location]" data-name-template="settings[sport_types][__INDEX__][location]">
<?php foreach ($sportLocationOptions as $locationValue => $locationLabel): ?>
<option value="<?= e($locationValue) ?>" <?= ($sportType['location'] ?? '') === $locationValue ? 'selected' : '' ?>><?= e($locationLabel) ?></option>
<?php endforeach; ?>
</select>
</label>
<label>
<span>Erholungsgruppe optional</span>
<input type="text" name="settings[sport_types][<?= e((string) $index) ?>][recovery_group]" value="<?= e($sportType['recovery_group']) ?>" data-name-template="settings[sport_types][__INDEX__][recovery_group]">
</label>
</div>
<div class="field-grid field-grid--four">
<label>
<span>Bonuspunkte</span>
<input type="number" name="settings[sport_types][<?= e((string) $index) ?>][bonus_points]" value="<?= e((string) $sportType['bonus_points']) ?>" min="0" max="20" data-name-template="settings[sport_types][__INDEX__][bonus_points]">
</label>
</div>
<label class="checkbox-row">
<input type="checkbox" name="settings[sport_types][<?= e((string) $index) ?>][allow_consecutive]" value="1" <?= !empty($sportType['allow_consecutive']) ? 'checked' : '' ?> data-name-template="settings[sport_types][__INDEX__][allow_consecutive]">
<span>Darf an aufeinanderfolgenden Tagen Bonus geben</span>
</label>
<p class="helper-text">Die Erholungsgruppe brauchst du nur, wenn mehrere Varianten dieselbe Belastung teilen sollen, zum Beispiel Krafttraining Zuhause und Krafttraining Auswärts. Wenn du nichts Besonderes einträgst, reicht die Sportart selbst.</p>
<div class="sport-type-card__actions">
<span class="sport-pill sport-pill--soft">
<img src="<?= e(sport_icon_path($sportType['icon'])) ?>" alt="">
<span><?= e($sportType['label']) ?><?= !empty($sportType['location']) ? ' · ' . e(sport_location_label((string) $sportType['location'])) : '' ?></span>
</span>
<button class="ghost-button ghost-button--small" type="button" data-remove-sport-type>Entfernen</button>
</div> </div>
<div class="field-grid field-grid--four"><label><span>Bonuspunkte</span><input type="number" name="settings[sport_types][<?= e((string) $index) ?>][bonus_points]" value="<?= e((string) $sportType['bonus_points']) ?>" min="0" max="20" data-name-template="settings[sport_types][__INDEX__][bonus_points]"></label></div>
<label class="checkbox-row"><input type="checkbox" name="settings[sport_types][<?= e((string) $index) ?>][allow_consecutive]" value="1" <?= !empty($sportType['allow_consecutive']) ? 'checked' : '' ?> data-name-template="settings[sport_types][__INDEX__][allow_consecutive]"><span>Darf an Folgetagen Bonus geben</span></label>
<div class="sport-type-card__actions"><button class="ghost-button ghost-button--small" type="button" data-remove-sport-type>Entfernen</button></div>
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<div class="section-actions">
<button class="primary-button" type="submit">Sportarten speichern</button>
</div>
<template id="sport-type-row-template"> <template id="sport-type-row-template">
<div class="sport-type-card band-card" data-sport-type-row> <div class="sport-type-card band-card" data-sport-type-row>
<input type="hidden" value="" data-name-template="settings[sport_types][__INDEX__][id]"> <input type="hidden" value="" data-name-template="settings[sport_types][__INDEX__][id]">
<div class="field-grid field-grid--four"> <div class="field-grid field-grid--four">
<label> <label><span>Bezeichnung</span><input type="text" value="" data-name-template="settings[sport_types][__INDEX__][label]"></label>
<span>Bezeichnung</span> <label><span>Icon</span><select data-name-template="settings[sport_types][__INDEX__][icon]"><?php foreach (sport_icon_options() as $iconValue => $iconLabel): ?><option value="<?= e($iconValue) ?>"><?= e($iconLabel) ?></option><?php endforeach; ?></select></label>
<input type="text" value="" placeholder="z. B. Krafttraining Zuhause" data-name-template="settings[sport_types][__INDEX__][label]"> <label><span>Ort</span><select data-name-template="settings[sport_types][__INDEX__][location]"><?php foreach ($sportLocationOptions as $locationValue => $locationLabel): ?><option value="<?= e($locationValue) ?>"><?= e($locationLabel) ?></option><?php endforeach; ?></select></label>
</label> <label><span>Erholungsgruppe</span><input type="text" value="" data-name-template="settings[sport_types][__INDEX__][recovery_group]"></label>
<label>
<span>Icon</span>
<select data-name-template="settings[sport_types][__INDEX__][icon]">
<?php foreach (sport_icon_options() as $iconValue => $iconLabel): ?>
<option value="<?= e($iconValue) ?>"><?= e($iconLabel) ?></option>
<?php endforeach; ?>
</select>
</label>
<label>
<span>Ort</span>
<select data-name-template="settings[sport_types][__INDEX__][location]">
<?php foreach ($sportLocationOptions as $locationValue => $locationLabel): ?>
<option value="<?= e($locationValue) ?>"><?= e($locationLabel) ?></option>
<?php endforeach; ?>
</select>
</label>
<label>
<span>Erholungsgruppe optional</span>
<input type="text" value="" placeholder="z. B. krafttraining" data-name-template="settings[sport_types][__INDEX__][recovery_group]">
</label>
</div>
<div class="field-grid field-grid--four">
<label>
<span>Bonuspunkte</span>
<input type="number" value="2" min="0" max="20" data-name-template="settings[sport_types][__INDEX__][bonus_points]">
</label>
</div>
<label class="checkbox-row">
<input type="checkbox" value="1" data-name-template="settings[sport_types][__INDEX__][allow_consecutive]">
<span>Darf an aufeinanderfolgenden Tagen Bonus geben</span>
</label>
<p class="helper-text">Die Erholungsgruppe ist nur dann nützlich, wenn mehrere Varianten sportlich gleich belastend sind und denselben Bonusrhythmus teilen sollen.</p>
<div class="sport-type-card__actions">
<span class="sport-pill sport-pill--soft">
<img src="<?= e(sport_icon_path('run')) ?>" alt="">
<span>Neue Sportart</span>
</span>
<button class="ghost-button ghost-button--small" type="button" data-remove-sport-type>Entfernen</button>
</div> </div>
<div class="field-grid field-grid--four"><label><span>Bonuspunkte</span><input type="number" value="2" min="0" max="20" data-name-template="settings[sport_types][__INDEX__][bonus_points]"></label></div>
<label class="checkbox-row"><input type="checkbox" value="1" data-name-template="settings[sport_types][__INDEX__][allow_consecutive]"><span>Darf an Folgetagen Bonus geben</span></label>
<div class="sport-type-card__actions"><button class="ghost-button ghost-button--small" type="button" data-remove-sport-type>Entfernen</button></div>
</div> </div>
</template> </template>
</div> </div>
<button class="primary-button" type="submit">Sportarten speichern</button>
<div class="settings-section"> </form>
<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>
<span class="chart-chip"><?= e((string) $pushSubscriptionCount) ?> Gerät<?= $pushSubscriptionCount === 1 ? '' : 'e' ?></span>
</div> </div>
<div class="options-panel" data-options-panel="walk" hidden>
<h2>Spaziergang anpassen</h2>
<form method="post" action="/options" class="stack-form">
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="settings">
<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>
<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>
<button class="primary-button" type="submit">Spaziergang speichern</button>
</form>
</div>
<div class="options-panel" data-options-panel="reminders" hidden>
<h2>Erinnerungen setzen</h2>
<form method="post" action="/options" class="stack-form">
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="settings">
<div class="field-grid field-grid--two"> <div class="field-grid field-grid--two">
<label class="checkbox-row checkbox-row--panel"> <label class="checkbox-row checkbox-row--panel"><input type="checkbox" name="settings[notifications][enabled]" value="1" <?= !empty($settings['notifications']['enabled']) ? 'checked' : '' ?>><span>Tägliche Push-Erinnerung aktivieren</span></label>
<input type="checkbox" name="settings[notifications][enabled]" value="1" <?= !empty($settings['notifications']['enabled']) ? 'checked' : '' ?>> <label><span>Uhrzeit der Erinnerung</span><input type="time" name="settings[notifications][time]" value="<?= e((string) ($settings['notifications']['time'] ?? '20:30')) ?>"></label>
<span>Tägliche Push-Erinnerung aktivieren</span>
</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>
<div class="push-panel band-card" data-push-panel data-push-ready="<?= !empty($pushAvailable) && !empty($pushPublicKey) ? '1' : '0' ?>">
<div <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>
class="push-panel band-card" <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>
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>
<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> <button class="primary-button" type="submit">Erinnerungen speichern</button>
</div> </form>
</div> </div>
<div class="settings-section"> <div class="options-panel" data-options-panel="ratings" hidden>
<h4>Bewertungsskala</h4> <h2>Bewertungsskala ändern</h2>
<p class="helper-text">Diese Score-Grenzen und Schutzregeln gelten ebenfalls nur für deinen eigenen Account.</p> <form method="post" action="/options" class="stack-form stack-form--spacious">
<div class="band-grid"> <?= csrf_field() ?>
<?php foreach ($settings['ratings'] as $index => $rating): ?> <input type="hidden" name="form_name" value="settings">
<div class="band-card"> <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>
<label><span>Label</span><input type="text" name="settings[ratings][<?= e((string) $index) ?>][label]" value="<?= e($rating['label']) ?>"></label> <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>Min</span><input type="number" name="settings[ratings][<?= e((string) $index) ?>][min]" value="<?= e((string) $rating['min']) ?>"></label> <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>
<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> <button class="primary-button" type="submit">Bewertung speichern</button>
</form> </form>
</article> </div>
<aside class="stack-column"> <div class="options-panel" data-options-panel="stats" hidden>
<article class="glass-panel detail-card"> <h2>Statistik</h2>
<p class="eyebrow">Backup</p> <section class="stats-grid">
<h3>Eigene Einträge sichern</h3> <article class="metric-card glass-panel"><span>Getrackte Tage</span><strong><?= e((string) $statsSummary['tracked_days']) ?></strong></article>
<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> <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>Ø Stimmung</span><strong><?= e(format_points((float) $statsSummary['average_mood'])) ?>/10</strong></article>
<form method="post" action="/options" class="stack-form"> <article class="metric-card glass-panel"><span>Ø Stress</span><strong><?= e(format_points((float) $statsSummary['average_stress'])) ?>/10</strong></article>
<?= csrf_field() ?> <article class="metric-card glass-panel"><span>Serie</span><strong><?= e((string) $statsSummary['streak']) ?> Tage</strong></article>
<input type="hidden" name="form_name" value="export_backup"> </section>
<button class="primary-button" type="submit" <?= empty($backupAvailable) ? 'disabled' : '' ?>>Backup herunterladen</button> <section class="dashboard-grid dashboard-grid--embedded-stats">
<?php if (empty($backupAvailable)): ?> <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>
<p class="helper-text">Auf diesem Server fehlt gerade die ZIP-Erweiterung für den Download.</p> <article class="glass-panel chart-card"><div class="section-head"><div><p class="eyebrow">Trend</p><h3>Tagesstimmung</h3></div></div><div class="line-chart" data-chart-type="line" data-series="mood" data-payload="<?= e($statsChartPayload) ?>"></div></article>
<?php endif; ?> <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>
</form> <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>
<form method="post" action="/options" enctype="multipart/form-data" class="stack-form"> </div>
<?= 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'])): ?> <?php if (!empty($authUser['is_admin'])): ?>
<article class="glass-panel detail-card"> <div class="options-panel" data-options-panel="users" hidden>
<p class="eyebrow">KI</p> <h2>Neue Nutzer anlegen</h2>
<h3>OpenAI für Zusammenfassungen</h3>
<p class="helper-text">Diese Einstellung gilt zentral für alle Nutzer. Der API-Key bleibt in der Umgebung des Servers und wird hier bewusst nicht gespeichert.</p>
<?php if (!empty($aiStatus)): ?>
<div class="user-list">
<div class="user-row">
<strong>API-Key</strong>
<span><?= !empty($aiStatus['has_api_key']) ? 'vorhanden' : 'fehlt' ?></span>
</div>
<div class="user-row">
<strong>Aktuelles Modell</strong>
<span><?= e((string) ($aiStatus['model'] ?? '')) ?></span>
</div>
<div class="user-row">
<strong>Timeout</strong>
<span><?= e((string) ($aiStatus['timeout'] ?? '')) ?> s</span>
</div>
</div>
<?php endif; ?>
<form method="post" action="/options" class="stack-form">
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="ai_config">
<label><span>OpenAI-Modell</span><input type="text" name="ai[model]" value="<?= e((string) ($aiConfig['model'] ?? 'gpt-4o-mini')) ?>" required></label>
<label><span>Timeout in Sekunden</span><input type="number" name="ai[timeout]" value="<?= e((string) ($aiConfig['timeout'] ?? 25)) ?>" min="5" max="120" required></label>
<button class="primary-button" type="submit">KI-Konfiguration speichern</button>
</form>
</article>
<article class="glass-panel detail-card">
<p class="eyebrow">Mehrere Accounts</p>
<h3>Neuen Nutzer anlegen</h3>
<form method="post" action="/options" class="stack-form"> <form method="post" action="/options" class="stack-form">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="form_name" value="create_user"> <input type="hidden" name="form_name" value="create_user">
@@ -432,18 +228,30 @@
<label class="checkbox-row"><input type="checkbox" name="is_admin" value="1"><span>Als Admin anlegen</span></label> <label class="checkbox-row"><input type="checkbox" name="is_admin" value="1"><span>Als Admin anlegen</span></label>
<button class="primary-button" type="submit">Account erstellen</button> <button class="primary-button" type="submit">Account erstellen</button>
</form> </form>
<?php if ($users !== []): ?><div class="user-list"><?php foreach ($users as $account): ?><div class="user-row"><strong><?= e($account['username']) ?></strong><span><?= !empty($account['is_admin']) ? 'Admin' : 'Nutzer' ?></span></div><?php endforeach; ?></div><?php endif; ?>
</div>
<?php endif; ?>
<?php if ($users !== []): ?> <div class="options-panel" data-options-panel="security" hidden>
<div class="user-list"> <h2>Sicherheit</h2>
<?php foreach ($users as $account): ?> <article class="detail-card detail-card--overlay">
<div class="user-row"> <p class="eyebrow">Backup</p>
<strong><?= e($account['username']) ?></strong> <form method="post" action="/options" class="stack-form"><?= csrf_field() ?><input type="hidden" name="form_name" value="export_backup"><button class="primary-button" type="submit" <?= empty($backupAvailable) ? 'disabled' : '' ?>>Backup herunterladen</button></form>
<span><?= !empty($account['is_admin']) ? 'Admin' : 'Nutzer' ?></span> <form method="post" action="/options" enctype="multipart/form-data" class="stack-form"><?= csrf_field() ?><input type="hidden" name="form_name" value="import_backup"><label><span>Backup importieren</span><input type="file" name="backup_files[]" accept=".zip,.txt" multiple></label><button class="ghost-button" type="submit">Backup importieren</button></form>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</article> </article>
<article class="detail-card detail-card--overlay">
<p class="eyebrow">Passwort</p>
<form method="post" action="/options" class="stack-form"><?= csrf_field() ?><input type="hidden" name="form_name" value="password"><label><span>Aktuelles Passwort</span><input type="password" name="current_password" required></label><label><span>Neues Passwort</span><input type="password" name="new_password" minlength="10" required></label><label><span>Neues Passwort wiederholen</span><input type="password" name="new_password_confirm" minlength="10" required></label><button class="primary-button" type="submit">Passwort aktualisieren</button></form>
</article>
</div>
<?php if (!empty($authUser['is_admin'])): ?>
<div class="options-panel" data-options-panel="ai" hidden>
<h2>KI</h2>
<?php if (!empty($aiStatus)): ?><div class="user-list"><div class="user-row"><strong>API-Key</strong><span><?= !empty($aiStatus['has_api_key']) ? 'vorhanden' : 'fehlt' ?></span></div><div class="user-row"><strong>Aktuelles Modell</strong><span><?= e((string) ($aiStatus['model'] ?? '')) ?></span></div><div class="user-row"><strong>Timeout</strong><span><?= e((string) ($aiStatus['timeout'] ?? '')) ?> s</span></div></div><?php endif; ?>
<form method="post" action="/options" class="stack-form"><?= csrf_field() ?><input type="hidden" name="form_name" value="ai_config"><label><span>OpenAI-Modell</span><input type="text" name="ai[model]" value="<?= e((string) ($aiConfig['model'] ?? 'gpt-4o-mini')) ?>" required></label><label><span>Timeout in Sekunden</span><input type="number" name="ai[timeout]" value="<?= e((string) ($aiConfig['timeout'] ?? 25)) ?>" min="5" max="120" required></label><button class="primary-button" type="submit">KI-Konfiguration speichern</button></form>
</div>
<?php endif; ?> <?php endif; ?>
</aside> </section>
</div>
</section> </section>