Compare commits
24 Commits
V1.3.0
..
3a467aca38
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a467aca38 | |||
| f5daff1a04 | |||
| a087eb508b | |||
| 2047cae61c | |||
| 1dd5339a46 | |||
| 0df5983f65 | |||
| 7c9f464686 | |||
| abcd35714f | |||
| 0fb8adbb14 | |||
| 3b2c36c849 | |||
| adaff22651 | |||
| 36a15f3ed4 | |||
| 6a5852654b | |||
| 3e497a8047 | |||
| 59c7d89e81 | |||
| 176b07f202 | |||
| d8636f6c41 | |||
| a555f552c2 | |||
| e00cd66fbe | |||
| e36f27da4a | |||
| bc6e850afb | |||
| b8a96e96ef | |||
| 48df9831fd | |||
| 83b4686b6f |
@@ -2,6 +2,10 @@ Options -Indexes
|
||||
DirectoryIndex index.php
|
||||
AddType application/manifest+json .webmanifest
|
||||
|
||||
<IfModule mod_setenvif.c>
|
||||
SetEnvIf Authorization "(.+)" HTTP_AUTHORIZATION=$1
|
||||
</IfModule>
|
||||
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Projektueberblick
|
||||
|
||||
Mood ist ein dateibasierter Stimmungstracker fuer klassische PHP/LAMP-Deployments ohne Datenbank.
|
||||
Die App rendert serverseitig PHP-Templates und speichert Nutzer-, Einstellungs- und Trackingdaten unter `storage/`.
|
||||
|
||||
## Einstiegspunkte
|
||||
|
||||
- `index.php`: Front-Controller, bootet die App.
|
||||
- `src/bootstrap.php`: laedt Dateien, initialisiert Session und stellt `storage/` sicher.
|
||||
- `src/App.php`: zentrales Routing und Grossteil der Anwendungslogik.
|
||||
|
||||
## Wichtige Struktur
|
||||
|
||||
- `src/Domain/`: dateibasierte Repositories und Fachlogik.
|
||||
- `src/Support/`: Auth, View, Verschluesselung, OpenAI, Web Push.
|
||||
- `templates/layout.php`: globales Layout.
|
||||
- `templates/pages/`: serverseitige Seiten.
|
||||
- `assets/css/app.css`: gesamtes Styling.
|
||||
- `assets/js/app.js`: Frontend-Logik fuer Charts, Formulare, Archiv, Push und PWA.
|
||||
- `storage/system/`: globale Systemdaten wie Nutzer, Throttle, Notifications, Key-Dateien.
|
||||
- `storage/users/<user>/`: Nutzerdaten, Einstellungen, Tage, Zusammenfassungen und Push-Status.
|
||||
|
||||
## Routing
|
||||
|
||||
Die App nutzt keinen Router von aussen. Routen werden direkt in `App::run()` per `switch ($path)` behandelt.
|
||||
|
||||
Wichtige Routen:
|
||||
|
||||
- `/setup`
|
||||
- `/login`
|
||||
- `/logout`
|
||||
- `/`
|
||||
- `/track`
|
||||
- `/archive`
|
||||
- `/options`
|
||||
- `/push/subscribe`
|
||||
- `/push/unsubscribe`
|
||||
- `/push/test`
|
||||
- `/reminders/run`
|
||||
|
||||
## Datenmodell
|
||||
|
||||
- Nutzer stehen in `storage/system/users.json`.
|
||||
- Einstellungen pro Nutzer in `storage/users/<user>/settings.json`.
|
||||
- Tagesdaten in `storage/users/<user>/days/YYYY-MM-DD.txt`.
|
||||
- Wochen- und Monatszusammenfassungen unter `storage/users/<user>/summaries/`.
|
||||
- Push-Subscriptions und Reminder-State liegen ebenfalls pro Nutzer unter `storage/users/<user>/`.
|
||||
|
||||
Tagesdateien und Zusammenfassungen koennen serverseitig verschluesselt gespeichert werden. Die Logik liegt in `src/Support/EntryCrypto.php`.
|
||||
|
||||
## Sicherheitsrelevante Regeln
|
||||
|
||||
- Form-POSTs nutzen CSRF-Token via `csrf_field()` und `App::enforceCsrf()`.
|
||||
- JSON-POSTs nutzen `App::enforceRequestCsrf()`.
|
||||
- Auth-Logik liegt in `src/Support/Auth.php`.
|
||||
- Security-Header werden zentral in `App::sendSecurityHeaders()` gesetzt.
|
||||
- Aendere keine Auth-, Cookie-, CSRF- oder Reminder-Token-Logik leichtfertig.
|
||||
|
||||
## Arbeitsregeln fuer Aenderungen
|
||||
|
||||
- Bevorzuge kleine, lokale Aenderungen. Die App ist bewusst simpel und frameworkfrei.
|
||||
- Ziehe bestehende Hilfsfunktionen in `src/helpers.php` vor, statt neue Utility-Dateien einzufuehren.
|
||||
- Wenn moeglich dem bestehenden Muster folgen: Daten lesen/schreiben in Repositories, Seiten in `App`, Ausgabe in Templates.
|
||||
- Fuehre keine grossen Architekturumbauten ohne konkreten Bedarf ein. `src/App.php` ist zentral und gewollt monolithisch.
|
||||
- Beruehre `storage/` nur, wenn die Aufgabe das wirklich erfordert. Dort koennen echte Nutzerdaten liegen.
|
||||
- Fuehre keine Massenformatierung oder kosmetische Grossumbauten ohne Anlass durch.
|
||||
|
||||
## Frontend-Hinweise
|
||||
|
||||
- Das UI ist servergerendert; JavaScript erweitert nur interaktive Teile.
|
||||
- Neue UI-Logik moeglichst in `assets/js/app.js` integrieren, statt neue Build-Schritte einzufuehren.
|
||||
- Externe CDNs oder Frontend-Frameworks nicht einfuehren.
|
||||
|
||||
## KI- und Push-Integrationen
|
||||
|
||||
- OpenAI-Zusammenfassungen laufen ueber `src/Support/OpenAiSummaryService.php`.
|
||||
- Web Push und VAPID laufen ueber `src/Support/WebPushService.php` und `src/Domain/NotificationRepository.php`.
|
||||
- Bei Aenderungen in diesen Bereichen besonders auf Datenschutz, Fehlerbehandlung und Rueckwaertskompatibilitaet der gespeicherten Daten achten.
|
||||
|
||||
## Lokale Checks
|
||||
|
||||
Es gibt aktuell keine sichtbare Composer- oder PHPUnit-Konfiguration im Projekt.
|
||||
|
||||
Sinnvolle manuelle Checks:
|
||||
|
||||
- PHP-Syntax fuer geaenderte Dateien pruefen: `php -l <datei>`
|
||||
- Setup/Login/Tracken/Archiv/Optionen im Browser kurz durchklicken
|
||||
- Falls Push oder Reminder betroffen sind: relevante Endpunkte gezielt testen
|
||||
|
||||
## Deployment-Annahmen
|
||||
|
||||
- Ziel ist klassisches Apache/LAMP bzw. Cloudron.
|
||||
- `.htaccess` und Schreibrechte auf `storage/` sind wichtig.
|
||||
- Die App erwartet keinen Datenbankserver und keinen JS-Buildprozess.
|
||||
+2523
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="12" y="18" width="40" height="28" rx="10" stroke="#DFF7FF" stroke-width="4"/>
|
||||
<path d="M20 28H44" stroke="#90E3FF" stroke-width="4" stroke-linecap="round"/>
|
||||
<path d="M20 36H34" stroke="#DFF7FF" stroke-width="4" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 348 B |
@@ -0,0 +1,5 @@
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M21 20C15.4772 24.1806 12 30.8108 12 38C12 50.1503 21.8497 60 34 60C43.9254 60 52.3144 53.422 55 44" stroke="#DFF7FF" stroke-width="4" stroke-linecap="round"/>
|
||||
<path d="M37 14L31 24H39L33 34" stroke="#90E3FF" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="47" cy="19" r="3" fill="#8CFFD1"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 434 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M338.8-9.9c11.9 8.6 16.3 24.2 10.9 37.8L271.3 224H416c13.5 0 25.5 8.4 30.1 21.1s.7 26.9-9.6 35.5l-288 240c-11.3 9.4-27.4 9.9-39.3 1.3s-16.3-24.2-10.9-37.8L176.7 288H32c-13.5 0-25.5-8.4-30.1-21.1s-.7-26.9 9.6-35.5l288-240c11.3-9.4 27.4-9.9 39.3-1.3z"/></svg>
|
||||
|
After Width: | Height: | Size: 349 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M256 512a256 256 0 1 0 0-512 256 256 0 1 0 0 512zM165.4 321.9c20.4 28 53.4 46.1 90.6 46.1s70.2-18.1 90.6-46.1c7.8-10.7 22.8-13.1 33.5-5.3s13.1 22.8 5.3 33.5C356.3 390 309.2 416 256 416s-100.3-26-129.4-65.9c-7.8-10.7-5.4-25.7 5.3-33.5s25.7-5.4 33.5 5.3zM144 208a32 32 0 1 1 64 0 32 32 0 1 1-64 0zm192-32a32 32 0 1 1 0 64 32 32 0 1 1 0-64z"/></svg>
|
||||
|
After Width: | Height: | Size: 438 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M120 56c0-30.9 25.1-56 56-56h24c17.7 0 32 14.3 32 32v448c0 17.7-14.3 32-32 32h-32c-29.8 0-54.9-20.4-62-48-.7 0-1.3 0-2 0-44.2 0-80-35.8-80-80 0-18 6-34.6 16-48-19.4-14.6-32-37.8-32-64 0-30.9 17.6-57.8 43.2-71.1-7.1-12-11.2-26-11.2-40.9 0-44.2 35.8-80 80-80V56zm272 0v24c44.2 0 80 35.8 80 80 0 15-4.1 29-11.2 40.9 25.7 13.3 43.2 40.1 43.2 71.1 0 26.2-12.6 49.4-32 64 10 13.4 16 30 16 48 0 44.2-35.8 80-80 80-.7 0-1.3 0-2 0-7.1 27.6-32.2 48-62 48h-32c-17.7 0-32-14.3-32-32V32c0-17.7 14.3-32 32-32h24c30.9 0 56 25.1 56 56z"/></svg>
|
||||
|
After Width: | Height: | Size: 620 B |
+911
-7
@@ -39,6 +39,10 @@
|
||||
return `/assets/icons/mood-${sentiment}.svg`;
|
||||
}
|
||||
|
||||
function dashboardDayPath(date) {
|
||||
return `/?view=day&date=${encodeURIComponent(date)}`;
|
||||
}
|
||||
|
||||
function sportIconPath(icon) {
|
||||
return `/assets/icons/sport-${icon}.svg`;
|
||||
}
|
||||
@@ -421,7 +425,10 @@
|
||||
let minValue = Math.min(...values);
|
||||
let maxValue = Math.max(...values);
|
||||
|
||||
if (seriesName === "mood" || seriesName === "stress" || seriesName === "pain") {
|
||||
if (seriesName === "balance") {
|
||||
minValue = -2;
|
||||
maxValue = 2;
|
||||
} else if (seriesName === "mood" || seriesName === "stress" || seriesName === "pain") {
|
||||
minValue = Math.max(1, minValue - 1.5);
|
||||
maxValue = Math.min(10, maxValue + 1.5);
|
||||
} else {
|
||||
@@ -431,7 +438,10 @@
|
||||
|
||||
if ((maxValue - minValue) < 3) {
|
||||
const center = (maxValue + minValue) / 2;
|
||||
if (seriesName === "mood" || seriesName === "stress" || seriesName === "pain") {
|
||||
if (seriesName === "balance") {
|
||||
minValue = -2;
|
||||
maxValue = 2;
|
||||
} else if (seriesName === "mood" || seriesName === "stress" || seriesName === "pain") {
|
||||
minValue = Math.max(1, center - 1.5);
|
||||
maxValue = Math.min(10, center + 1.5);
|
||||
} else {
|
||||
@@ -697,7 +707,7 @@
|
||||
}
|
||||
|
||||
const title = item.entry
|
||||
? `${item.date}: ${formatNumber(Number(item.entry.score))} Punkte · ${item.entry.label}`
|
||||
? `${item.date}: ${formatNumber(Number(item.entry.score))}% Bilanz · ${item.entry.label}`
|
||||
: `${item.date}: kein Eintrag`;
|
||||
|
||||
if (!item.entry) {
|
||||
@@ -705,7 +715,7 @@
|
||||
}
|
||||
|
||||
return `
|
||||
<a class="calendar-link" data-date="${item.date}" href="/track?date=${encodeURIComponent(item.date)}" aria-label="${title}">
|
||||
<a class="calendar-link" data-date="${item.date}" href="${dashboardDayPath(item.date)}" aria-label="${title}">
|
||||
<rect class="calendar-cell calendar-cell--active ${activeDate === item.date ? "calendar-cell--selected" : ""}" x="${x}" y="${y}" width="${cellSize}" height="${cellSize}" rx="4" fill="${fill}"></rect>
|
||||
<title>${title}</title>
|
||||
</a>
|
||||
@@ -728,9 +738,9 @@
|
||||
</div>
|
||||
<div class="calendar-detail__score">
|
||||
<span data-calendar-score>${formatNumber(Number(latestVisibleEntry.score))}</span>
|
||||
<small>Punkte</small>
|
||||
<small>% Bilanz</small>
|
||||
</div>
|
||||
<a class="ghost-link calendar-detail__link" data-calendar-link href="/track?date=${encodeURIComponent(latestVisibleEntry.date)}">Tag öffnen</a>
|
||||
<a class="ghost-link calendar-detail__link" data-calendar-link href="${dashboardDayPath(latestVisibleEntry.date)}">Tag öffnen</a>
|
||||
`;
|
||||
|
||||
container.innerHTML = `
|
||||
@@ -773,7 +783,7 @@
|
||||
detailDate.textContent = formatDateLabel(entry.date);
|
||||
detailLabel.textContent = entry.label;
|
||||
detailScore.textContent = formatNumber(Number(entry.score));
|
||||
detailLink.href = `/track?date=${encodeURIComponent(entry.date)}`;
|
||||
detailLink.href = dashboardDayPath(entry.date);
|
||||
|
||||
container.querySelectorAll(".calendar-cell--selected").forEach(cell => {
|
||||
cell.classList.remove("calendar-cell--selected");
|
||||
@@ -971,6 +981,895 @@
|
||||
syncPresets();
|
||||
}
|
||||
|
||||
function initDashboardExperience() {
|
||||
const summaryOverlay = document.querySelector("[data-summary-overlay]");
|
||||
const openSummary = document.querySelector("[data-summary-overlay-open]");
|
||||
const closeSummary = [...document.querySelectorAll("[data-summary-overlay-close]")];
|
||||
const momentOverlay = document.querySelector("[data-moment-overlay]");
|
||||
const settingsMenuOverlay = document.querySelector("[data-settings-menu-overlay]");
|
||||
const openSettingsMenu = document.querySelector("[data-settings-menu-open]");
|
||||
const closeSettingsMenu = [...document.querySelectorAll("[data-settings-menu-close]")];
|
||||
const openMoment = document.querySelector("[data-moment-overlay-open]");
|
||||
const fabMenu = document.querySelector("[data-fab-menu]");
|
||||
const closeMoment = [...document.querySelectorAll("[data-moment-overlay-close]")];
|
||||
const chooseStep = document.querySelector('[data-moment-step="choose"]');
|
||||
const formStep = document.querySelector('[data-moment-step="form"]');
|
||||
const momentSubmit = document.querySelector("[data-moment-submit]");
|
||||
const typeInput = document.querySelector("[data-moment-type-input]");
|
||||
const formNameInput = document.querySelector("[data-moment-form-name]");
|
||||
const eventIdInput = document.querySelector("[data-moment-event-id]");
|
||||
const typeLabel = document.querySelector("[data-moment-type-label]");
|
||||
const valueField = document.querySelector("[data-moment-value-field]");
|
||||
const valueLabel = document.querySelector("[data-moment-value-label]");
|
||||
const valueInput = document.querySelector("[data-moment-value-input]");
|
||||
const walkField = document.querySelector("[data-moment-walk-field]");
|
||||
const walkModeInput = document.querySelector("[data-walk-mode-input]");
|
||||
const sportField = document.querySelector("[data-moment-sport-field]");
|
||||
const alcoholField = document.querySelector("[data-moment-alcohol-field]");
|
||||
const momentComment = document.querySelector("[data-moment-comment]");
|
||||
const backButton = document.querySelector("[data-moment-back]");
|
||||
const deleteForm = document.querySelector("[data-moment-delete-form]");
|
||||
const deleteIdInput = document.querySelector("[data-moment-delete-id]");
|
||||
const typeSelect = document.querySelector("[data-event-type-select]");
|
||||
const unitInput = document.querySelector("[data-event-unit]");
|
||||
const swipeContainer = document.querySelector("[data-day-swipe]");
|
||||
const dayStrip = document.querySelector("[data-day-strip]");
|
||||
const daySlider = document.querySelector("[data-day-slider]");
|
||||
const periodRail = document.querySelector(".range-period-rail");
|
||||
|
||||
const walkMode = document.body.dataset.walkMode || "time";
|
||||
const walkUnit = walkMode === "steps" ? "steps" : "min";
|
||||
|
||||
const hoistOverlay = overlay => {
|
||||
if (!(overlay instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (overlay.parentElement !== document.body) {
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
};
|
||||
|
||||
hoistOverlay(summaryOverlay);
|
||||
hoistOverlay(momentOverlay);
|
||||
hoistOverlay(settingsMenuOverlay);
|
||||
|
||||
if (periodRail instanceof HTMLElement) {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const view = params.get("view") || "day";
|
||||
const storageKey = `mood:${view}:period-scroll`;
|
||||
const storedScroll = window.sessionStorage.getItem(storageKey);
|
||||
|
||||
if (storedScroll !== null) {
|
||||
periodRail.scrollLeft = Number(storedScroll) || 0;
|
||||
}
|
||||
|
||||
periodRail.addEventListener("click", event => {
|
||||
const link = event.target.closest("a[href]");
|
||||
if (!link) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.sessionStorage.setItem(storageKey, String(periodRail.scrollLeft));
|
||||
});
|
||||
}
|
||||
|
||||
const stepperConfigs = {
|
||||
event: { label: "Moment", valueLabel: "Wert", unit: "", placeholder: "optional", showValue: false, showSport: false, showAlcohol: false, commentPlaceholder: "Was hast du erlebt?" },
|
||||
walk: { label: "Spaziergang", valueLabel: "Spaziergang", unit: walkUnit, placeholder: walkMode === "steps" ? "Schritte" : "Minuten", showValue: true, showSport: false, showAlcohol: false, showWalk: true, commentPlaceholder: "Was war dabei besonders?" },
|
||||
sport: { label: "Sport", valueLabel: "Dauer", unit: "min", placeholder: "Minuten", showValue: true, showSport: true, showAlcohol: false, commentPlaceholder: "Was hast du gemacht?" },
|
||||
sleep: { label: "Schlaf", valueLabel: "Dauer", unit: "h", placeholder: "Stunden", showValue: true, showSport: false, showAlcohol: false, commentPlaceholder: "Wie war der Schlaf?" },
|
||||
alcohol: { label: "Alkohol", valueLabel: "", unit: "", placeholder: "", showValue: false, showSport: false, showAlcohol: true, commentPlaceholder: "Optionaler Kommentar" },
|
||||
};
|
||||
|
||||
const toneClass = (value, metric = "mood") => {
|
||||
const current = Math.max(-2, Math.min(2, Number(value || 0)));
|
||||
if (metric === "stress") {
|
||||
if (current <= -2) return "tone-pos2";
|
||||
if (current === -1) return "tone-pos1";
|
||||
if (current === 1) return "tone-neg1";
|
||||
if (current >= 2) return "tone-neg2";
|
||||
return "tone-zero";
|
||||
}
|
||||
if (current <= -2) return "tone-neg2";
|
||||
if (current === -1) return "tone-neg1";
|
||||
if (current === 1) return "tone-pos1";
|
||||
if (current >= 2) return "tone-pos2";
|
||||
return "tone-zero";
|
||||
};
|
||||
|
||||
const signalLabels = {
|
||||
mood: { "-2": "sehr niedrig", "-1": "niedrig", 0: "neutral", 1: "hoch", 2: "sehr hoch" },
|
||||
energy: { "-2": "leer", "-1": "matt", 0: "okay", 1: "wach", 2: "kraftvoll" },
|
||||
stress: { "-2": "sehr ruhig", "-1": "ruhig", 0: "neutral", 1: "angespannt", 2: "sehr angespannt" },
|
||||
};
|
||||
|
||||
const setHidden = (element, hidden) => {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hidden) {
|
||||
element.setAttribute("hidden", "hidden");
|
||||
} else {
|
||||
element.removeAttribute("hidden");
|
||||
}
|
||||
};
|
||||
|
||||
const setOverlay = (overlay, open) => {
|
||||
if (!overlay) {
|
||||
return;
|
||||
}
|
||||
|
||||
setHidden(overlay, !open);
|
||||
document.body.classList.toggle("is-dashboard-overlay-open", open);
|
||||
|
||||
if (open) {
|
||||
const focusTarget = overlay.querySelector("button, [href]");
|
||||
if (focusTarget instanceof HTMLElement) {
|
||||
window.setTimeout(() => focusTarget.focus(), 10);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.querySelectorAll("[data-stepper]").forEach(stepper => {
|
||||
const input = stepper.querySelector("[data-stepper-input]");
|
||||
const value = stepper.querySelector("[data-stepper-value]");
|
||||
const minus = stepper.querySelector("[data-stepper-minus]");
|
||||
const plus = stepper.querySelector("[data-stepper-plus]");
|
||||
|
||||
if (!input || !value || !minus || !plus) {
|
||||
return;
|
||||
}
|
||||
|
||||
const render = () => {
|
||||
const current = Math.max(-2, Math.min(2, Number(input.value || 0)));
|
||||
const metric = stepper.dataset.stepperMetric || "mood";
|
||||
input.value = String(current);
|
||||
value.textContent = `${current > 0 ? "+" : ""}${current}`;
|
||||
const label = stepper.querySelector("[data-stepper-label]");
|
||||
if (label) {
|
||||
label.textContent = signalLabels[metric]?.[current] || signalLabels.mood[current] || "neutral";
|
||||
}
|
||||
minus.disabled = current <= -2;
|
||||
plus.disabled = current >= 2;
|
||||
const ring = stepper.querySelector(".overlay-signal-card__ring");
|
||||
if (ring) {
|
||||
ring.classList.remove("tone-neg2", "tone-neg1", "tone-zero", "tone-pos1", "tone-pos2");
|
||||
ring.classList.add(toneClass(current, metric));
|
||||
}
|
||||
};
|
||||
|
||||
minus.addEventListener("click", () => {
|
||||
input.value = String(Number(input.value || 0) - 1);
|
||||
render();
|
||||
});
|
||||
|
||||
plus.addEventListener("click", () => {
|
||||
input.value = String(Number(input.value || 0) + 1);
|
||||
render();
|
||||
});
|
||||
|
||||
render();
|
||||
});
|
||||
|
||||
if (openSummary) {
|
||||
openSummary.addEventListener("click", event => {
|
||||
event.preventDefault();
|
||||
setOverlay(summaryOverlay, true);
|
||||
});
|
||||
}
|
||||
|
||||
closeSummary.forEach(button => {
|
||||
button.addEventListener("click", event => {
|
||||
event.preventDefault();
|
||||
setOverlay(summaryOverlay, false);
|
||||
});
|
||||
});
|
||||
|
||||
if (openSettingsMenu) {
|
||||
openSettingsMenu.addEventListener("click", event => {
|
||||
event.preventDefault();
|
||||
setOverlay(settingsMenuOverlay, true);
|
||||
});
|
||||
}
|
||||
|
||||
closeSettingsMenu.forEach(button => {
|
||||
button.addEventListener("click", event => {
|
||||
event.preventDefault();
|
||||
setOverlay(settingsMenuOverlay, false);
|
||||
});
|
||||
});
|
||||
|
||||
const setMomentEditMode = payload => {
|
||||
if (!formNameInput || !eventIdInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload) {
|
||||
formNameInput.value = "update_event";
|
||||
eventIdInput.value = payload.id || "";
|
||||
if (deleteForm && deleteIdInput) {
|
||||
deleteIdInput.value = payload.id || "";
|
||||
setHidden(deleteForm, false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
formNameInput.value = "add_event";
|
||||
eventIdInput.value = "";
|
||||
if (deleteForm && deleteIdInput) {
|
||||
deleteIdInput.value = "";
|
||||
setHidden(deleteForm, true);
|
||||
}
|
||||
};
|
||||
|
||||
const showMomentChoose = () => {
|
||||
setMomentEditMode(null);
|
||||
setHidden(chooseStep, false);
|
||||
setHidden(formStep, true);
|
||||
document.querySelectorAll("[data-sport-choice]").forEach(item => item.classList.remove("is-selected"));
|
||||
if (momentSubmit) {
|
||||
momentSubmit.disabled = true;
|
||||
}
|
||||
};
|
||||
|
||||
const showMomentForm = type => {
|
||||
const config = stepperConfigs[type] || stepperConfigs.event;
|
||||
if (typeInput) typeInput.value = type;
|
||||
if (typeLabel) typeLabel.textContent = config.label;
|
||||
if (valueLabel) valueLabel.textContent = config.valueLabel || "Wert";
|
||||
if (valueInput) {
|
||||
valueInput.placeholder = config.placeholder;
|
||||
valueInput.required = !!config.showValue;
|
||||
valueInput.value = config.showValue ? valueInput.value : "";
|
||||
valueInput.step = type === "sleep" ? "0.01" : "1";
|
||||
}
|
||||
if (unitInput) {
|
||||
unitInput.value = config.unit;
|
||||
}
|
||||
if (walkModeInput) {
|
||||
walkModeInput.value = walkMode;
|
||||
}
|
||||
if (momentComment) {
|
||||
momentComment.placeholder = config.commentPlaceholder;
|
||||
momentComment.required = type !== "alcohol";
|
||||
}
|
||||
|
||||
if (sportField) setHidden(sportField, !config.showSport);
|
||||
if (alcoholField) setHidden(alcoholField, !config.showAlcohol);
|
||||
if (valueField) setHidden(valueField, !config.showValue);
|
||||
if (walkField) setHidden(walkField, !config.showWalk);
|
||||
if (momentSubmit) {
|
||||
momentSubmit.disabled = false;
|
||||
}
|
||||
|
||||
if (config.showWalk) {
|
||||
document.querySelectorAll('input[name="event_walk_mode"]').forEach(radio => {
|
||||
radio.checked = radio.value === walkMode;
|
||||
});
|
||||
}
|
||||
|
||||
setHidden(chooseStep, true);
|
||||
setHidden(formStep, false);
|
||||
};
|
||||
|
||||
const populateMomentForm = payload => {
|
||||
showMomentForm(payload.type || "event");
|
||||
setMomentEditMode(payload);
|
||||
|
||||
const form = document.querySelector("#moment-form");
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
|
||||
const setValue = (selector, value) => {
|
||||
const field = form.querySelector(selector);
|
||||
if (field) {
|
||||
field.value = value;
|
||||
}
|
||||
};
|
||||
|
||||
setValue('input[name="event_time"]', payload.time || "");
|
||||
setValue('[name="event_comment"]', payload.comment || "");
|
||||
setValue('[name="event_value"]', payload.value || "");
|
||||
setValue('[name="event_sport_type_id"]', payload.sport_type_id || "");
|
||||
setValue('[name="event_unit"]', payload.unit || "");
|
||||
setValue('[name="event_walk_mode"]', payload.unit === "steps" ? "steps" : "time");
|
||||
setValue('[name="event_mood"]', payload.mood ?? 0);
|
||||
setValue('[name="event_energy"]', payload.energy ?? 0);
|
||||
setValue('[name="event_stress"]', payload.stress ?? 0);
|
||||
|
||||
const consumedYes = form.querySelector('input[name="event_consumed"][value="1"]');
|
||||
const consumedNo = form.querySelector('input[name="event_consumed"][value="0"]');
|
||||
if (consumedYes && consumedNo) {
|
||||
if (payload.consumed) {
|
||||
consumedYes.checked = true;
|
||||
} else {
|
||||
consumedNo.checked = true;
|
||||
}
|
||||
}
|
||||
|
||||
form.querySelectorAll('input[name="event_walk_mode"]').forEach(radio => {
|
||||
radio.checked = radio.value === (payload.unit === "steps" ? "steps" : "time");
|
||||
});
|
||||
|
||||
form.querySelectorAll("[data-stepper]").forEach(stepper => {
|
||||
const input = stepper.querySelector("[data-stepper-input]");
|
||||
const value = stepper.querySelector("[data-stepper-value]");
|
||||
const minus = stepper.querySelector("[data-stepper-minus]");
|
||||
const plus = stepper.querySelector("[data-stepper-plus]");
|
||||
if (!input || !value || !minus || !plus) return;
|
||||
const current = Math.max(-2, Math.min(2, Number(input.value || 0)));
|
||||
const metric = stepper.dataset.stepperMetric || "mood";
|
||||
input.value = String(current);
|
||||
value.textContent = `${current > 0 ? "+" : ""}${current}`;
|
||||
minus.disabled = current <= -2;
|
||||
plus.disabled = current >= 2;
|
||||
const ring = stepper.querySelector(".overlay-signal-card__ring");
|
||||
if (ring) {
|
||||
ring.classList.remove("tone-neg2", "tone-neg1", "tone-zero", "tone-pos1", "tone-pos2");
|
||||
ring.classList.add(toneClass(current, metric));
|
||||
}
|
||||
});
|
||||
|
||||
form.querySelectorAll("[data-sport-choice]").forEach(button => {
|
||||
button.classList.toggle("is-selected", button.dataset.sportChoice === (payload.sport_type_id || ""));
|
||||
});
|
||||
};
|
||||
|
||||
if (openMoment) {
|
||||
openMoment.addEventListener("click", event => {
|
||||
event.preventDefault();
|
||||
if (fabMenu instanceof HTMLElement) {
|
||||
fabMenu.hidden = !fabMenu.hidden;
|
||||
openMoment.classList.toggle("is-open", !fabMenu.hidden);
|
||||
return;
|
||||
}
|
||||
|
||||
showMomentChoose();
|
||||
setOverlay(momentOverlay, true);
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll("[data-fab-moment-choice]").forEach(button => {
|
||||
button.addEventListener("click", event => {
|
||||
event.preventDefault();
|
||||
const type = button.dataset.fabMomentChoice || "event";
|
||||
if (fabMenu instanceof HTMLElement) {
|
||||
fabMenu.hidden = true;
|
||||
}
|
||||
if (openMoment) {
|
||||
openMoment.classList.remove("is-open");
|
||||
}
|
||||
showMomentForm(type);
|
||||
setOverlay(momentOverlay, true);
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener("click", event => {
|
||||
if (!(fabMenu instanceof HTMLElement) || fabMenu.hidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target.closest("[data-fab-menu]") || event.target.closest("[data-moment-overlay-open]")) {
|
||||
return;
|
||||
}
|
||||
|
||||
fabMenu.hidden = true;
|
||||
if (openMoment) {
|
||||
openMoment.classList.remove("is-open");
|
||||
}
|
||||
});
|
||||
|
||||
closeMoment.forEach(button => {
|
||||
button.addEventListener("click", event => {
|
||||
event.preventDefault();
|
||||
setOverlay(momentOverlay, false);
|
||||
showMomentChoose();
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-moment-type-choice]").forEach(button => {
|
||||
button.addEventListener("click", event => {
|
||||
event.preventDefault();
|
||||
showMomentForm(button.dataset.momentTypeChoice || "event");
|
||||
});
|
||||
});
|
||||
|
||||
if (backButton) {
|
||||
backButton.addEventListener("click", event => {
|
||||
event.preventDefault();
|
||||
showMomentChoose();
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll("[data-sport-choice]").forEach(button => {
|
||||
button.addEventListener("click", event => {
|
||||
event.preventDefault();
|
||||
const hidden = document.querySelector('input[name="event_sport_type_id"]');
|
||||
if (!hidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
hidden.value = button.dataset.sportChoice || "";
|
||||
document.querySelectorAll("[data-sport-choice]").forEach(item => item.classList.remove("is-selected"));
|
||||
button.classList.add("is-selected");
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('input[name="event_walk_mode"]').forEach(radio => {
|
||||
radio.addEventListener("change", () => {
|
||||
if (!valueInput || !valueLabel || !unitInput || !walkModeInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mode = radio.checked ? radio.value : walkModeInput.value;
|
||||
walkModeInput.value = mode;
|
||||
unitInput.value = mode === "steps" ? "steps" : "min";
|
||||
valueLabel.textContent = mode === "steps" ? "Schritte" : "Dauer";
|
||||
valueInput.placeholder = mode === "steps" ? "Schritte" : "Minuten";
|
||||
valueInput.step = "1";
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-confirm-delete]").forEach(button => {
|
||||
button.addEventListener("click", event => {
|
||||
if (!window.confirm("Diesen Moment wirklich löschen?")) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-event-editable]").forEach(card => {
|
||||
card.addEventListener("click", event => {
|
||||
if (event.target.closest("form") || event.target.closest("button")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = decodePayload(card.dataset.eventPayload || "");
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
populateMomentForm(payload);
|
||||
setOverlay(momentOverlay, true);
|
||||
});
|
||||
});
|
||||
|
||||
if (typeSelect && unitInput) {
|
||||
const syncUnit = () => {
|
||||
const option = typeSelect.options[typeSelect.selectedIndex];
|
||||
unitInput.value = option?.dataset.defaultUnit || "";
|
||||
};
|
||||
|
||||
syncUnit();
|
||||
typeSelect.addEventListener("change", syncUnit);
|
||||
}
|
||||
|
||||
if (swipeContainer && dayStrip && daySlider) {
|
||||
let pointerStartX = 0;
|
||||
let pointerStartY = 0;
|
||||
let dragging = false;
|
||||
let didSwipe = false;
|
||||
let activePointerId = null;
|
||||
let currentOffset = 0;
|
||||
let targetOffset = 0;
|
||||
let animationFrame = null;
|
||||
const prefetchedDays = new Set();
|
||||
|
||||
const setSlideProgress = offset => {
|
||||
const progress = Math.min(1, Math.abs(offset) / 120);
|
||||
daySlider.style.setProperty("--day-slider-offset", `${offset.toFixed(1)}px`);
|
||||
daySlider.style.setProperty("--day-slider-scale", (1 - (progress * 0.025)).toFixed(3));
|
||||
swipeContainer.style.setProperty("--day-prev-hint", offset > 0 ? progress.toFixed(3) : "0");
|
||||
swipeContainer.style.setProperty("--day-next-hint", offset < 0 ? progress.toFixed(3) : "0");
|
||||
};
|
||||
|
||||
const animateSlide = () => {
|
||||
currentOffset += (targetOffset - currentOffset) * 0.34;
|
||||
if (Math.abs(targetOffset - currentOffset) < 0.4) {
|
||||
currentOffset = targetOffset;
|
||||
}
|
||||
|
||||
setSlideProgress(currentOffset);
|
||||
|
||||
if (currentOffset !== targetOffset) {
|
||||
animationFrame = window.requestAnimationFrame(animateSlide);
|
||||
} else {
|
||||
animationFrame = null;
|
||||
}
|
||||
};
|
||||
|
||||
const setTargetOffset = offset => {
|
||||
targetOffset = offset;
|
||||
if (animationFrame === null) {
|
||||
animationFrame = window.requestAnimationFrame(animateSlide);
|
||||
}
|
||||
};
|
||||
|
||||
const preloadDay = date => {
|
||||
if (!date || prefetchedDays.has(date)) {
|
||||
return;
|
||||
}
|
||||
|
||||
prefetchedDays.add(date);
|
||||
window.fetch(dashboardDayPath(date), {
|
||||
credentials: "same-origin",
|
||||
cache: "force-cache",
|
||||
priority: "low"
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
preloadDay(swipeContainer.dataset.prevDate);
|
||||
preloadDay(swipeContainer.dataset.nextDate);
|
||||
|
||||
const resetStrip = () => {
|
||||
dayStrip.classList.remove("is-dragging");
|
||||
daySlider.classList.remove("is-dragging");
|
||||
setTargetOffset(0);
|
||||
};
|
||||
|
||||
const handleSwipe = (deltaX, deltaY) => {
|
||||
const absX = Math.abs(deltaX);
|
||||
const absY = Math.abs(deltaY);
|
||||
if (document.body.classList.contains("is-dashboard-overlay-open") || absX < 55 || absY > Math.max(120, absX * 1.15)) {
|
||||
resetStrip();
|
||||
return;
|
||||
}
|
||||
|
||||
if (deltaX < 0 && swipeContainer.dataset.nextDate) {
|
||||
didSwipe = true;
|
||||
setSlideProgress(-window.innerWidth);
|
||||
window.location.href = dashboardDayPath(swipeContainer.dataset.nextDate);
|
||||
} else if (deltaX > 0 && swipeContainer.dataset.prevDate) {
|
||||
didSwipe = true;
|
||||
setSlideProgress(window.innerWidth);
|
||||
window.location.href = dashboardDayPath(swipeContainer.dataset.prevDate);
|
||||
} else {
|
||||
resetStrip();
|
||||
}
|
||||
};
|
||||
|
||||
daySlider.addEventListener("pointerdown", event => {
|
||||
if (event.target.closest("input, textarea, select, label, [data-stepper], .dashboard-overlay")) {
|
||||
dragging = false;
|
||||
return;
|
||||
}
|
||||
|
||||
didSwipe = false;
|
||||
dragging = true;
|
||||
activePointerId = event.pointerId;
|
||||
pointerStartX = event.clientX;
|
||||
pointerStartY = event.clientY;
|
||||
dayStrip.classList.add("is-dragging");
|
||||
daySlider.classList.add("is-dragging");
|
||||
daySlider.setPointerCapture?.(event.pointerId);
|
||||
});
|
||||
|
||||
daySlider.addEventListener("pointermove", event => {
|
||||
if (!dragging || (activePointerId !== null && event.pointerId !== activePointerId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaX = event.clientX - pointerStartX;
|
||||
const deltaY = event.clientY - pointerStartY;
|
||||
if (Math.abs(deltaY) > Math.max(52, Math.abs(deltaX) * 1.35)) {
|
||||
dragging = false;
|
||||
activePointerId = null;
|
||||
resetStrip();
|
||||
return;
|
||||
}
|
||||
|
||||
const dampedOffset = Math.sign(deltaX) * Math.min(148, Math.pow(Math.abs(deltaX), 0.88) * 1.6);
|
||||
setTargetOffset(dampedOffset);
|
||||
|
||||
if (deltaX < -32) {
|
||||
preloadDay(swipeContainer.dataset.nextDate);
|
||||
} else if (deltaX > 32) {
|
||||
preloadDay(swipeContainer.dataset.prevDate);
|
||||
}
|
||||
});
|
||||
|
||||
daySlider.addEventListener("pointerup", event => {
|
||||
if (!dragging) {
|
||||
return;
|
||||
}
|
||||
|
||||
dragging = false;
|
||||
activePointerId = null;
|
||||
handleSwipe(event.clientX - pointerStartX, event.clientY - pointerStartY);
|
||||
});
|
||||
|
||||
daySlider.addEventListener("pointercancel", () => {
|
||||
dragging = false;
|
||||
activePointerId = null;
|
||||
resetStrip();
|
||||
});
|
||||
|
||||
daySlider.addEventListener("lostpointercapture", () => {
|
||||
if (!dragging) {
|
||||
return;
|
||||
}
|
||||
|
||||
dragging = false;
|
||||
activePointerId = null;
|
||||
resetStrip();
|
||||
});
|
||||
|
||||
daySlider.addEventListener("click", event => {
|
||||
if (!didSwipe) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
didSwipe = false;
|
||||
}, true);
|
||||
|
||||
window.addEventListener("keydown", event => {
|
||||
if (document.body.classList.contains("is-dashboard-overlay-open")) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowLeft" && swipeContainer.dataset.nextDate) {
|
||||
window.location.href = dashboardDayPath(swipeContainer.dataset.nextDate);
|
||||
}
|
||||
|
||||
if (event.key === "ArrowRight" && swipeContainer.dataset.prevDate) {
|
||||
window.location.href = dashboardDayPath(swipeContainer.dataset.prevDate);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function initOptionsPanels() {
|
||||
const overlay = document.querySelector("[data-options-overlay]");
|
||||
if (!overlay) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (overlay.parentElement !== document.body) {
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
const panels = [...overlay.querySelectorAll("[data-options-panel]")];
|
||||
const menu = overlay.querySelector("[data-options-menu]");
|
||||
const closeButtons = [...overlay.querySelectorAll("[data-options-close]")];
|
||||
const backButtons = [...overlay.querySelectorAll("[data-options-back]")];
|
||||
const isStandalone = overlay.dataset.optionsStandalone === "1";
|
||||
const initialPanel = overlay.dataset.openPanel || null;
|
||||
|
||||
const setOpen = (panelName) => {
|
||||
overlay.hidden = !isStandalone && panelName === null;
|
||||
document.body.classList.toggle("is-dashboard-overlay-open", isStandalone || panelName !== null);
|
||||
|
||||
if (menu instanceof HTMLElement) {
|
||||
menu.hidden = panelName !== null;
|
||||
}
|
||||
|
||||
backButtons.forEach(button => {
|
||||
button.hidden = panelName === null;
|
||||
});
|
||||
|
||||
panels.forEach(panel => {
|
||||
panel.hidden = panel.dataset.optionsPanel !== panelName;
|
||||
});
|
||||
|
||||
if (panelName === "stats") {
|
||||
window.setTimeout(() => {
|
||||
initDashboardCharts();
|
||||
}, 40);
|
||||
}
|
||||
};
|
||||
|
||||
document.querySelectorAll("[data-options-open]").forEach(button => {
|
||||
button.addEventListener("click", event => {
|
||||
event.preventDefault();
|
||||
setOpen(button.dataset.optionsOpen || null);
|
||||
});
|
||||
});
|
||||
|
||||
closeButtons.forEach(button => {
|
||||
button.addEventListener("click", event => {
|
||||
event.preventDefault();
|
||||
if (isStandalone) {
|
||||
window.location.href = "/";
|
||||
return;
|
||||
}
|
||||
setOpen(null);
|
||||
});
|
||||
});
|
||||
|
||||
backButtons.forEach(button => {
|
||||
button.addEventListener("click", event => {
|
||||
event.preventDefault();
|
||||
setOpen(null);
|
||||
if (window.location.search.includes("panel=")) {
|
||||
window.history.replaceState(null, "", "/options");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (initialPanel) {
|
||||
setOpen(initialPanel);
|
||||
} else if (isStandalone) {
|
||||
setOpen(null);
|
||||
}
|
||||
}
|
||||
|
||||
function initHealthImportStatus() {
|
||||
const panel = document.querySelector("[data-health-import-status]");
|
||||
if (!panel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const progressWrap = panel.querySelector("[data-health-progress-wrap]");
|
||||
const progress = panel.querySelector("[data-health-progress-bar]");
|
||||
const progressText = panel.querySelector("[data-health-progress-text]");
|
||||
const lastImport = panel.querySelector("[data-health-last-import]");
|
||||
const lastMessage = panel.querySelector("[data-health-last-message]");
|
||||
let timer = null;
|
||||
|
||||
const formatDuration = seconds => {
|
||||
const rounded = Math.max(0, Math.round(Number(seconds) || 0));
|
||||
if (rounded < 60) {
|
||||
return `${rounded} s`;
|
||||
}
|
||||
|
||||
return `${Math.ceil(rounded / 60)} min`;
|
||||
};
|
||||
|
||||
const render = status => {
|
||||
const done = Math.max(0, Number(status.progress_done || 0));
|
||||
const total = Math.max(0, Number(status.progress_total || 0));
|
||||
const percent = total > 0 ? Math.min(100, Math.round((done / total) * 100)) : 0;
|
||||
|
||||
if (progress) {
|
||||
progress.value = String(percent);
|
||||
progress.textContent = `${percent}%`;
|
||||
}
|
||||
|
||||
if (progressWrap) {
|
||||
progressWrap.dataset.progressDone = String(done);
|
||||
progressWrap.dataset.progressTotal = String(total);
|
||||
}
|
||||
|
||||
if (lastImport) {
|
||||
lastImport.textContent = status.last_import_at ? new Date(status.last_import_at).toLocaleString("de-DE") : "-";
|
||||
}
|
||||
|
||||
if (lastMessage) {
|
||||
lastMessage.textContent = status.last_message || "-";
|
||||
}
|
||||
|
||||
if (progressText) {
|
||||
if (status.last_status === "running") {
|
||||
let eta = "wird berechnet";
|
||||
const started = Date.parse(status.started_at || "");
|
||||
if (started && done > 0 && total > done) {
|
||||
const elapsed = (Date.now() - started) / 1000;
|
||||
eta = formatDuration((elapsed / done) * (total - done));
|
||||
}
|
||||
progressText.textContent = `Import läuft: ${done} von ${total} verarbeitet. Restzeit ca. ${eta}.`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (status.last_status === "error") {
|
||||
progressText.textContent = `${status.last_message || "Import fehlgeschlagen."} Derselbe Export kann erneut gesendet werden und wird idempotent übernommen.`;
|
||||
return;
|
||||
}
|
||||
|
||||
progressText.textContent = status.last_message || "Noch kein Import gelaufen.";
|
||||
}
|
||||
};
|
||||
|
||||
const initialDone = progressWrap ? Number(progressWrap.dataset.progressDone || 0) : 0;
|
||||
const initialTotal = progressWrap ? Number(progressWrap.dataset.progressTotal || 0) : 0;
|
||||
render({ progress_done: initialDone, progress_total: initialTotal });
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/health/status", { credentials: "same-origin" });
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
if (!data || !data.ok || !data.status) {
|
||||
return;
|
||||
}
|
||||
render(data.status);
|
||||
if (data.status.last_status !== "running" && timer !== null) {
|
||||
window.clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
} catch (error) {
|
||||
// Status polling is best-effort; the import itself is server-side.
|
||||
}
|
||||
};
|
||||
|
||||
refresh();
|
||||
timer = window.setInterval(refresh, 3500);
|
||||
}
|
||||
|
||||
function initMediaLightbox() {
|
||||
const lightbox = document.querySelector("[data-media-lightbox]");
|
||||
const content = document.querySelector("[data-media-lightbox-content]");
|
||||
if (!(lightbox instanceof HTMLElement) || !(content instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (lightbox.parentElement !== document.body) {
|
||||
document.body.appendChild(lightbox);
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
lightbox.setAttribute("hidden", "hidden");
|
||||
content.replaceChildren();
|
||||
document.body.classList.remove("is-dashboard-overlay-open");
|
||||
};
|
||||
|
||||
document.querySelectorAll("[data-media-lightbox-close]").forEach(button => {
|
||||
button.addEventListener("click", close);
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", event => {
|
||||
if (event.key === "Escape" && !lightbox.hasAttribute("hidden")) {
|
||||
close();
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-lightbox-kind]").forEach(trigger => {
|
||||
trigger.addEventListener("click", event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
content.replaceChildren();
|
||||
if (trigger.dataset.lightboxKind === "image") {
|
||||
const image = document.createElement("img");
|
||||
image.src = trigger.dataset.lightboxSrc || "";
|
||||
image.alt = "";
|
||||
content.appendChild(image);
|
||||
} else {
|
||||
const clone = trigger.cloneNode(true);
|
||||
clone.removeAttribute("data-lightbox-kind");
|
||||
clone.removeAttribute("aria-label");
|
||||
clone.classList.add("is-lightbox-clone");
|
||||
content.appendChild(clone);
|
||||
}
|
||||
|
||||
lightbox.removeAttribute("hidden");
|
||||
document.body.classList.add("is-dashboard-overlay-open");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function initSleepPhaseTooltips() {
|
||||
document.querySelectorAll(".sleep-phase-bar__segment[data-tooltip]").forEach(segment => {
|
||||
segment.addEventListener("click", event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
document.querySelectorAll(".sleep-phase-bar__segment.is-tooltip-visible").forEach(active => {
|
||||
if (active !== segment) {
|
||||
active.classList.remove("is-tooltip-visible");
|
||||
}
|
||||
});
|
||||
segment.classList.toggle("is-tooltip-visible");
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener("click", event => {
|
||||
if (event.target.closest(".sleep-phase-bar__segment[data-tooltip]")) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.querySelectorAll(".sleep-phase-bar__segment.is-tooltip-visible").forEach(segment => {
|
||||
segment.classList.remove("is-tooltip-visible");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function csrfToken() {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") || "";
|
||||
}
|
||||
@@ -1300,6 +2199,11 @@
|
||||
initTrackPreview();
|
||||
initArchiveMobileDetail();
|
||||
initDashboardCharts();
|
||||
initDashboardExperience();
|
||||
initOptionsPanels();
|
||||
initHealthImportStatus();
|
||||
initMediaLightbox();
|
||||
initSleepPhaseTooltips();
|
||||
initSportTypeManager();
|
||||
initPwaShell();
|
||||
initPullToRefresh();
|
||||
|
||||
+1975
-22
File diff suppressed because it is too large
Load Diff
@@ -96,6 +96,10 @@ final class EntryRepository
|
||||
|
||||
private function parse(string $content, string $fallbackDate): ?array
|
||||
{
|
||||
if (str_contains($content, '<!-- mood-tracker:v3 -->')) {
|
||||
return $this->parseV3($content, $fallbackDate);
|
||||
}
|
||||
|
||||
$sportTypes = [];
|
||||
$sportTypesRaw = (string) ($this->extract('/^- Sportarten:\s*(.*)$/mu', $content) ?? '');
|
||||
if ($sportTypesRaw !== '') {
|
||||
@@ -134,6 +138,19 @@ final class EntryRepository
|
||||
'walk_steps' => $walkMode === 'steps' ? $walkValue : 0,
|
||||
'alcohol' => in_array($alcoholRaw, ['ja', 'yes', 'true', '1'], true),
|
||||
'note' => $this->extractNote($content),
|
||||
'summary' => [
|
||||
'comment' => $this->extractNote($content),
|
||||
'mood' => legacy_to_signal_scale((int) ($this->extract('/^- Stimmung:\s*(.+)$/m', $content) ?? 5)),
|
||||
'energy' => legacy_to_signal_scale((int) ($this->extract('/^- Energie:\s*(.+)$/m', $content) ?? 5)),
|
||||
'stress' => legacy_to_signal_scale((int) ($this->extract('/^- Stress:\s*(.+)$/m', $content) ?? 5)),
|
||||
'alcohol' => in_array($alcoholRaw, ['ja', 'yes', 'true', '1'], true),
|
||||
],
|
||||
'summary_comment' => $this->extractNote($content),
|
||||
'summary_mood' => legacy_to_signal_scale((int) ($this->extract('/^- Stimmung:\s*(.+)$/m', $content) ?? 5)),
|
||||
'summary_energy' => legacy_to_signal_scale((int) ($this->extract('/^- Energie:\s*(.+)$/m', $content) ?? 5)),
|
||||
'summary_stress' => legacy_to_signal_scale((int) ($this->extract('/^- Stress:\s*(.+)$/m', $content) ?? 5)),
|
||||
'background_image' => '',
|
||||
'events' => [],
|
||||
];
|
||||
|
||||
return $entry;
|
||||
@@ -163,18 +180,73 @@ final class EntryRepository
|
||||
|
||||
private function toMarkdown(string $username, string $date, array $entry, array $evaluation): string
|
||||
{
|
||||
$summary = is_array($entry['summary'] ?? null) ? $entry['summary'] : [
|
||||
'comment' => (string) ($entry['summary_comment'] ?? $entry['note'] ?? ''),
|
||||
'mood' => normalize_signal_value($entry['summary_mood'] ?? legacy_to_signal_scale($entry['mood'] ?? 5)),
|
||||
'energy' => normalize_signal_value($entry['summary_energy'] ?? legacy_to_signal_scale($entry['energy'] ?? 5)),
|
||||
'stress' => normalize_signal_value($entry['summary_stress'] ?? legacy_to_signal_scale($entry['stress'] ?? 5)),
|
||||
];
|
||||
$events = is_array($entry['events'] ?? null) ? $entry['events'] : [];
|
||||
$health = is_array($entry['health'] ?? null) ? $entry['health'] : [];
|
||||
$sportTypes = $evaluation['sport_types'] ?? [];
|
||||
$sportTypeValues = array_map(
|
||||
static fn (array $type): string => (string) $type['label'] . ' [' . (string) $type['id'] . ']',
|
||||
array_filter($sportTypes, 'is_array')
|
||||
);
|
||||
|
||||
$eventLines = [];
|
||||
foreach ($events as $event) {
|
||||
if (!is_array($event)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$eventLines[] = '### ' . ((string) ($event['id'] ?? 'evt'));
|
||||
$eventLines[] = '- Typ: ' . day_event_type_label((string) ($event['type'] ?? 'event')) . ' [' . (string) ($event['type'] ?? 'event') . ']';
|
||||
$eventLines[] = '- Uhrzeit: ' . (string) ($event['time'] ?? '');
|
||||
$eventLines[] = '- Wert: ' . (string) ($event['value'] ?? 0);
|
||||
$eventLines[] = '- Einheit: ' . (string) ($event['unit'] ?? '');
|
||||
$eventLines[] = '- Sportart: ' . (string) ($event['sport_type_id'] ?? '');
|
||||
$eventLines[] = '- Bild: ' . (string) ($event['image'] ?? '');
|
||||
$eventLines[] = '- Getrunken: ' . (!empty($event['consumed']) ? 'ja' : 'nein');
|
||||
$eventLines[] = '- Kommentar: ' . (string) ($event['comment'] ?? '');
|
||||
$eventLines[] = '- Stimmung: ' . (string) ($event['mood'] ?? 0);
|
||||
$eventLines[] = '- Energie: ' . (string) ($event['energy'] ?? 0);
|
||||
$eventLines[] = '- Stress: ' . (string) ($event['stress'] ?? 0);
|
||||
$eventLines[] = '- Quelle: ' . (string) ($event['source'] ?? '');
|
||||
$eventLines[] = '- Import-ID: ' . (string) ($event['import_id'] ?? '');
|
||||
$eventLines[] = '- Dauer-Label: ' . (string) ($event['duration_label'] ?? '');
|
||||
$eventLines[] = '- Distanz-Label: ' . (string) ($event['distance_label'] ?? '');
|
||||
$eventLines[] = '- Energie-Label: ' . (string) ($event['energy_label'] ?? '');
|
||||
$eventLines[] = '- Puls-Label: ' . (string) ($event['heart_rate_label'] ?? '');
|
||||
$eventLines[] = '- Tiefschlaf: ' . (string) ($event['sleep_deep'] ?? 0);
|
||||
$eventLines[] = '- REM-Schlaf: ' . (string) ($event['sleep_rem'] ?? 0);
|
||||
$eventLines[] = '- Kernschlaf: ' . (string) ($event['sleep_core'] ?? 0);
|
||||
$route = is_array($event['route'] ?? null) ? $event['route'] : [];
|
||||
$eventLines[] = '- Route: ' . ($route !== [] ? base64_encode((string) json_encode($route, JSON_UNESCAPED_SLASHES)) : '');
|
||||
$eventLines[] = '';
|
||||
}
|
||||
|
||||
$lines = [
|
||||
'<!-- mood-tracker:v2 -->',
|
||||
'# Stimmungstracker',
|
||||
'<!-- mood-tracker:v3 -->',
|
||||
'# Stimmungstracker Tag',
|
||||
'Datum: ' . $date,
|
||||
'Benutzer: ' . normalize_username($username),
|
||||
'Hintergrundbild: ' . trim((string) ($entry['background_image'] ?? '')),
|
||||
'',
|
||||
'## Tagesbilanz',
|
||||
'- Kommentar: ' . trim((string) ($summary['comment'] ?? '')),
|
||||
'- Stimmung: ' . normalize_signal_value($summary['mood'] ?? 0),
|
||||
'- Energie: ' . normalize_signal_value($summary['energy'] ?? 0),
|
||||
'- Stress: ' . normalize_signal_value($summary['stress'] ?? 0),
|
||||
'- Alkohol: ' . (!empty($summary['alcohol']) ? 'ja' : 'nein'),
|
||||
'',
|
||||
'## Ereignisse',
|
||||
...($eventLines !== [] ? $eventLines : ['- Keine Ereignisse', '']),
|
||||
'## Gesundheitsdaten',
|
||||
'- Schritte: ' . (int) ($health['steps'] ?? 0),
|
||||
'- Schritte importiert am: ' . (string) ($health['steps_imported_at'] ?? ''),
|
||||
'',
|
||||
'## Tracking',
|
||||
'## Werte',
|
||||
'- Stimmung: ' . $entry['mood'],
|
||||
'- Energie: ' . $entry['energy'],
|
||||
@@ -202,14 +274,158 @@ final class EntryRepository
|
||||
'- Sport: ' . format_points((float) $evaluation['components']['sport_minutes']),
|
||||
'- Sportbonus: ' . format_points((float) ($evaluation['components']['sport_bonus'] ?? 0)),
|
||||
'- Spaziergang: ' . format_points((float) $evaluation['components']['walk_minutes']),
|
||||
'- Schrittebonus: ' . format_points((float) ($evaluation['components']['step_bonus'] ?? 0)),
|
||||
'- Momente: ' . format_points((float) ($evaluation['components']['events'] ?? 0)),
|
||||
'- Alkohol: ' . format_points((float) ($evaluation['components']['alcohol'] ?? 0)),
|
||||
'- Notiz: ' . format_points((float) $evaluation['components']['note']),
|
||||
'',
|
||||
'## Notiz',
|
||||
trim((string) $entry['note']),
|
||||
trim((string) ($summary['comment'] ?? $entry['note'] ?? '')),
|
||||
'',
|
||||
];
|
||||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
private function parseV3(string $content, string $fallbackDate): ?array
|
||||
{
|
||||
$date = $this->extract('/^Datum:\s*(\d{4}-\d{2}-\d{2})$/m', $content) ?? $fallbackDate;
|
||||
$backgroundImage = trim((string) ($this->extract('/^Hintergrundbild:[ \t]*([^\r\n]*)$/mu', $content) ?? ''));
|
||||
if (preg_match('/^[A-Za-z0-9._-]+\.(?:jpe?g|png|webp)$/i', $backgroundImage) !== 1) {
|
||||
$backgroundImage = '';
|
||||
}
|
||||
$summarySection = $this->extractSection($content, '## Tagesbilanz', '## Ereignisse');
|
||||
$eventsSection = $this->extractSection($content, '## Ereignisse', '## Gesundheitsdaten')
|
||||
?? $this->extractSection($content, '## Ereignisse', '## Tracking');
|
||||
$healthSection = $this->extractSection($content, '## Gesundheitsdaten', '## Tracking');
|
||||
$legacySection = $this->extractSection($content, '## Werte', '## Bewertung');
|
||||
$legacyContent = $legacySection !== null ? "## Werte\n" . $legacySection : $content;
|
||||
|
||||
$base = $this->parse(str_replace('<!-- mood-tracker:v3 -->', '<!-- mood-tracker:v2 -->', $legacyContent), $fallbackDate);
|
||||
if ($base === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$summary = [
|
||||
'comment' => (string) ($this->extract('/^- Kommentar:\s*(.*)$/mu', $summarySection ?? '') ?? ''),
|
||||
'mood' => normalize_signal_value($this->extract('/^- Stimmung:\s*(-?\d+)$/m', $summarySection ?? '') ?? 0),
|
||||
'energy' => normalize_signal_value($this->extract('/^- Energie:\s*(-?\d+)$/m', $summarySection ?? '') ?? 0),
|
||||
'stress' => normalize_signal_value($this->extract('/^- Stress:\s*(-?\d+)$/m', $summarySection ?? '') ?? 0),
|
||||
'alcohol' => in_array(strtolower((string) ($this->extract('/^- Alkohol:\s*(.*)$/mu', $summarySection ?? '') ?? 'nein')), ['ja', 'yes', 'true', '1'], true),
|
||||
];
|
||||
|
||||
$events = [];
|
||||
foreach (preg_split('/^###\s+/m', trim((string) $eventsSection)) ?: [] as $chunk) {
|
||||
$chunk = trim($chunk);
|
||||
if ($chunk === '' || str_starts_with($chunk, '- Keine Ereignisse')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$lines = preg_split('/\R/', $chunk) ?: [];
|
||||
$id = trim((string) array_shift($lines));
|
||||
$block = implode("\n", $lines);
|
||||
$typeLine = (string) ($this->extract('/^- Typ:\s*.*\[([^\]]+)\]$/mu', $block) ?? 'event');
|
||||
|
||||
$events[] = [
|
||||
'id' => $id,
|
||||
'type' => $typeLine,
|
||||
'time' => (string) ($this->extract('/^- Uhrzeit:\s*(.*)$/mu', $block) ?? ''),
|
||||
'value' => (float) ($this->extract('/^- Wert:\s*(.*)$/mu', $block) ?? 0),
|
||||
'unit' => (string) ($this->extract('/^- Einheit:\s*(.*)$/mu', $block) ?? ''),
|
||||
'sport_type_id' => (string) ($this->extract('/^- Sportart:\s*(.*)$/mu', $block) ?? ''),
|
||||
'image' => $this->normalizeImageFileName((string) ($this->extract('/^- Bild:\s*(.*)$/mu', $block) ?? '')),
|
||||
'consumed' => in_array(strtolower((string) ($this->extract('/^- Getrunken:\s*(.*)$/mu', $block) ?? 'ja')), ['ja', 'yes', 'true', '1'], true),
|
||||
'comment' => (string) ($this->extract('/^- Kommentar:\s*(.*)$/mu', $block) ?? ''),
|
||||
'mood' => normalize_signal_value($this->extract('/^- Stimmung:\s*(-?\d+)$/m', $block) ?? 0),
|
||||
'energy' => normalize_signal_value($this->extract('/^- Energie:\s*(-?\d+)$/m', $block) ?? 0),
|
||||
'stress' => normalize_signal_value($this->extract('/^- Stress:\s*(-?\d+)$/m', $block) ?? 0),
|
||||
'source' => (string) ($this->extract('/^- Quelle:\s*(.*)$/mu', $block) ?? ''),
|
||||
'import_id' => (string) ($this->extract('/^- Import-ID:\s*(.*)$/mu', $block) ?? ''),
|
||||
'duration_label' => (string) ($this->extract('/^- Dauer-Label:\s*(.*)$/mu', $block) ?? ''),
|
||||
'distance_label' => (string) ($this->extract('/^- Distanz-Label:\s*(.*)$/mu', $block) ?? ''),
|
||||
'energy_label' => (string) ($this->extract('/^- Energie-Label:\s*(.*)$/mu', $block) ?? ''),
|
||||
'heart_rate_label' => (string) ($this->extract('/^- Puls-Label:\s*(.*)$/mu', $block) ?? ''),
|
||||
'sleep_deep' => (float) ($this->extract('/^- Tiefschlaf:\s*(.*)$/mu', $block) ?? 0),
|
||||
'sleep_rem' => (float) ($this->extract('/^- REM-Schlaf:\s*(.*)$/mu', $block) ?? 0),
|
||||
'sleep_core' => (float) ($this->extract('/^- Kernschlaf:\s*(.*)$/mu', $block) ?? 0),
|
||||
'route' => $this->decodeRoute((string) ($this->extract('/^- Route:\s*(.*)$/mu', $block) ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
$health = [
|
||||
'steps' => max(0, (int) ($this->extract('/^- Schritte:\s*(\d+)$/m', $healthSection ?? '') ?? 0)),
|
||||
'steps_imported_at' => (string) ($this->extract('/^- Schritte importiert am:\s*(.*)$/mu', $healthSection ?? '') ?? ''),
|
||||
];
|
||||
|
||||
$base['date'] = $date;
|
||||
$base['background_image'] = $backgroundImage;
|
||||
$base['summary'] = $summary;
|
||||
$base['summary_comment'] = $summary['comment'];
|
||||
$base['summary_mood'] = $summary['mood'];
|
||||
$base['summary_energy'] = $summary['energy'];
|
||||
$base['summary_stress'] = $summary['stress'];
|
||||
$base['summary_alcohol'] = !empty($summary['alcohol']);
|
||||
$base['health'] = $health;
|
||||
$base['events'] = $events;
|
||||
$base['alcohol'] = !empty($summary['alcohol']);
|
||||
$base['note'] = $summary['comment'];
|
||||
|
||||
return $base;
|
||||
}
|
||||
|
||||
private function extractSection(string $content, string $startHeading, string $endHeading): ?string
|
||||
{
|
||||
$pattern = '/' . preg_quote($startHeading, '/') . '\s*(.*?)\s*' . preg_quote($endHeading, '/') . '/su';
|
||||
|
||||
if (preg_match($pattern, $content, $matches) !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trim((string) ($matches[1] ?? ''));
|
||||
}
|
||||
|
||||
private function normalizeImageFileName(string $fileName): string
|
||||
{
|
||||
$fileName = trim($fileName);
|
||||
|
||||
return preg_match('/^[A-Za-z0-9._-]+\.(?:jpe?g|png|webp)$/i', $fileName) === 1 ? $fileName : '';
|
||||
}
|
||||
|
||||
private function decodeRoute(string $encoded): array
|
||||
{
|
||||
$encoded = trim($encoded);
|
||||
if ($encoded === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$decoded = base64_decode($encoded, true);
|
||||
if (!is_string($decoded)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$route = json_decode($decoded, true);
|
||||
if (!is_array($route)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$points = [];
|
||||
foreach ($route as $point) {
|
||||
if (!is_array($point) || !is_numeric($point['lat'] ?? null) || !is_numeric($point['lon'] ?? null)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$lat = (float) $point['lat'];
|
||||
$lon = (float) $point['lon'];
|
||||
if ($lat < -90 || $lat > 90 || $lon < -180 || $lon > 180) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$points[] = [
|
||||
'lat' => round($lat, 6),
|
||||
'lon' => round($lon, 6),
|
||||
];
|
||||
}
|
||||
|
||||
return $points;
|
||||
}
|
||||
}
|
||||
|
||||
+330
-12
@@ -6,25 +6,49 @@ final class ScoringService
|
||||
{
|
||||
public function normalize(array $input): array
|
||||
{
|
||||
$sportTypes = normalize_sport_type_selection($input['sport_types'] ?? ($input['sport_type'] ?? []));
|
||||
$hasSummaryInput = is_array($input['summary'] ?? null)
|
||||
|| array_key_exists('summary_mood', $input)
|
||||
|| array_key_exists('summary_energy', $input)
|
||||
|| array_key_exists('summary_stress', $input);
|
||||
$hasEventInput = is_array($input['events'] ?? null) && $input['events'] !== [];
|
||||
$summary = $this->normalizeSummary($input['summary'] ?? [
|
||||
'comment' => $input['summary_comment'] ?? ($input['note'] ?? ''),
|
||||
'mood' => $input['summary_mood'] ?? legacy_to_signal_scale($input['mood'] ?? 5),
|
||||
'energy' => $input['summary_energy'] ?? legacy_to_signal_scale($input['energy'] ?? 5),
|
||||
'stress' => $input['summary_stress'] ?? legacy_to_signal_scale($input['stress'] ?? 5),
|
||||
'alcohol' => !empty($input['summary_alcohol'] ?? $input['alcohol'] ?? false),
|
||||
]);
|
||||
$events = $this->normalizeEvents($input['events'] ?? []);
|
||||
$derived = $this->deriveLegacyFieldsFromEvents($events, $summary, $input);
|
||||
$sportTypes = normalize_sport_type_selection($hasEventInput ? ($derived['sport_types'] ?? []) : ($input['sport_types'] ?? ($derived['sport_types'] ?? ($input['sport_type'] ?? []))));
|
||||
$health = $this->normalizeHealth($input['health'] ?? []);
|
||||
|
||||
return [
|
||||
'date' => $input['date'] ?? today(),
|
||||
'mood' => max(1, min(10, (int) ($input['mood'] ?? 5))),
|
||||
'energy' => max(1, min(10, (int) ($input['energy'] ?? 5))),
|
||||
'stress' => max(1, min(10, (int) ($input['stress'] ?? 5))),
|
||||
'mood' => max(1, min(10, (int) ($hasSummaryInput ? $derived['mood'] : ($input['mood'] ?? $derived['mood'])))),
|
||||
'energy' => max(1, min(10, (int) ($hasSummaryInput ? $derived['energy'] : ($input['energy'] ?? $derived['energy'])))),
|
||||
'stress' => max(1, min(10, (int) ($hasSummaryInput ? $derived['stress'] : ($input['stress'] ?? $derived['stress'])))),
|
||||
'pain' => max(1, min(10, (int) ($input['pain'] ?? 1))),
|
||||
'pain_enabled' => $this->normalizeBoolean($input['pain_enabled'] ?? false),
|
||||
'sleep_hours' => max(0, min(24, (float) ($input['sleep_hours'] ?? 0))),
|
||||
'sleep_feeling' => max(1, min(5, (int) ($input['sleep_feeling'] ?? 3))),
|
||||
'sport_minutes' => max(0, min(1440, (int) ($input['sport_minutes'] ?? 0))),
|
||||
'sleep_hours' => max(0, min(24, (float) ($hasEventInput ? $derived['sleep_hours'] : ($input['sleep_hours'] ?? $derived['sleep_hours'])))),
|
||||
'sleep_feeling' => max(1, min(5, (int) ($hasSummaryInput ? $derived['sleep_feeling'] : ($input['sleep_feeling'] ?? $derived['sleep_feeling'])))),
|
||||
'sport_minutes' => max(0, min(1440, (int) ($hasEventInput ? $derived['sport_minutes'] : ($input['sport_minutes'] ?? $derived['sport_minutes'])))),
|
||||
'sport_type' => $sportTypes[0] ?? '',
|
||||
'sport_types' => $sportTypes,
|
||||
'walk_mode' => $this->normalizeWalkMode((string) ($input['walk_mode'] ?? ((isset($input['walk_steps']) && !isset($input['walk_minutes'])) ? 'steps' : 'time'))),
|
||||
'walk_minutes' => max(0, min(1440, (int) ($input['walk_minutes'] ?? 0))),
|
||||
'walk_steps' => max(0, min(50000, (int) ($input['walk_steps'] ?? 0))),
|
||||
'alcohol' => $this->normalizeBoolean($input['alcohol'] ?? false),
|
||||
'note' => trim((string) ($input['note'] ?? '')),
|
||||
'walk_mode' => $this->normalizeWalkMode((string) ($input['walk_mode'] ?? $derived['walk_mode'] ?? ((isset($input['walk_steps']) && !isset($input['walk_minutes'])) ? 'steps' : 'time'))),
|
||||
'walk_minutes' => max(0, min(1440, (int) ($hasEventInput ? $derived['walk_minutes'] : ($input['walk_minutes'] ?? $derived['walk_minutes'])))),
|
||||
'walk_steps' => max(0, min(50000, (int) ($hasEventInput ? $derived['walk_steps'] : ($input['walk_steps'] ?? $derived['walk_steps'])))),
|
||||
'alcohol' => $this->normalizeBoolean($hasSummaryInput ? $derived['alcohol'] : ($input['alcohol'] ?? $derived['alcohol'])),
|
||||
'note' => trim((string) ($input['note'] ?? $summary['comment'])),
|
||||
'summary' => $summary,
|
||||
'summary_comment' => $summary['comment'],
|
||||
'summary_mood' => $summary['mood'],
|
||||
'summary_energy' => $summary['energy'],
|
||||
'summary_stress' => $summary['stress'],
|
||||
'summary_alcohol' => !empty($summary['alcohol']),
|
||||
'background_image' => trim((string) ($input['background_image'] ?? '')),
|
||||
'health' => $health,
|
||||
'events' => $events,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -36,6 +60,7 @@ final class ScoringService
|
||||
$ratings = $this->sortedRatings($settings['ratings'] ?? []);
|
||||
$sportTypes = find_sport_types($settings, $entry['sport_types']);
|
||||
$sportBonus = $this->sportBonusPoints($entry, $previousEntry, $settings, $sportTypes);
|
||||
$eventSignalPoints = $this->eventSignalPoints($entry['events']);
|
||||
$painEnabled = !empty($settings['tracking']['pain_enabled']);
|
||||
|
||||
$components = [
|
||||
@@ -47,6 +72,9 @@ final class ScoringService
|
||||
'sport_minutes' => $this->bandPoints((int) $entry['sport_minutes'], $scoring['sport_bands']),
|
||||
'sport_bonus' => $sportBonus,
|
||||
'walk_minutes' => $this->walkPoints($entry, $settings),
|
||||
'step_bonus' => $this->stepBonusPoints($entry, $scoring['step_bonus'] ?? []),
|
||||
'daily_steps' => $this->stepTargetPoints((int) ($entry['health']['steps'] ?? 0), $scoring['walk_step_targets'] ?? []),
|
||||
'events' => $eventSignalPoints,
|
||||
'alcohol' => !empty($entry['alcohol']) ? ((float) abs((float) ($scoring['alcohol_penalty'] ?? 5)) * -1) : 0.0,
|
||||
'note' => $entry['note'] === '' ? 0 : (float) $scoring['journal_points'],
|
||||
];
|
||||
@@ -66,6 +94,9 @@ final class ScoringService
|
||||
$this->maxBandPoints($scoring['sport_bands']) +
|
||||
$this->maxSportBonusPoints($settings) +
|
||||
$this->maxWalkPoints($entry, $settings) +
|
||||
$this->maxStepTargetPoints($scoring['walk_step_targets'] ?? []) +
|
||||
max(0.0, (float) ($scoring['step_bonus']['points'] ?? 0)) +
|
||||
($eventSignalPoints !== 0.0 ? 8.0 : 0.0) +
|
||||
(float) $scoring['journal_points'],
|
||||
1
|
||||
);
|
||||
@@ -95,11 +126,72 @@ final class ScoringService
|
||||
'guardrail' => $guardrail,
|
||||
'sentiment' => $this->sentimentForLabel($label, $ratings),
|
||||
'percentage' => $maxTotal > 0 ? round(($total / $maxTotal) * 100, 1) : 0,
|
||||
'balance' => $this->dayBalance($entry, $components, $settings),
|
||||
'sport_type' => $sportTypes[0] ?? null,
|
||||
'sport_types' => $sportTypes,
|
||||
];
|
||||
}
|
||||
|
||||
private function dayBalance(array $entry, array $components, array $settings): array
|
||||
{
|
||||
$config = is_array($settings['day_balance'] ?? null) ? $settings['day_balance'] : [];
|
||||
$moodWeight = max(0.0, (float) ($config['mood_weight'] ?? 3));
|
||||
$energyWeight = max(0.0, (float) ($config['energy_weight'] ?? 2));
|
||||
$stressWeight = max(0.0, (float) ($config['stress_weight'] ?? 2));
|
||||
$weightTotal = max(1.0, $moodWeight + $energyWeight + $stressWeight);
|
||||
|
||||
$summary = is_array($entry['summary'] ?? null) ? $entry['summary'] : [];
|
||||
$mood = normalize_signal_value($summary['mood'] ?? $entry['summary_mood'] ?? legacy_to_signal_scale($entry['mood'] ?? 5));
|
||||
$energy = normalize_signal_value($summary['energy'] ?? $entry['summary_energy'] ?? legacy_to_signal_scale($entry['energy'] ?? 5));
|
||||
$stress = normalize_signal_value($summary['stress'] ?? $entry['summary_stress'] ?? legacy_to_signal_scale($entry['stress'] ?? 5));
|
||||
|
||||
$base = (($mood * $moodWeight) + ($energy * $energyWeight) + ((-$stress) * $stressWeight)) / $weightTotal;
|
||||
$adjustmentPoints = 0.0;
|
||||
foreach ($components as $key => $value) {
|
||||
if (in_array($key, ['mood', 'energy', 'stress', 'pain'], true)) {
|
||||
continue;
|
||||
}
|
||||
$adjustmentPoints += (float) $value;
|
||||
}
|
||||
|
||||
$pointsPerStep = max(1.0, (float) ($config['points_per_step'] ?? 12));
|
||||
$cap = max(0.0, min(2.0, (float) ($config['adjustment_cap'] ?? 1.0)));
|
||||
$adjustment = max(-$cap, min($cap, $adjustmentPoints / $pointsPerStep));
|
||||
$raw = max(-2.0, min(2.0, $base + $adjustment));
|
||||
$level = max(-2, min(2, (int) round($raw)));
|
||||
|
||||
return [
|
||||
'base' => round($base, 2),
|
||||
'adjustment' => round($adjustment, 2),
|
||||
'raw' => round($raw, 2),
|
||||
'level' => $level,
|
||||
'percentage' => round((($raw + 2.0) / 4.0) * 100, 1),
|
||||
'tone' => signal_value_class($level),
|
||||
];
|
||||
}
|
||||
|
||||
private function eventSignalPoints(array $events): float
|
||||
{
|
||||
if ($events === []) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$scores = [];
|
||||
foreach ($events as $event) {
|
||||
if (!is_array($event)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$scores[] = signal_combo_score($event['mood'] ?? 0, $event['energy'] ?? 0, $event['stress'] ?? 0);
|
||||
}
|
||||
|
||||
if ($scores === []) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return round(max(-8.0, min(8.0, (array_sum($scores) / count($scores)) * 4.0)), 1);
|
||||
}
|
||||
|
||||
private function sleepDurationPoints(float $hours, array $points): float
|
||||
{
|
||||
if ($hours < 4) {
|
||||
@@ -170,6 +262,19 @@ final class ScoringService
|
||||
return $this->bandPoints((int) $entry['walk_minutes'], $scoring['walk_bands'] ?? []);
|
||||
}
|
||||
|
||||
private function stepBonusPoints(array $entry, array $config): float
|
||||
{
|
||||
$steps = (int) ($entry['health']['steps'] ?? 0);
|
||||
$min = max(0, (int) ($config['min'] ?? 10000));
|
||||
$max = max($min, (int) ($config['max'] ?? 15000));
|
||||
|
||||
if ($steps > $min && $steps <= $max) {
|
||||
return max(0.0, (float) ($config['points'] ?? 1));
|
||||
}
|
||||
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
private function maxWalkPoints(array $entry, array $settings): float
|
||||
{
|
||||
$scoring = $settings['scoring'] ?? [];
|
||||
@@ -186,6 +291,20 @@ final class ScoringService
|
||||
return $this->maxBandPoints($scoring['walk_bands'] ?? []);
|
||||
}
|
||||
|
||||
private function maxStepTargetPoints(array $targets): float
|
||||
{
|
||||
$max = 0.0;
|
||||
foreach ($targets as $target) {
|
||||
if (!is_array($target)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$max = max($max, (float) ($target['points'] ?? 0));
|
||||
}
|
||||
|
||||
return $max;
|
||||
}
|
||||
|
||||
private function stepTargetPoints(int $steps, array $targets): float
|
||||
{
|
||||
if ($targets === []) {
|
||||
@@ -304,6 +423,205 @@ final class ScoringService
|
||||
return $total;
|
||||
}
|
||||
|
||||
private function normalizeSummary(mixed $summary): array
|
||||
{
|
||||
$summary = is_array($summary) ? $summary : [];
|
||||
|
||||
return [
|
||||
'comment' => trim((string) ($summary['comment'] ?? '')),
|
||||
'mood' => normalize_signal_value($summary['mood'] ?? 0),
|
||||
'energy' => normalize_signal_value($summary['energy'] ?? 0),
|
||||
'stress' => normalize_signal_value($summary['stress'] ?? 0),
|
||||
'alcohol' => $this->normalizeBoolean($summary['alcohol'] ?? false),
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeEvents(mixed $events): array
|
||||
{
|
||||
if (!is_array($events)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$normalized = [];
|
||||
|
||||
foreach ($events as $event) {
|
||||
if (!is_array($event)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$type = trim((string) ($event['type'] ?? 'event'));
|
||||
if (!array_key_exists($type, day_event_type_options())) {
|
||||
$type = 'event';
|
||||
}
|
||||
|
||||
$time = trim((string) ($event['time'] ?? ''));
|
||||
if (preg_match('/^(?:[01]\d|2[0-3]):[0-5]\d$/', $time) !== 1) {
|
||||
$time = '';
|
||||
}
|
||||
|
||||
$unit = trim((string) ($event['unit'] ?? day_event_type_unit($type)));
|
||||
$value = is_numeric($event['value'] ?? null) ? (float) $event['value'] : 0.0;
|
||||
|
||||
$normalized[] = [
|
||||
'id' => trim((string) ($event['id'] ?? '')) ?: ('evt-' . substr(sha1(json_encode($event) . microtime(true)), 0, 12)),
|
||||
'type' => $type,
|
||||
'time' => $time,
|
||||
'comment' => trim(preg_replace('/\s+/u', ' ', (string) ($event['comment'] ?? '')) ?? ''),
|
||||
'value' => max(0, min(50000, $value)),
|
||||
'unit' => $unit,
|
||||
'sport_type_id' => trim((string) ($event['sport_type_id'] ?? '')),
|
||||
'image' => trim((string) ($event['image'] ?? '')),
|
||||
'consumed' => $this->normalizeBoolean($event['consumed'] ?? true),
|
||||
'mood' => normalize_signal_value($event['mood'] ?? 0),
|
||||
'energy' => normalize_signal_value($event['energy'] ?? 0),
|
||||
'stress' => normalize_signal_value($event['stress'] ?? 0),
|
||||
'source' => trim((string) ($event['source'] ?? '')),
|
||||
'import_id' => trim((string) ($event['import_id'] ?? '')),
|
||||
'duration_label' => trim((string) ($event['duration_label'] ?? '')),
|
||||
'distance_label' => trim((string) ($event['distance_label'] ?? '')),
|
||||
'energy_label' => trim((string) ($event['energy_label'] ?? '')),
|
||||
'heart_rate_label' => trim((string) ($event['heart_rate_label'] ?? '')),
|
||||
'sleep_deep' => max(0.0, min(24.0, is_numeric($event['sleep_deep'] ?? null) ? (float) $event['sleep_deep'] : 0.0)),
|
||||
'sleep_rem' => max(0.0, min(24.0, is_numeric($event['sleep_rem'] ?? null) ? (float) $event['sleep_rem'] : 0.0)),
|
||||
'sleep_core' => max(0.0, min(24.0, is_numeric($event['sleep_core'] ?? null) ? (float) $event['sleep_core'] : 0.0)),
|
||||
'route' => $this->normalizeRoute($event['route'] ?? []),
|
||||
];
|
||||
}
|
||||
|
||||
usort($normalized, static function (array $left, array $right): int {
|
||||
return strcmp((string) ($left['time'] ?? ''), (string) ($right['time'] ?? ''));
|
||||
});
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
private function normalizeHealth(mixed $health): array
|
||||
{
|
||||
if (!is_array($health)) {
|
||||
return [
|
||||
'steps' => 0,
|
||||
'steps_imported_at' => '',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'steps' => max(0, min(100000, (int) ($health['steps'] ?? 0))),
|
||||
'steps_imported_at' => trim((string) ($health['steps_imported_at'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeRoute(mixed $route): array
|
||||
{
|
||||
if (!is_array($route)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$points = [];
|
||||
foreach ($route as $point) {
|
||||
if (!is_array($point)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$lat = $point['lat'] ?? $point['latitude'] ?? null;
|
||||
$lon = $point['lon'] ?? $point['longitude'] ?? null;
|
||||
|
||||
if (!is_numeric($lat) || !is_numeric($lon)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$lat = (float) $lat;
|
||||
$lon = (float) $lon;
|
||||
|
||||
if ($lat < -90 || $lat > 90 || $lon < -180 || $lon > 180) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$points[] = [
|
||||
'lat' => round($lat, 6),
|
||||
'lon' => round($lon, 6),
|
||||
];
|
||||
}
|
||||
|
||||
if (count($points) <= 180) {
|
||||
return $points;
|
||||
}
|
||||
|
||||
$step = max(1, (int) floor(count($points) / 180));
|
||||
$reduced = [];
|
||||
foreach ($points as $index => $point) {
|
||||
if ($index % $step === 0) {
|
||||
$reduced[] = $point;
|
||||
}
|
||||
}
|
||||
|
||||
$last = $points[count($points) - 1];
|
||||
if ($reduced === [] || $reduced[count($reduced) - 1] !== $last) {
|
||||
$reduced[] = $last;
|
||||
}
|
||||
|
||||
return $reduced;
|
||||
}
|
||||
|
||||
private function deriveLegacyFieldsFromEvents(array $events, array $summary, array $input): array
|
||||
{
|
||||
$sportMinutes = 0;
|
||||
$walkMinutes = 0;
|
||||
$walkSteps = 0;
|
||||
$sleepHours = 0.0;
|
||||
$alcohol = false;
|
||||
$walkMode = $this->normalizeWalkMode((string) ($input['walk_mode'] ?? 'time'));
|
||||
$sportTypes = [];
|
||||
|
||||
foreach ($events as $event) {
|
||||
$type = (string) ($event['type'] ?? 'event');
|
||||
$unit = (string) ($event['unit'] ?? '');
|
||||
$value = (float) ($event['value'] ?? 0);
|
||||
|
||||
if ($type === 'sport') {
|
||||
$sportMinutes += (int) round($value);
|
||||
$sportTypeID = trim((string) ($event['sport_type_id'] ?? ''));
|
||||
if ($sportTypeID !== '') {
|
||||
$sportTypes[$sportTypeID] = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($type === 'walk') {
|
||||
if ($unit === 'steps') {
|
||||
$walkMode = 'steps';
|
||||
$walkSteps += (int) round($value);
|
||||
} else {
|
||||
$walkMinutes += (int) round($value);
|
||||
}
|
||||
}
|
||||
|
||||
if ($type === 'sleep') {
|
||||
$sleepHours += $unit === 'min' ? ($value / 60) : $value;
|
||||
}
|
||||
|
||||
if ($type === 'alcohol') {
|
||||
$alcohol = !empty($event['consumed']);
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($summary['alcohol'])) {
|
||||
$alcohol = true;
|
||||
}
|
||||
|
||||
return [
|
||||
'mood' => signal_to_legacy_scale($summary['mood']),
|
||||
'energy' => signal_to_legacy_scale($summary['energy']),
|
||||
'stress' => signal_to_legacy_scale($summary['stress']),
|
||||
'sleep_hours' => $sleepHours,
|
||||
'sleep_feeling' => max(1, min(5, 3 + $summary['energy'])),
|
||||
'sport_minutes' => $sportMinutes,
|
||||
'walk_mode' => $walkMode,
|
||||
'walk_minutes' => $walkMinutes,
|
||||
'walk_steps' => $walkSteps,
|
||||
'alcohol' => $alcohol,
|
||||
'sport_types' => array_keys($sportTypes),
|
||||
];
|
||||
}
|
||||
|
||||
private function sortedRatings(array $ratings): array
|
||||
{
|
||||
usort($ratings, static fn (array $a, array $b): int => ((int) $a['min']) <=> ((int) $b['min']));
|
||||
|
||||
@@ -38,7 +38,7 @@ final class UserRepository
|
||||
|
||||
public function verify(string $username, string $password): ?array
|
||||
{
|
||||
$user = $this->find($username);
|
||||
$user = $this->find($username) ?? [];
|
||||
|
||||
if ($user === null) {
|
||||
return null;
|
||||
@@ -51,6 +51,257 @@ final class UserRepository
|
||||
return $user;
|
||||
}
|
||||
|
||||
public function findByRememberToken(string $selector, string $validator): ?array
|
||||
{
|
||||
$validatorHash = hash('sha256', $validator);
|
||||
$now = time();
|
||||
|
||||
foreach ($this->all() as $user) {
|
||||
$token = $user['remember_token'] ?? null;
|
||||
|
||||
if (!is_array($token) || !hash_equals((string) ($token['selector'] ?? ''), $selector)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$expiresAt = strtotime((string) ($token['expires_at'] ?? '')) ?: 0;
|
||||
|
||||
if ($expiresAt < $now) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!hash_equals((string) ($token['validator_hash'] ?? ''), $validatorHash)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function storeRememberToken(string $username, string $selector, string $validatorHash, int $expiresAt): void
|
||||
{
|
||||
$normalized = normalize_username($username);
|
||||
$users = $this->all();
|
||||
$updated = false;
|
||||
|
||||
foreach ($users as &$user) {
|
||||
if (($user['username'] ?? '') !== $normalized) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$user['remember_token'] = [
|
||||
'selector' => $selector,
|
||||
'validator_hash' => $validatorHash,
|
||||
'expires_at' => date(DATE_ATOM, $expiresAt),
|
||||
'created_at' => date(DATE_ATOM),
|
||||
];
|
||||
$user['updated_at'] = date(DATE_ATOM);
|
||||
$updated = true;
|
||||
break;
|
||||
}
|
||||
unset($user);
|
||||
|
||||
if (!$updated) {
|
||||
throw new RuntimeException('Der Remember-Me-Token konnte keinem Benutzer zugeordnet werden.');
|
||||
}
|
||||
|
||||
$this->write(['users' => $users]);
|
||||
}
|
||||
|
||||
public function clearRememberToken(string $username): void
|
||||
{
|
||||
$normalized = normalize_username($username);
|
||||
$users = $this->all();
|
||||
$updated = false;
|
||||
|
||||
foreach ($users as &$user) {
|
||||
if (($user['username'] ?? '') !== $normalized || !array_key_exists('remember_token', $user)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
unset($user['remember_token']);
|
||||
$user['updated_at'] = date(DATE_ATOM);
|
||||
$updated = true;
|
||||
break;
|
||||
}
|
||||
unset($user);
|
||||
|
||||
if ($updated) {
|
||||
$this->write(['users' => $users]);
|
||||
}
|
||||
}
|
||||
|
||||
public function findByHealthImportToken(string $token): ?array
|
||||
{
|
||||
$tokenHash = hash('sha256', $token);
|
||||
|
||||
foreach ($this->all() as $user) {
|
||||
$config = $user['health_import'] ?? null;
|
||||
|
||||
if (!is_array($config) || empty($config['enabled'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (hash_equals((string) ($config['token_hash'] ?? ''), $tokenHash)) {
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function healthImportConfig(string $username): array
|
||||
{
|
||||
$user = $this->find($username);
|
||||
$config = is_array($user['health_import'] ?? null) ? $user['health_import'] : [];
|
||||
|
||||
return [
|
||||
'enabled' => !empty($config['enabled']),
|
||||
'token_prefix' => (string) ($config['token_prefix'] ?? ''),
|
||||
'created_at' => (string) ($config['created_at'] ?? ''),
|
||||
'last_import_at' => (string) ($config['last_import_at'] ?? ''),
|
||||
'last_status' => (string) ($config['last_status'] ?? ''),
|
||||
'last_message' => (string) ($config['last_message'] ?? ''),
|
||||
'progress_done' => max(0, (int) ($config['progress_done'] ?? 0)),
|
||||
'progress_total' => max(0, (int) ($config['progress_total'] ?? 0)),
|
||||
'started_at' => (string) ($config['started_at'] ?? ''),
|
||||
'updated_at' => (string) ($config['updated_at'] ?? ''),
|
||||
'finished_at' => (string) ($config['finished_at'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
public function issueHealthImportToken(string $username): string
|
||||
{
|
||||
$token = 'mood_health_' . bin2hex(random_bytes(24));
|
||||
$normalized = normalize_username($username);
|
||||
$users = $this->all();
|
||||
$updated = false;
|
||||
|
||||
foreach ($users as &$user) {
|
||||
if (($user['username'] ?? '') !== $normalized) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$currentConfig = is_array($user['health_import'] ?? null) ? $user['health_import'] : [];
|
||||
$user['health_import'] = [
|
||||
'enabled' => true,
|
||||
'token_hash' => hash('sha256', $token),
|
||||
'token_prefix' => substr($token, 0, 18),
|
||||
'created_at' => date(DATE_ATOM),
|
||||
'last_import_at' => (string) ($currentConfig['last_import_at'] ?? ''),
|
||||
'last_status' => (string) ($currentConfig['last_status'] ?? ''),
|
||||
'last_message' => (string) ($currentConfig['last_message'] ?? ''),
|
||||
'progress_done' => max(0, (int) ($currentConfig['progress_done'] ?? 0)),
|
||||
'progress_total' => max(0, (int) ($currentConfig['progress_total'] ?? 0)),
|
||||
'started_at' => (string) ($currentConfig['started_at'] ?? ''),
|
||||
'updated_at' => (string) ($currentConfig['updated_at'] ?? ''),
|
||||
'finished_at' => (string) ($currentConfig['finished_at'] ?? ''),
|
||||
];
|
||||
$user['updated_at'] = date(DATE_ATOM);
|
||||
$updated = true;
|
||||
break;
|
||||
}
|
||||
unset($user);
|
||||
|
||||
if (!$updated) {
|
||||
throw new RuntimeException('Der Health-Import-Token konnte keinem Benutzer zugeordnet werden.');
|
||||
}
|
||||
|
||||
$this->write(['users' => $users]);
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
public function revokeHealthImportToken(string $username): void
|
||||
{
|
||||
$normalized = normalize_username($username);
|
||||
$users = $this->all();
|
||||
$updated = false;
|
||||
|
||||
foreach ($users as &$user) {
|
||||
if (($user['username'] ?? '') !== $normalized || !array_key_exists('health_import', $user)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
unset($user['health_import']);
|
||||
$user['updated_at'] = date(DATE_ATOM);
|
||||
$updated = true;
|
||||
break;
|
||||
}
|
||||
unset($user);
|
||||
|
||||
if ($updated) {
|
||||
$this->write(['users' => $users]);
|
||||
}
|
||||
}
|
||||
|
||||
public function recordHealthImport(string $username, string $status, string $message): void
|
||||
{
|
||||
$normalized = normalize_username($username);
|
||||
$users = $this->all();
|
||||
$updated = false;
|
||||
|
||||
foreach ($users as &$user) {
|
||||
if (($user['username'] ?? '') !== $normalized) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$config = is_array($user['health_import'] ?? null) ? $user['health_import'] : [];
|
||||
$config['last_import_at'] = date(DATE_ATOM);
|
||||
$config['last_status'] = $status;
|
||||
$config['last_message'] = substr($message, 0, 240);
|
||||
$config['updated_at'] = date(DATE_ATOM);
|
||||
if ($status !== 'running') {
|
||||
$config['finished_at'] = date(DATE_ATOM);
|
||||
if ($status === 'ok') {
|
||||
$total = max(0, (int) ($config['progress_total'] ?? 0));
|
||||
$config['progress_done'] = $total > 0 ? $total : max(0, (int) ($config['progress_done'] ?? 0));
|
||||
}
|
||||
}
|
||||
$user['health_import'] = $config;
|
||||
$user['updated_at'] = date(DATE_ATOM);
|
||||
$updated = true;
|
||||
break;
|
||||
}
|
||||
unset($user);
|
||||
|
||||
if ($updated) {
|
||||
$this->write(['users' => $users]);
|
||||
}
|
||||
}
|
||||
|
||||
public function recordHealthImportProgress(string $username, string $message, int $done, int $total, ?string $startedAt = null): void
|
||||
{
|
||||
$normalized = normalize_username($username);
|
||||
$users = $this->all();
|
||||
$updated = false;
|
||||
|
||||
foreach ($users as &$user) {
|
||||
if (($user['username'] ?? '') !== $normalized) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$config = is_array($user['health_import'] ?? null) ? $user['health_import'] : [];
|
||||
$config['last_status'] = 'running';
|
||||
$config['last_message'] = substr($message, 0, 240);
|
||||
$config['progress_done'] = max(0, min($done, max($total, 0)));
|
||||
$config['progress_total'] = max(0, $total);
|
||||
$config['started_at'] = $startedAt ?? (string) ($config['started_at'] ?? date(DATE_ATOM));
|
||||
$config['updated_at'] = date(DATE_ATOM);
|
||||
$config['finished_at'] = '';
|
||||
$user['health_import'] = $config;
|
||||
$user['updated_at'] = date(DATE_ATOM);
|
||||
$updated = true;
|
||||
break;
|
||||
}
|
||||
unset($user);
|
||||
|
||||
if ($updated) {
|
||||
$this->write(['users' => $users]);
|
||||
}
|
||||
}
|
||||
|
||||
public function create(string $username, string $password, bool $isAdmin = false): array
|
||||
{
|
||||
$normalized = normalize_username($username);
|
||||
|
||||
+60
-1
@@ -11,7 +11,7 @@ final class Auth
|
||||
public function check(): bool
|
||||
{
|
||||
if (!isset($_SESSION['user']) || !is_array($_SESSION['user'])) {
|
||||
return false;
|
||||
return $this->attemptRememberedLogin();
|
||||
}
|
||||
|
||||
$username = $_SESSION['user']['username'] ?? null;
|
||||
@@ -62,17 +62,76 @@ final class Auth
|
||||
$_SESSION['remember_me'] = $remember;
|
||||
|
||||
if ($remember) {
|
||||
$this->issueRememberCookie($user['username']);
|
||||
setcookie(session_name(), session_id(), session_cookie_options_for(time() + remember_me_lifetime()));
|
||||
} else {
|
||||
$this->users->clearRememberToken($user['username']);
|
||||
$this->clearRememberCookie();
|
||||
setcookie(session_name(), session_id(), session_cookie_options_for());
|
||||
}
|
||||
}
|
||||
|
||||
public function logout(): void
|
||||
{
|
||||
$username = $_SESSION['user']['username'] ?? null;
|
||||
|
||||
if (is_string($username) && $username !== '') {
|
||||
$this->users->clearRememberToken($username);
|
||||
}
|
||||
|
||||
unset($_SESSION['user']);
|
||||
unset($_SESSION['remember_me']);
|
||||
$this->clearRememberCookie();
|
||||
setcookie(session_name(), '', session_cookie_options_for(time() - 3600));
|
||||
session_regenerate_id(true);
|
||||
}
|
||||
|
||||
private function attemptRememberedLogin(): bool
|
||||
{
|
||||
$cookie = $_COOKIE[remember_cookie_name()] ?? '';
|
||||
|
||||
if (!is_string($cookie) || $cookie === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$parts = explode(':', $cookie, 2);
|
||||
|
||||
if (count($parts) !== 2) {
|
||||
$this->clearRememberCookie();
|
||||
return false;
|
||||
}
|
||||
|
||||
[$selector, $validator] = $parts;
|
||||
|
||||
if (!ctype_xdigit($selector) || strlen($selector) !== 32 || !ctype_xdigit($validator) || strlen($validator) !== 64) {
|
||||
$this->clearRememberCookie();
|
||||
return false;
|
||||
}
|
||||
|
||||
$user = $this->users->findByRememberToken($selector, $validator);
|
||||
|
||||
if ($user === null) {
|
||||
$this->clearRememberCookie();
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->login($user, true);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function issueRememberCookie(string $username): void
|
||||
{
|
||||
$selector = bin2hex(random_bytes(16));
|
||||
$validator = bin2hex(random_bytes(32));
|
||||
$expiresAt = time() + remember_me_lifetime();
|
||||
|
||||
$this->users->storeRememberToken($username, $selector, hash('sha256', $validator), $expiresAt);
|
||||
setcookie(remember_cookie_name(), $selector . ':' . $validator, session_cookie_options_for($expiresAt));
|
||||
}
|
||||
|
||||
private function clearRememberCookie(): void
|
||||
{
|
||||
setcookie(remember_cookie_name(), '', session_cookie_options_for(time() - 3600));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,19 @@ final class Defaults
|
||||
'walk' => [
|
||||
'mode' => 'time',
|
||||
],
|
||||
'sleep' => [
|
||||
'optimal_hours' => 7.0,
|
||||
],
|
||||
'display' => [
|
||||
'score_mode' => 'scale',
|
||||
],
|
||||
'day_balance' => [
|
||||
'mood_weight' => 3,
|
||||
'energy_weight' => 2,
|
||||
'stress_weight' => 2,
|
||||
'adjustment_cap' => 1.0,
|
||||
'points_per_step' => 12,
|
||||
],
|
||||
'tracking' => [
|
||||
'pain_enabled' => false,
|
||||
],
|
||||
@@ -88,7 +101,7 @@ final class Defaults
|
||||
],
|
||||
[
|
||||
'id' => 'rowing',
|
||||
'label' => 'Rudern',
|
||||
'label' => 'Rudergerät',
|
||||
'icon' => 'row',
|
||||
'location' => '',
|
||||
'recovery_group' => 'rudern',
|
||||
@@ -152,6 +165,11 @@ final class Defaults
|
||||
['steps' => 15000, 'points' => 4],
|
||||
['steps' => 20000, 'points' => 0],
|
||||
],
|
||||
'step_bonus' => [
|
||||
'min' => 10000,
|
||||
'max' => 15000,
|
||||
'points' => 1,
|
||||
],
|
||||
'journal_points' => 2,
|
||||
'alcohol_penalty' => 5,
|
||||
],
|
||||
|
||||
+191
@@ -115,6 +115,23 @@ function format_points(float $value): string
|
||||
return number_format($rounded, 1, ',', '.');
|
||||
}
|
||||
|
||||
function format_duration_hours(float $hours): string
|
||||
{
|
||||
$minutes = max(0, (int) round($hours * 60));
|
||||
$wholeHours = intdiv($minutes, 60);
|
||||
$remainingMinutes = $minutes % 60;
|
||||
|
||||
if ($wholeHours <= 0) {
|
||||
return $remainingMinutes . ' min';
|
||||
}
|
||||
|
||||
if ($remainingMinutes === 0) {
|
||||
return $wholeHours . ' h';
|
||||
}
|
||||
|
||||
return $wholeHours . ' h ' . $remainingMinutes . ' min';
|
||||
}
|
||||
|
||||
function normalize_username(string $username): string
|
||||
{
|
||||
return strtolower(trim($username));
|
||||
@@ -335,6 +352,11 @@ function remember_me_lifetime(): int
|
||||
return 60 * 60 * 24 * 30;
|
||||
}
|
||||
|
||||
function remember_cookie_name(): string
|
||||
{
|
||||
return 'mood_remember';
|
||||
}
|
||||
|
||||
function session_cookie_params_for(int $lifetime = 0): array
|
||||
{
|
||||
return [
|
||||
@@ -634,3 +656,172 @@ function find_sport_types(array $settings, array $ids): array
|
||||
|
||||
return $types;
|
||||
}
|
||||
|
||||
function signal_scale_options(): array
|
||||
{
|
||||
return [
|
||||
-2 => 'sehr niedrig',
|
||||
-1 => 'niedrig',
|
||||
0 => 'neutral',
|
||||
1 => 'hoch',
|
||||
2 => 'sehr hoch',
|
||||
];
|
||||
}
|
||||
|
||||
function signal_labels_for_metric(string $metric): array
|
||||
{
|
||||
return match ($metric) {
|
||||
'stress' => [
|
||||
-2 => 'sehr ruhig',
|
||||
-1 => 'ruhig',
|
||||
0 => 'neutral',
|
||||
1 => 'angespannt',
|
||||
2 => 'sehr angespannt',
|
||||
],
|
||||
'energy' => [
|
||||
-2 => 'leer',
|
||||
-1 => 'matt',
|
||||
0 => 'okay',
|
||||
1 => 'wach',
|
||||
2 => 'kraftvoll',
|
||||
],
|
||||
default => [
|
||||
-2 => 'sehr niedrig',
|
||||
-1 => 'niedrig',
|
||||
0 => 'neutral',
|
||||
1 => 'hoch',
|
||||
2 => 'sehr hoch',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function normalize_signal_value(mixed $value): int
|
||||
{
|
||||
return max(-2, min(2, (int) $value));
|
||||
}
|
||||
|
||||
function signal_to_legacy_scale(mixed $value): int
|
||||
{
|
||||
return match (normalize_signal_value($value)) {
|
||||
-2 => 1,
|
||||
-1 => 3,
|
||||
0 => 5,
|
||||
1 => 7,
|
||||
2 => 9,
|
||||
};
|
||||
}
|
||||
|
||||
function legacy_to_signal_scale(mixed $value): int
|
||||
{
|
||||
$legacy = max(1, min(10, (int) $value));
|
||||
|
||||
return match (true) {
|
||||
$legacy <= 2 => -2,
|
||||
$legacy <= 4 => -1,
|
||||
$legacy <= 6 => 0,
|
||||
$legacy <= 8 => 1,
|
||||
default => 2,
|
||||
};
|
||||
}
|
||||
|
||||
function day_event_type_options(): array
|
||||
{
|
||||
return [
|
||||
'event' => [
|
||||
'label' => 'Moment',
|
||||
'icon' => '/assets/icons/activity-event.svg',
|
||||
'unit' => '',
|
||||
],
|
||||
'walk' => [
|
||||
'label' => 'Spaziergang',
|
||||
'icon' => sport_icon_path('hike'),
|
||||
'unit' => 'min',
|
||||
],
|
||||
'sport' => [
|
||||
'label' => 'Sport',
|
||||
'icon' => sport_icon_path('run'),
|
||||
'unit' => 'min',
|
||||
],
|
||||
'sleep' => [
|
||||
'label' => 'Schlaf',
|
||||
'icon' => '/assets/icons/activity-sleep.svg',
|
||||
'unit' => 'h',
|
||||
],
|
||||
'alcohol' => [
|
||||
'label' => 'Alkohol',
|
||||
'icon' => icon_path('alcohol'),
|
||||
'unit' => '',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
function day_event_type_label(string $type): string
|
||||
{
|
||||
return day_event_type_options()[$type]['label'] ?? day_event_type_options()['event']['label'];
|
||||
}
|
||||
|
||||
function day_event_type_icon(string $type): string
|
||||
{
|
||||
return day_event_type_options()[$type]['icon'] ?? day_event_type_options()['event']['icon'];
|
||||
}
|
||||
|
||||
function day_event_type_unit(string $type): string
|
||||
{
|
||||
return day_event_type_options()[$type]['unit'] ?? '';
|
||||
}
|
||||
|
||||
function signal_badge_tone(int $value, string $metric): string
|
||||
{
|
||||
$value = normalize_signal_value($value);
|
||||
|
||||
if ($metric === 'stress') {
|
||||
return match (true) {
|
||||
$value <= -1 => 'good',
|
||||
$value === 0 => 'neutral',
|
||||
default => 'warn',
|
||||
};
|
||||
}
|
||||
|
||||
return match (true) {
|
||||
$value <= -1 => 'warn',
|
||||
$value === 0 => 'neutral',
|
||||
default => 'good',
|
||||
};
|
||||
}
|
||||
|
||||
function signal_combo_score(mixed $mood, mixed $energy, mixed $stress): int
|
||||
{
|
||||
return max(-2, min(2, (int) round((
|
||||
normalize_signal_value($mood) +
|
||||
normalize_signal_value($energy) -
|
||||
normalize_signal_value($stress)
|
||||
) / 3)));
|
||||
}
|
||||
|
||||
function day_entry_has_content(array $entry): bool
|
||||
{
|
||||
if (trim((string) ($entry['summary']['comment'] ?? '')) !== '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (trim((string) ($entry['background_image'] ?? '')) !== '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ((int) ($entry['health']['steps'] ?? 0) > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return count(array_filter(is_array($entry['events'] ?? null) ? $entry['events'] : [], 'is_array')) > 0;
|
||||
}
|
||||
|
||||
function signal_value_class(int $value): string
|
||||
{
|
||||
return match (normalize_signal_value($value)) {
|
||||
-2 => 'neg2',
|
||||
-1 => 'neg1',
|
||||
0 => 'zero',
|
||||
1 => 'pos1',
|
||||
2 => 'pos2',
|
||||
};
|
||||
}
|
||||
|
||||
+19
-35
@@ -3,7 +3,7 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
$brandSubtitle = match ($page) {
|
||||
'dashboard' => 'Statistiken und Verlauf',
|
||||
'dashboard' => '',
|
||||
'track' => 'Tag erfassen und bewerten',
|
||||
'archive' => '',
|
||||
'options' => 'Logik, Erinnerungen, Sicherheit und Accounts',
|
||||
@@ -11,12 +11,15 @@ $brandSubtitle = match ($page) {
|
||||
'setup' => 'Erstkonfiguration',
|
||||
default => 'Stimmungstracker',
|
||||
};
|
||||
$immersiveDashboard = in_array($page, ['dashboard', 'options'], true);
|
||||
$cssVersion = is_file(base_path('assets/css/app.css')) ? (string) filemtime(base_path('assets/css/app.css')) : '1';
|
||||
$jsVersion = is_file(base_path('assets/js/app.js')) ? (string) filemtime(base_path('assets/js/app.js')) : '1';
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#0b1e2e">
|
||||
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
@@ -33,15 +36,19 @@ $brandSubtitle = match ($page) {
|
||||
<link rel="shortcut icon" href="/favicon.ico?v=20260412">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/assets/branding/apple-touch-icon.png?v=20260412">
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<link rel="stylesheet" href="/assets/css/app.css">
|
||||
<script defer src="/assets/js/app.js"></script>
|
||||
<?php if (($page ?? '') === 'dashboard' && ($dashboardView ?? '') === 'day'): ?>
|
||||
<link rel="prefetch" href="/?view=day&date=<?= e(rawurlencode((string) ($dashboardPrevDate ?? shift_date(today(), -1)))) ?>">
|
||||
<link rel="prefetch" href="/?view=day&date=<?= e(rawurlencode((string) ($dashboardNextDate ?? shift_date(today(), 1)))) ?>">
|
||||
<?php endif; ?>
|
||||
<link rel="stylesheet" href="/assets/css/app.css?v=<?= e($cssVersion) ?>">
|
||||
<script defer src="/assets/js/app.js?v=<?= e($jsVersion) ?>"></script>
|
||||
</head>
|
||||
<body class="app-body page-<?= e($page) ?><?= $authUser !== null ? ' is-authenticated' : '' ?><?= !empty($pageBodyClass) ? ' ' . e((string) $pageBodyClass) : '' ?>" data-authenticated="<?= $authUser !== null ? '1' : '0' ?>"<?= isset($trackMood) ? ' data-track-mood="' . e($trackMood) . '"' : '' ?>>
|
||||
<body class="app-body page-<?= e($page) ?><?= $authUser !== null ? ' is-authenticated' : '' ?><?= !empty($pageBodyClass) ? ' ' . e((string) $pageBodyClass) : '' ?>" data-authenticated="<?= $authUser !== null ? '1' : '0' ?>"<?= isset($trackMood) ? ' data-track-mood="' . e($trackMood) . '"' : '' ?><?= isset($dashboardWalkMode) ? ' data-walk-mode="' . e((string) $dashboardWalkMode) . '"' : '' ?>>
|
||||
<div class="aurora aurora-one"></div>
|
||||
<div class="aurora aurora-two"></div>
|
||||
<div class="pull-refresh-indicator glass-panel" data-pull-refresh-indicator aria-hidden="true">Zum Aktualisieren ziehen</div>
|
||||
<div class="shell">
|
||||
<?php if ($authUser !== null): ?>
|
||||
<div class="shell<?= $immersiveDashboard ? ' shell--dashboard' : '' ?>">
|
||||
<?php if ($authUser !== null && !$immersiveDashboard): ?>
|
||||
<aside class="sidebar glass-panel">
|
||||
<div class="brand-block">
|
||||
<div class="brand-mark">
|
||||
@@ -56,11 +63,7 @@ $brandSubtitle = match ($page) {
|
||||
<nav class="main-nav" aria-label="Hauptnavigation">
|
||||
<a class="<?= is_active_path('/') ? 'active' : '' ?>" href="/">
|
||||
<img class="nav-icon" src="<?= e(icon_path('dashboard')) ?>" alt="">
|
||||
<span>Dashboard</span>
|
||||
</a>
|
||||
<a class="<?= is_active_path('/track') ? 'active' : '' ?>" href="/track">
|
||||
<img class="nav-icon" src="<?= e(icon_path('track')) ?>" alt="">
|
||||
<span>Tracken</span>
|
||||
<span>Start</span>
|
||||
</a>
|
||||
<a class="<?= is_active_path('/archive') ? 'active' : '' ?>" href="/archive">
|
||||
<img class="nav-icon" src="<?= e(icon_path('archive')) ?>" alt="">
|
||||
@@ -86,7 +89,7 @@ $brandSubtitle = match ($page) {
|
||||
<?php endif; ?>
|
||||
|
||||
<main class="content">
|
||||
<?php if ($authUser !== null): ?>
|
||||
<?php if ($authUser !== null && !$immersiveDashboard): ?>
|
||||
<header class="topbar glass-panel">
|
||||
<div>
|
||||
<?php if ($brandSubtitle !== ''): ?>
|
||||
@@ -115,32 +118,13 @@ $brandSubtitle = match ($page) {
|
||||
|
||||
<?= $content ?>
|
||||
|
||||
<?php if (!$immersiveDashboard): ?>
|
||||
<footer class="site-footer glass-panel">
|
||||
<a class="site-footer__link" href="https://git.hnz.io/hnzio/mood-tracking/releases" target="_blank" rel="noreferrer">Version 1.2.1</a>
|
||||
<a class="site-footer__link" href="https://git.hnz.io/hnzio/mood-tracking/releases" target="_blank" rel="noreferrer">Version 1.7.0</a>
|
||||
<a class="site-footer__link" href="https://hnz.io" target="_blank" rel="noreferrer">(c) 2026 @hnz.io</a>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
<?php if ($authUser !== null): ?>
|
||||
<nav class="mobile-nav glass-panel" aria-label="Mobile Hauptnavigation">
|
||||
<a class="<?= is_active_path('/') ? 'active' : '' ?>" href="/">
|
||||
<img class="nav-icon" src="<?= e(icon_path('dashboard')) ?>" alt="">
|
||||
<span>Dashboard</span>
|
||||
</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 class="<?= is_active_path('/archive') ? 'active' : '' ?>" href="/archive">
|
||||
<img class="nav-icon" src="<?= e(icon_path('archive')) ?>" alt="">
|
||||
<span>Archiv</span>
|
||||
</a>
|
||||
<a class="<?= is_active_path('/options') ? 'active' : '' ?>" href="/options">
|
||||
<img class="nav-icon" src="<?= e(icon_path('options')) ?>" alt="">
|
||||
<span>Optionen</span>
|
||||
</a>
|
||||
</nav>
|
||||
<?php endif; ?>
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+697
-82
@@ -1,99 +1,714 @@
|
||||
<section class="hero-grid">
|
||||
<article class="hero-card hero-card--wide glass-panel">
|
||||
<p class="eyebrow">Stimmung im Blick</p>
|
||||
<h3>Dein Dashboard verbindet Verlauf, Score und Bewegungsdaten in einer schnellen Übersicht.</h3>
|
||||
<p class="hero-copy">Die Bewertung basiert auf deiner konfigurierbaren Logik. Sobald du die Regeln in den Optionen änderst, spiegeln Archiv und Statistiken die neue Gewichtung wider.</p>
|
||||
</article>
|
||||
<?php
|
||||
$dayDateLabel = format_display_date((string) $dayEntry['date']);
|
||||
$dayWeekday = strtok($dayDateLabel, ',');
|
||||
$dayBackground = is_string($dayEntry['background_image_url'] ?? null) ? (string) $dayEntry['background_image_url'] : null;
|
||||
$summaryComment = trim((string) ($dayEntry['summary']['comment'] ?? $dayEntry['summary_comment'] ?? ''));
|
||||
if (preg_match('/^\s*-?\s*(?:Stimmung|Energie|Stress)\s*:\s*0\s*$/iu', $summaryComment) === 1) {
|
||||
$summaryComment = '';
|
||||
}
|
||||
$summaryMood = normalize_signal_value($dayEntry['summary']['mood'] ?? $dayEntry['summary_mood'] ?? 0);
|
||||
$summaryEnergy = normalize_signal_value($dayEntry['summary']['energy'] ?? $dayEntry['summary_energy'] ?? 0);
|
||||
$summaryStress = normalize_signal_value($dayEntry['summary']['stress'] ?? $dayEntry['summary_stress'] ?? 0);
|
||||
$summaryAlcohol = !empty($dayEntry['summary']['alcohol'] ?? $dayEntry['summary_alcohol'] ?? false);
|
||||
$dayHealth = is_array($dayEntry['health'] ?? null) ? $dayEntry['health'] : [];
|
||||
$daySteps = (int) ($dayHealth['steps'] ?? 0);
|
||||
$dayStepBonus = (float) ($dayEntry['evaluation']['components']['step_bonus'] ?? 0);
|
||||
$optimalSleepHours = max(1.0, min(16.0, (float) ($settings['sleep']['optimal_hours'] ?? 7.0)));
|
||||
$formatBalanceValue = static function (?array $entry) use ($settings): string {
|
||||
if ($entry === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
<article class="hero-card glass-panel">
|
||||
<p class="eyebrow">Heute</p>
|
||||
<?php if ($summary['today'] !== null): ?>
|
||||
<div class="hero-score"><?= e(format_points((float) $summary['today']['evaluation']['total'])) ?></div>
|
||||
<p class="hero-label"><?= e($summary['today']['evaluation']['label']) ?></p>
|
||||
$mode = (string) ($settings['display']['score_mode'] ?? 'scale');
|
||||
$balance = is_array($entry['evaluation']['balance'] ?? null) ? $entry['evaluation']['balance'] : [];
|
||||
if ($mode === 'points') {
|
||||
return format_points((float) ($entry['evaluation']['total'] ?? 0)) . ' Punkte';
|
||||
}
|
||||
|
||||
if ($mode === 'percent') {
|
||||
return format_points((float) ($balance['percentage'] ?? $entry['evaluation']['percentage'] ?? 0)) . ' %';
|
||||
}
|
||||
|
||||
$level = max(-2, min(2, (int) ($balance['level'] ?? 0)));
|
||||
return ($level > 0 ? '+' : '') . (string) $level;
|
||||
};
|
||||
?>
|
||||
|
||||
<section class="dashboard-shell<?= $dayBackground !== null ? ' dashboard-shell--with-image' : '' ?>" data-dashboard-root>
|
||||
<?php if ($dayBackground !== null): ?>
|
||||
<div class="dashboard-shell__background" aria-hidden="true">
|
||||
<img src="<?= e($dayBackground) ?>" alt="">
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<header class="dashboard-topbar">
|
||||
<nav class="dashboard-switcher glass-panel" aria-label="Ansicht wechseln">
|
||||
<a class="<?= $dashboardView === 'day' && $dashboardDate === today() ? 'active' : '' ?>" href="/?view=day&date=<?= e(rawurlencode(today())) ?>">Heute</a>
|
||||
<a class="<?= $dashboardView === 'week' ? 'active' : '' ?>" href="/?view=week&date=<?= e(rawurlencode(today())) ?>">Woche</a>
|
||||
<a class="<?= $dashboardView === 'month' ? 'active' : '' ?>" href="/?view=month&date=<?= e(rawurlencode(today())) ?>">Monat</a>
|
||||
</nav>
|
||||
|
||||
<button class="dashboard-settings glass-panel" type="button" data-settings-menu-open aria-label="Optionen öffnen">
|
||||
<img src="<?= e(icon_path('options')) ?>" alt="">
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<?php if ($dashboardView === 'day'): ?>
|
||||
<div class="dashboard-day" data-day-swipe data-prev-date="<?= e($dashboardPrevDate) ?>" data-next-date="<?= e($dashboardNextDate) ?>">
|
||||
<div class="dashboard-day-slider" data-day-slider-shell>
|
||||
<span class="day-slide-hint day-slide-hint--prev" data-day-slide-prev-hint><span class="day-slide-hint__arrow" aria-hidden="true"></span>Vorherigen Tag laden</span>
|
||||
<span class="day-slide-hint day-slide-hint--next" data-day-slide-next-hint>Nächster Tag laden<span class="day-slide-hint__arrow" aria-hidden="true"></span></span>
|
||||
<div class="dashboard-day__hero" data-day-slider>
|
||||
<p class="dashboard-day__eyebrow"><?= e((string) $dayWeekday) ?></p>
|
||||
<h1><?= e(format_display_date((string) $dayEntry['date'], false)) ?></h1>
|
||||
|
||||
<nav class="dashboard-compare-strip" aria-label="Tagesvergleich" data-day-strip>
|
||||
<span class="score-scale score-scale--day" aria-hidden="true"><span>+2</span><span>+1</span><span>0</span><span>-1</span><span>-2</span></span>
|
||||
<?php foreach ($dashboardCompareDays as $compareDay): ?>
|
||||
<a class="compare-day offset-<?= e((string) ($compareDay['offset'] ?? 0)) ?><?= !empty($compareDay['is_current']) ? ' is-current' : '' ?><?= empty($compareDay['has_content']) ? ' is-empty' : '' ?>" href="/?view=day&date=<?= e(rawurlencode((string) $compareDay['date'])) ?>">
|
||||
<span class="compare-day__line<?= empty($compareDay['has_content']) ? ' is-empty' : '' ?><?= !empty($compareDay['is_current']) ? ' is-primary' : '' ?> score-<?= e((string) ($compareDay['score_level'] ?? 'empty')) ?> compare-tone-<?= e((string) ($compareDay['line_tone'] ?? 'empty')) ?>">
|
||||
<span class="compare-day__marker"></span>
|
||||
</span>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="day-summary-card glass-panel<?= $dashboardHasContent ? ' is-filled' : '' ?>" type="button" data-summary-overlay-open>
|
||||
<span class="day-summary-card__label">Tagesbilanz</span>
|
||||
<strong class="day-summary-card__title"><?= $summaryComment !== '' ? e($summaryComment) : 'Tagesbilanz' ?></strong>
|
||||
<?php if ($daySteps > 0): ?>
|
||||
<span class="day-summary-card__chips">
|
||||
<span class="day-chip day-chip--score">Bilanz <?= e($formatBalanceValue($dayEntry)) ?></span>
|
||||
<span class="day-chip"><?= e(number_format($daySteps, 0, ',', '.')) ?> Schritte</span>
|
||||
<?php if ($dayStepBonus > 0): ?>
|
||||
<span class="day-chip day-chip--bonus">+<?= e(format_points($dayStepBonus)) ?> Punkt</span>
|
||||
<?php endif; ?>
|
||||
</span>
|
||||
<?php else: ?>
|
||||
<div class="hero-score">-</div>
|
||||
<p class="hero-label">Noch kein Eintrag für heute</p>
|
||||
<span class="day-summary-card__chips"><span class="day-chip day-chip--score">Bilanz <?= e($formatBalanceValue($dayEntry)) ?></span></span>
|
||||
<?php endif; ?>
|
||||
</article>
|
||||
</section>
|
||||
</button>
|
||||
|
||||
<section class="stats-grid">
|
||||
<article class="metric-card glass-panel">
|
||||
<span>Getrackte Tage</span>
|
||||
<strong><?= e((string) $summary['tracked_days']) ?></strong>
|
||||
</article>
|
||||
<article class="metric-card glass-panel">
|
||||
<span>Ø Score</span>
|
||||
<strong><?= e(format_points((float) $summary['average_score'])) ?></strong>
|
||||
</article>
|
||||
<article class="metric-card glass-panel">
|
||||
<span>Ø Stimmung</span>
|
||||
<strong><?= e(format_points((float) $summary['average_mood'])) ?>/10</strong>
|
||||
</article>
|
||||
<article class="metric-card glass-panel">
|
||||
<span>Ø Stress</span>
|
||||
<strong><?= e(format_points((float) $summary['average_stress'])) ?>/10</strong>
|
||||
</article>
|
||||
<article class="metric-card glass-panel">
|
||||
<span>Serie</span>
|
||||
<strong><?= e((string) $summary['streak']) ?> Tage</strong>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="dashboard-grid">
|
||||
<article class="glass-panel chart-card chart-card--calendar">
|
||||
<div class="section-head">
|
||||
<section class="dashboard-moments-block">
|
||||
<div class="section-head section-head--compact section-head--dashboard">
|
||||
<div>
|
||||
<p class="eyebrow">Kalender</p>
|
||||
<h3>Gesamtstimmung pro Tag</h3>
|
||||
<p class="eyebrow">Deine Momente</p>
|
||||
<h2>Momente des Tages</h2>
|
||||
</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 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>
|
||||
<span class="chart-chip">letzte 30 Einträge</span>
|
||||
</div>
|
||||
<div class="line-chart" data-chart-type="line" data-series="mood" data-payload="<?= e($chartPayload) ?>"></div>
|
||||
</article>
|
||||
|
||||
<article class="glass-panel chart-card">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<p class="eyebrow">Belastung</p>
|
||||
<h3>Stressverlauf</h3>
|
||||
</div>
|
||||
<span class="chart-chip chart-chip--warm">weniger ist besser</span>
|
||||
</div>
|
||||
<div class="line-chart" data-chart-type="line" data-series="stress" data-payload="<?= e($chartPayload) ?>"></div>
|
||||
</article>
|
||||
|
||||
<?php if (!empty($settings['tracking']['pain_enabled'])): ?>
|
||||
<article class="glass-panel chart-card">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<p class="eyebrow">Körper</p>
|
||||
<h3>Schmerzverlauf</h3>
|
||||
</div>
|
||||
<span class="chart-chip chart-chip--warm">weniger ist besser</span>
|
||||
</div>
|
||||
<div class="line-chart" data-chart-type="line" data-series="pain" data-payload="<?= e($chartPayload) ?>"></div>
|
||||
</article>
|
||||
<?php endif; ?>
|
||||
|
||||
<article class="glass-panel chart-card chart-card--wide">
|
||||
<div class="section-head">
|
||||
<?php foreach ($dashboardTimeline as $item): ?>
|
||||
<?php $eventType = (string) ($item['type'] ?? 'event'); ?>
|
||||
<?php $eventComment = trim((string) ($item['comment'] ?? '')); ?>
|
||||
<?php $isImportedHealth = (string) ($item['source'] ?? '') === 'health_auto_export'; ?>
|
||||
<?php $sportType = $eventType === 'sport' ? find_sport_type($settings, (string) ($item['sport_type_id'] ?? '')) : null; ?>
|
||||
<?php $isImportedWalkSport = $isImportedHealth && $eventType === 'sport' && str_contains(strtolower((string) ($sportType['label'] ?? $eventComment)), 'spaziergang'); ?>
|
||||
<?php $eventTone = signal_value_class(normalize_signal_value($item['mood'] ?? 0)); ?>
|
||||
<?php $eventValueText = (float) $item['value'] > 0 ? ($eventType === 'sleep' ? format_duration_hours((float) $item['value']) : rtrim(rtrim(number_format((float) $item['value'], 2, ',', '.'), '0'), ',') . ' ' . (string) $item['unit']) : ''; ?>
|
||||
<?php $eventTitle = match ($eventType) {
|
||||
'sport' => $isImportedWalkSport ? 'Spaziergang' : (string) ($sportType['label'] ?? 'Sport'),
|
||||
'walk' => 'Spaziergang',
|
||||
'sleep' => 'Schlaf',
|
||||
default => (string) ($item['comment'] !== '' ? $item['comment'] : day_event_type_label($eventType)),
|
||||
}; ?>
|
||||
<?php $eventDetail = match ($eventType) {
|
||||
'sport' => trim($eventValueText),
|
||||
'walk', 'sleep' => trim($eventValueText),
|
||||
default => trim($eventValueText . ($sportType !== null ? ' · ' . (string) ($sportType['label'] ?? '') : '')),
|
||||
}; ?>
|
||||
<?php $showEventComment = in_array($eventType, ['sport', 'walk', 'sleep'], true) && $eventComment !== ''; ?>
|
||||
<?php
|
||||
$sleepPhases = ['deep' => (float) ($item['sleep_deep'] ?? 0), 'rem' => (float) ($item['sleep_rem'] ?? 0), 'core' => (float) ($item['sleep_core'] ?? 0)];
|
||||
$sleepPhaseSource = trim($eventComment . ' ' . (string) ($item['duration_label'] ?? '') . ' ' . (string) ($item['distance_label'] ?? '') . ' ' . (string) ($item['energy_label'] ?? '') . ' ' . (string) ($item['heart_rate_label'] ?? ''));
|
||||
if ($eventType === 'sleep' && array_sum($sleepPhases) <= 0 && $sleepPhaseSource !== '') {
|
||||
if (preg_match('/(?:Tief|Tiefschlaf)\s*:?\s*([0-9]+(?:[,.][0-9]+)?)/u', $sleepPhaseSource, $match) === 1) {
|
||||
$sleepPhases['deep'] = (float) str_replace(',', '.', $match[1]);
|
||||
}
|
||||
if (preg_match('/REM(?:-Schlaf)?\s*:?\s*([0-9]+(?:[,.][0-9]+)?)/u', $sleepPhaseSource, $match) === 1) {
|
||||
$sleepPhases['rem'] = (float) str_replace(',', '.', $match[1]);
|
||||
}
|
||||
if (preg_match('/(?:Kern|Kernschlaf)\s*:?\s*([0-9]+(?:[,.][0-9]+)?)/u', $sleepPhaseSource, $match) === 1) {
|
||||
$sleepPhases['core'] = (float) str_replace(',', '.', $match[1]);
|
||||
}
|
||||
}
|
||||
$sleepPhaseTotal = max(0.0, array_sum($sleepPhases));
|
||||
$sleepActualTotal = $eventType === 'sleep' ? max((float) ($item['value'] ?? 0), $sleepPhaseTotal) : 0.0;
|
||||
$sleepBarTotal = $eventType === 'sleep' ? max($sleepActualTotal, $optimalSleepHours / 0.75) : 0.0;
|
||||
$sleepUnclassified = max(0.0, $sleepActualTotal - $sleepPhaseTotal);
|
||||
$sleepPhaseRemainder = max(0.0, $sleepBarTotal - $sleepActualTotal);
|
||||
$sleepOptimalPercent = $sleepBarTotal > 0 ? max(0, min(100, ($optimalSleepHours / $sleepBarTotal) * 100)) : 0;
|
||||
$sleepActualPercent = $sleepBarTotal > 0 ? max(0, min(100, ($sleepActualTotal / $sleepBarTotal) * 100)) : 0;
|
||||
$sleepPhaseLeft = 0.0;
|
||||
?>
|
||||
<?php $eventStats = array_values(array_filter([
|
||||
$eventType !== 'sleep' ? (string) ($item['duration_label'] ?? '') : '',
|
||||
(string) ($item['distance_label'] ?? ''),
|
||||
'',
|
||||
(string) ($item['heart_rate_label'] ?? ''),
|
||||
], static function (string $value): bool {
|
||||
$value = trim($value);
|
||||
return $value !== '' && !preg_match('/^-\s*(Distanz|Energie|Puls|Route|Tief|Tiefschlaf|REM|REM-Schlaf|Kern|Kernschlaf)(?:-?Label)?:?(?:\s*[0-9]+(?:[,.][0-9]+)?)?$/u', $value);
|
||||
})); ?>
|
||||
<?php $eventPayload = encode_payload([
|
||||
'id' => (string) ($item['id'] ?? ''),
|
||||
'type' => (string) ($item['type'] ?? 'event'),
|
||||
'time' => (string) ($item['time'] ?? ''),
|
||||
'comment' => (string) ($item['comment'] ?? ''),
|
||||
'value' => (float) ($item['value'] ?? 0),
|
||||
'unit' => (string) ($item['unit'] ?? ''),
|
||||
'sport_type_id' => (string) ($item['sport_type_id'] ?? ''),
|
||||
'image' => (string) ($item['image'] ?? ''),
|
||||
'consumed' => !empty($item['consumed']),
|
||||
'mood' => normalize_signal_value($item['mood'] ?? 0),
|
||||
'energy' => normalize_signal_value($item['energy'] ?? 0),
|
||||
'stress' => normalize_signal_value($item['stress'] ?? 0),
|
||||
'source' => (string) ($item['source'] ?? ''),
|
||||
'import_id' => (string) ($item['import_id'] ?? ''),
|
||||
'duration_label' => (string) ($item['duration_label'] ?? ''),
|
||||
'distance_label' => (string) ($item['distance_label'] ?? ''),
|
||||
'energy_label' => (string) ($item['energy_label'] ?? ''),
|
||||
'heart_rate_label' => (string) ($item['heart_rate_label'] ?? ''),
|
||||
'sleep_deep' => (float) ($item['sleep_deep'] ?? 0),
|
||||
'sleep_rem' => (float) ($item['sleep_rem'] ?? 0),
|
||||
'sleep_core' => (float) ($item['sleep_core'] ?? 0),
|
||||
]); ?>
|
||||
<?php $hasEventImage = is_string($item['image_url'] ?? null); ?>
|
||||
<?php $routeMap = is_array($item['route_map'] ?? null) ? $item['route_map'] : null; ?>
|
||||
<article class="timeline-card timeline-card--event timeline-card--<?= e($eventTone) ?><?= $hasEventImage ? ' timeline-card--with-image' : '' ?> glass-panel" data-event-editable data-event-payload="<?= e($eventPayload) ?>">
|
||||
<?php if ($hasEventImage): ?>
|
||||
<button class="timeline-media-button" type="button" data-lightbox-src="<?= e((string) $item['image_url']) ?>" data-lightbox-kind="image" aria-label="Bild vergrößern">
|
||||
<img class="timeline-card__image" src="<?= e((string) $item['image_url']) ?>" alt="">
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
<span class="timeline-card__time-chip"><?= e($item['time'] !== '' ? $item['time'] : '--:--') ?></span>
|
||||
<div class="timeline-card__meta">
|
||||
<div class="timeline-card__icon-wrap" title="<?= e(day_event_type_label((string) $item['type'])) ?>">
|
||||
<img class="timeline-card__icon" src="<?= e(day_event_type_icon((string) $item['type'])) ?>" alt="">
|
||||
</div>
|
||||
<div>
|
||||
<p class="eyebrow">Aktivität</p>
|
||||
<h3>Sport und Spaziergang</h3>
|
||||
<strong class="timeline-card__time"><?= e($item['time'] !== '' ? $item['time'] : '--:--') ?></strong>
|
||||
</div>
|
||||
<span class="chart-chip chart-chip--cool">Aktivität pro Tag</span>
|
||||
</div>
|
||||
<div class="bar-chart" data-chart-type="bars" data-series="sport" data-payload="<?= e($chartPayload) ?>"></div>
|
||||
|
||||
<div class="timeline-card__body">
|
||||
<h3><?= e($eventTitle) ?></h3>
|
||||
<?php if ($showEventComment): ?>
|
||||
<p class="timeline-card__comment"><?= e($eventComment) ?></p>
|
||||
<?php endif; ?>
|
||||
<?php if ((string) ($item['type'] ?? '') === 'alcohol'): ?>
|
||||
<p class="timeline-card__value">
|
||||
<?= !empty($item['consumed']) ? 'Heute getrunken' : 'Heute nicht getrunken' ?>
|
||||
</p>
|
||||
<?php elseif ($eventDetail !== ''): ?>
|
||||
<p class="timeline-card__value"><?= e($eventDetail) ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ((string) ($item['comment'] ?? '') !== '' && (string) ($item['type'] ?? '') === 'alcohol'): ?>
|
||||
<p class="timeline-card__value"><?= e((string) $item['comment']) ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($eventType === 'sleep' && $sleepActualTotal > 0): ?>
|
||||
<div class="sleep-phase-bar" aria-label="Schlafphasen" style="--sleep-optimal-left: <?= e((string) $sleepOptimalPercent) ?>%; --sleep-actual-width: <?= e((string) $sleepActualPercent) ?>%">
|
||||
<div class="sleep-phase-bar__fill" style="width: <?= e((string) $sleepActualPercent) ?>%">
|
||||
<?php if ($sleepPhaseTotal > 0): ?>
|
||||
<?php foreach (['deep' => ['Tief', 'deep'], 'rem' => ['REM', 'rem'], 'core' => ['Kern', 'core']] as $phase => [$label, $class]): ?>
|
||||
<?php $phaseHours = max(0.0, (float) ($sleepPhases[$phase] ?? 0)); ?>
|
||||
<?php if ($phaseHours <= 0) { continue; } ?>
|
||||
<?php $phasePercent = $sleepActualTotal > 0 ? max(0.5, min(100, ($phaseHours / $sleepActualTotal) * 100)) : 0; ?>
|
||||
<span class="sleep-phase-bar__segment sleep-phase-bar__segment--<?= e($class) ?><?= $phasePercent < 13 ? ' is-compact' : '' ?>" style="width: <?= e((string) $phasePercent) ?>%; flex-basis: <?= e((string) $phasePercent) ?>%" title="<?= e($label) ?>: <?= e(format_duration_hours($phaseHours)) ?>" data-tooltip="<?= e($label) ?>: <?= e(format_duration_hours($phaseHours)) ?>">
|
||||
<strong><?= e($label) ?></strong> <?= e(format_duration_hours($phaseHours)) ?>
|
||||
</span>
|
||||
<?php $sleepPhaseLeft += $phaseHours; ?>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
<?php if ($sleepUnclassified > 0): ?>
|
||||
<?php $unclassifiedPercent = $sleepActualTotal > 0 ? max(0.5, min(100, ($sleepUnclassified / $sleepActualTotal) * 100)) : 0; ?>
|
||||
<span class="sleep-phase-bar__segment sleep-phase-bar__segment--total<?= $sleepPhaseTotal > 0 ? ' is-after-phase' : '' ?><?= $unclassifiedPercent < 13 ? ' is-compact' : '' ?>" style="width: <?= e((string) $unclassifiedPercent) ?>%; flex-basis: <?= e((string) $unclassifiedPercent) ?>%" title="Schlaf: <?= e(format_duration_hours($sleepUnclassified)) ?>" data-tooltip="Schlaf: <?= e(format_duration_hours($sleepUnclassified)) ?>">
|
||||
<strong>Schlaf</strong> <?= e(format_duration_hours($sleepUnclassified)) ?>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<span class="sleep-phase-bar__target"><span><?= e(format_duration_hours($optimalSleepHours)) ?></span></span>
|
||||
<?php if ($sleepPhaseRemainder > 0): ?>
|
||||
<span class="sleep-phase-bar__rest-label">noch <?= e(format_duration_hours($sleepPhaseRemainder)) ?> bis Skalenende</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($eventStats !== []): ?>
|
||||
<div class="timeline-card__stats" aria-label="Importdetails">
|
||||
<?php foreach ($eventStats as $stat): ?>
|
||||
<span><?= e($stat) ?></span>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="signal-row">
|
||||
<?php foreach (['mood' => 'Stimmung', 'energy' => 'Energie', 'stress' => 'Stress'] as $metric => $label): ?>
|
||||
<?php $value = normalize_signal_value($item[$metric] ?? 0); ?>
|
||||
<?php $valueTone = signal_value_class($metric === 'stress' ? -$value : $value); ?>
|
||||
<?php if ($value === 0) { continue; } ?>
|
||||
<span class="signal-pill signal-pill--<?= e(signal_badge_tone($value, $metric)) ?> signal-pill--<?= e($valueTone) ?>">
|
||||
<strong><?= e($label) ?></strong>
|
||||
<img class="signal-pill__icon" src="<?= e(icon_path($metric === 'mood' ? 'signal-mood' : ($metric === 'energy' ? 'signal-energy' : 'signal-stress'))) ?>" alt="<?= e($label) ?>">
|
||||
<span><?= $value >= 0 ? '+' : '' ?><?= e((string) $value) ?></span>
|
||||
</span>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($routeMap !== null): ?>
|
||||
<button class="timeline-route-map" type="button" data-lightbox-kind="html" aria-label="Route vergrößern">
|
||||
<svg viewBox="0 0 <?= e((string) $routeMap['width']) ?> <?= e((string) $routeMap['height']) ?>" aria-hidden="true">
|
||||
<?php foreach ($routeMap['tiles'] as $tile): ?>
|
||||
<image href="<?= e((string) $tile['url']) ?>" x="<?= e((string) $tile['left']) ?>" y="<?= e((string) $tile['top']) ?>" width="256" height="256"></image>
|
||||
<?php endforeach; ?>
|
||||
<polyline points="<?= e((string) $routeMap['line']) ?>"></polyline>
|
||||
</svg>
|
||||
<span class="timeline-route-map__credit">© OpenStreetMap</span>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" action="/" class="timeline-card__delete">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="form_name" value="delete_event">
|
||||
<input type="hidden" name="date" value="<?= e((string) $dayEntry['date']) ?>">
|
||||
<input type="hidden" name="event_id" value="<?= e((string) $item['id']) ?>">
|
||||
<button class="ghost-button ghost-button--small" type="submit" data-confirm-delete aria-label="Moment löschen">×</button>
|
||||
</form>
|
||||
</article>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<button class="dashboard-fab" type="button" data-moment-overlay-open aria-label="Neuen Moment hinzufügen">+</button>
|
||||
<div class="dashboard-fab-menu glass-panel" data-fab-menu hidden>
|
||||
<?php foreach ($dashboardEventTypes as $type => $meta): ?>
|
||||
<?php if ($type === 'alcohol') { continue; } ?>
|
||||
<button type="button" data-fab-moment-choice="<?= e($type) ?>">
|
||||
<img src="<?= e((string) $meta['icon']) ?>" alt="">
|
||||
<span><?= e((string) $meta['label']) ?></span>
|
||||
</button>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-overlay" data-summary-overlay hidden>
|
||||
<div class="dashboard-overlay__backdrop" data-summary-overlay-close></div>
|
||||
<section class="dashboard-modal glass-panel dashboard-modal--summary" role="dialog" aria-modal="true">
|
||||
<div class="dashboard-modal__controls">
|
||||
<button class="dashboard-modal__round" type="button" data-summary-overlay-close>×</button>
|
||||
<button class="dashboard-modal__round dashboard-modal__round--confirm" type="submit" form="day-summary-form">✓</button>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/" enctype="multipart/form-data" class="dashboard-modal__form" id="day-summary-form">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="form_name" value="save_day_summary">
|
||||
<input type="hidden" name="date" value="<?= e((string) $dayEntry['date']) ?>">
|
||||
|
||||
<h2 class="dashboard-modal__title"><?= e(format_display_date((string) $dayEntry['date'], false)) ?></h2>
|
||||
<p class="dashboard-modal__subtitle">Deine Tagesbilanz</p>
|
||||
|
||||
<label class="dashboard-modal__textarea">
|
||||
<textarea name="summary_comment" rows="5" placeholder="Fasse deinen Tag zusammen"><?= e($summaryComment) ?></textarea>
|
||||
</label>
|
||||
|
||||
<div class="overlay-signal-grid overlay-signal-grid--summary-row">
|
||||
<?php foreach (['summary_mood' => ['Stimmung', $summaryMood], 'summary_energy' => ['Energie', $summaryEnergy], 'summary_stress' => ['Stress', $summaryStress]] as $field => [$label, $value]): ?>
|
||||
<?php $metric = str_replace('summary_', '', $field); ?>
|
||||
<div class="overlay-signal-card overlay-signal-card--inline" data-stepper data-stepper-metric="<?= e($metric) ?>">
|
||||
<div>
|
||||
<h3><?= e($label) ?></h3>
|
||||
<p data-stepper-label>
|
||||
<?= e(signal_labels_for_metric($metric)[$value]) ?>
|
||||
</p>
|
||||
</div>
|
||||
<div class="overlay-signal-card__control">
|
||||
<div class="overlay-signal-card__ring tone-<?= e(signal_value_class($value)) ?>">
|
||||
<span data-stepper-value><?= $value >= 0 ? '+' : '' ?><?= e((string) $value) ?></span>
|
||||
</div>
|
||||
<div class="overlay-signal-card__buttons">
|
||||
<button type="button" data-stepper-minus>-</button>
|
||||
<button type="button" data-stepper-plus>+</button>
|
||||
</div>
|
||||
<div class="signal-scale" aria-hidden="true"><span>-2</span><span>-1</span><span>0</span><span>+1</span><span>+2</span></div>
|
||||
<input type="hidden" name="<?= e($field) ?>" value="<?= e((string) $value) ?>" data-stepper-input>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<fieldset class="moment-alcohol-field moment-alcohol-field--summary">
|
||||
<legend>Alkohol</legend>
|
||||
<div class="moment-choice-row">
|
||||
<label class="moment-choice-pill">
|
||||
<input type="radio" name="summary_alcohol" value="1" <?= $summaryAlcohol ? 'checked' : '' ?>>
|
||||
<span>Ja</span>
|
||||
</label>
|
||||
<label class="moment-choice-pill">
|
||||
<input type="radio" name="summary_alcohol" value="0" <?= !$summaryAlcohol ? 'checked' : '' ?>>
|
||||
<span>Nein</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<label>
|
||||
<span>Tagesbild</span>
|
||||
<input type="file" name="background_image" accept="image/jpeg,image/png,image/webp">
|
||||
</label>
|
||||
</form>
|
||||
|
||||
<?php if ($dayBackground !== null): ?>
|
||||
<form method="post" action="/" class="dashboard-modal__secondary-action">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="form_name" value="remove_background">
|
||||
<input type="hidden" name="date" value="<?= e((string) $dayEntry['date']) ?>">
|
||||
<button class="ghost-button" type="submit">Bild entfernen</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-overlay" data-moment-overlay hidden>
|
||||
<div class="dashboard-overlay__backdrop" data-moment-overlay-close></div>
|
||||
<section class="dashboard-modal glass-panel dashboard-modal--moment" role="dialog" aria-modal="true" data-moment-modal>
|
||||
<div class="dashboard-modal__controls">
|
||||
<button class="dashboard-modal__round" type="button" data-moment-overlay-close>×</button>
|
||||
<button class="dashboard-modal__round dashboard-modal__round--confirm" type="submit" form="moment-form" data-moment-submit disabled>✓</button>
|
||||
</div>
|
||||
|
||||
<div data-moment-step="choose">
|
||||
<h2 class="dashboard-modal__title">Neuer Moment</h2>
|
||||
<div class="moment-type-grid">
|
||||
<?php foreach ($dashboardEventTypes as $type => $meta): ?>
|
||||
<?php if ($type === 'alcohol') { continue; } ?>
|
||||
<button class="moment-type-card" type="button" data-moment-type-choice="<?= e($type) ?>">
|
||||
<img src="<?= e((string) $meta['icon']) ?>" alt="">
|
||||
<span><?= e((string) $meta['label']) ?></span>
|
||||
</button>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/" enctype="multipart/form-data" class="dashboard-modal__form" id="moment-form" data-moment-step="form" hidden>
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="form_name" value="add_event" data-moment-form-name>
|
||||
<input type="hidden" name="date" value="<?= e((string) $dayEntry['date']) ?>">
|
||||
<input type="hidden" name="event_id" value="" data-moment-event-id>
|
||||
<input type="hidden" name="event_type" value="event" data-moment-type-input>
|
||||
<input type="hidden" name="event_unit" value="" data-event-unit>
|
||||
<input type="hidden" name="event_walk_mode" value="time" data-walk-mode-input>
|
||||
|
||||
<div class="dashboard-modal__heading-row">
|
||||
<div>
|
||||
<p class="dashboard-modal__subtitle" data-moment-type-label>Neuer Moment</p>
|
||||
<h2 class="dashboard-modal__title">Was ist passiert?</h2>
|
||||
</div>
|
||||
<button class="ghost-button ghost-button--small" type="button" data-moment-back>Typ ändern</button>
|
||||
</div>
|
||||
|
||||
<label class="dashboard-modal__textarea">
|
||||
<textarea name="event_comment" rows="4" placeholder="Was hast du erlebt?" data-moment-comment></textarea>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Momentbild</span>
|
||||
<input type="file" name="event_image" accept="image/jpeg,image/png,image/webp">
|
||||
</label>
|
||||
|
||||
<div class="field-grid field-grid--two">
|
||||
<label>
|
||||
<span>Erfasst um</span>
|
||||
<input type="time" name="event_time" value="<?= e(date('H:i')) ?>" required>
|
||||
</label>
|
||||
<label data-moment-value-field>
|
||||
<span data-moment-value-label>Wert</span>
|
||||
<input type="number" name="event_value" min="0" max="50000" step="0.01" placeholder="optional" data-moment-value-input>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<fieldset data-moment-sport-field hidden>
|
||||
<legend>Sportart</legend>
|
||||
<input type="hidden" name="event_sport_type_id" value="">
|
||||
<div class="moment-type-grid moment-type-grid--sport">
|
||||
<?php foreach ($dashboardSportTypes as $sportType): ?>
|
||||
<button class="moment-type-card moment-type-card--sport" type="button" data-sport-choice="<?= e((string) ($sportType['id'] ?? '')) ?>">
|
||||
<img src="<?= e(sport_icon_path((string) ($sportType['icon'] ?? 'run'))) ?>" alt="">
|
||||
<span><?= e((string) ($sportType['label'] ?? '')) ?></span>
|
||||
</button>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="moment-alcohol-field" data-moment-walk-field hidden>
|
||||
<legend>Spaziergang als</legend>
|
||||
<div class="moment-choice-row">
|
||||
<label class="moment-choice-pill"><input type="radio" name="event_walk_mode" value="time" checked><span>Dauer</span></label>
|
||||
<label class="moment-choice-pill"><input type="radio" name="event_walk_mode" value="steps"><span>Schritte</span></label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="moment-alcohol-field" data-moment-alcohol-field hidden>
|
||||
<legend>Heute Alkohol getrunken?</legend>
|
||||
<div class="moment-choice-row">
|
||||
<label class="moment-choice-pill">
|
||||
<input type="radio" name="event_consumed" value="1" checked>
|
||||
<span>Ja</span>
|
||||
</label>
|
||||
<label class="moment-choice-pill">
|
||||
<input type="radio" name="event_consumed" value="0">
|
||||
<span>Nein</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="overlay-signal-grid overlay-signal-grid--summary-row overlay-signal-grid--moment">
|
||||
<?php foreach (['event_mood' => ['Stimmung', 0], 'event_energy' => ['Energie', 0], 'event_stress' => ['Stress', 0]] as $field => [$label, $value]): ?>
|
||||
<?php $metric = str_replace('event_', '', $field); ?>
|
||||
<div class="overlay-signal-card overlay-signal-card--inline overlay-signal-card--moment" data-stepper data-stepper-metric="<?= e($metric) ?>">
|
||||
<div>
|
||||
<h3><?= e($label) ?></h3>
|
||||
</div>
|
||||
<div class="overlay-signal-card__control">
|
||||
<div class="overlay-signal-card__ring tone-zero">
|
||||
<span data-stepper-value><?= $value >= 0 ? '+' : '' ?><?= e((string) $value) ?></span>
|
||||
</div>
|
||||
<div class="overlay-signal-card__buttons">
|
||||
<button type="button" data-stepper-minus>-</button>
|
||||
<button type="button" data-stepper-plus>+</button>
|
||||
</div>
|
||||
<div class="signal-scale" aria-hidden="true"><span>-2</span><span>-1</span><span>0</span><span>+1</span><span>+2</span></div>
|
||||
<input type="hidden" name="<?= e($field) ?>" value="<?= e((string) $value) ?>" data-stepper-input>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form method="post" action="/" class="dashboard-modal__secondary-action" data-moment-delete-form hidden>
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="form_name" value="delete_event">
|
||||
<input type="hidden" name="date" value="<?= e((string) $dayEntry['date']) ?>">
|
||||
<input type="hidden" name="event_id" value="" data-moment-delete-id>
|
||||
<button class="ghost-button" type="submit">Moment löschen</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
<?php elseif ($dashboardView === 'week'): ?>
|
||||
<section class="dashboard-range-view dashboard-range-view--week">
|
||||
<header class="dashboard-range-view__hero">
|
||||
<p class="eyebrow">Wochenansicht</p>
|
||||
<h1><?= e($dashboardWeek['title']) ?></h1>
|
||||
<h2><?= e($dashboardWeek['range']) ?></h2>
|
||||
</header>
|
||||
|
||||
<?php $weekInsights = is_array($dashboardWeek['insights'] ?? null) ? $dashboardWeek['insights'] : []; ?>
|
||||
<?php if ((int) ($weekInsights['average_steps'] ?? 0) > 0 || (int) ($weekInsights['daily_sport_minutes'] ?? 0) > 0): ?>
|
||||
<section class="week-insight-card glass-panel">
|
||||
<?php if ((int) ($weekInsights['average_steps'] ?? 0) > 0): ?>
|
||||
<p>
|
||||
Du bist in dieser Woche durchschnittlich <strong><?= e(number_format((int) $weekInsights['average_steps'], 0, ',', '.')) ?> Schritte</strong> gegangen.
|
||||
<?php if (!empty($weekInsights['has_step_comparison'])): ?>
|
||||
Das sind <strong><?= e(number_format(abs((int) $weekInsights['step_difference']), 0, ',', '.')) ?> Schritte <?= e((string) $weekInsights['step_direction']) ?></strong> als im vergangenen Monat.
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
<?php if ((int) ($weekInsights['daily_sport_minutes'] ?? 0) > 0): ?>
|
||||
<p>Täglich hast du im Schnitt <strong><?= e((string) $weekInsights['daily_sport_minutes']) ?> Minuten Sport</strong> gemacht.</p>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="range-period-rail range-period-rail--week">
|
||||
<?php foreach (($dashboardWeek['periods'] ?? [$dashboardWeek]) as $week): ?>
|
||||
<article class="range-period-panel<?= !empty($week['is_selected']) ? ' is-selected' : '' ?>">
|
||||
<header class="range-period-panel__head">
|
||||
<a href="/?view=week&date=<?= e(rawurlencode((string) ($week['key'] ?? $dashboardDate))) ?>">
|
||||
<h3><?= e((string) $week['title']) ?></h3>
|
||||
<p><?= e((string) $week['range']) ?></p>
|
||||
</a>
|
||||
</header>
|
||||
|
||||
<nav class="range-score-strip range-score-strip--week glass-panel" aria-label="Tage dieser Woche">
|
||||
<span class="score-scale score-scale--range" aria-hidden="true"><span>+2</span><span>+1</span><span>0</span><span>-1</span><span>-2</span></span>
|
||||
<?php foreach ($week['days'] as $day): ?>
|
||||
<a class="range-score-day<?= !empty($day['is_current']) ? ' is-current' : '' ?><?= empty($day['has_content']) ? ' is-empty' : '' ?>" href="/?view=week&date=<?= e(rawurlencode((string) $day['date'])) ?>" title="<?= e((string) $day['weekday']) ?>">
|
||||
<span class="compare-day__line<?= !empty($day['is_current']) ? ' is-primary' : '' ?> score-<?= e((string) ($day['score_level'] ?? 'empty')) ?> compare-tone-<?= e((string) ($day['line_tone'] ?? 'empty')) ?>">
|
||||
<span class="compare-day__marker"></span>
|
||||
</span>
|
||||
<span class="range-score-day__label"><?= e((string) $day['day']) ?></span>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</nav>
|
||||
</article>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<?php $weekDetailDays = array_values(array_reverse(array_filter($dashboardWeek['days'], static fn (array $day): bool => !empty($day['has_content'])))); ?>
|
||||
<?php if ($weekDetailDays !== []): ?>
|
||||
<div class="range-day-list">
|
||||
<?php foreach ($weekDetailDays as $day): ?>
|
||||
<?php
|
||||
$entry = is_array($day['entry'] ?? null) ? $day['entry'] : null;
|
||||
$events = $entry !== null && is_array($entry['events'] ?? null) ? $entry['events'] : [];
|
||||
$summaryText = $entry !== null ? trim((string) ($entry['summary']['comment'] ?? $entry['summary_comment'] ?? $entry['note'] ?? '')) : '';
|
||||
$dayTone = (string) ($day['line_tone'] ?? 'empty');
|
||||
$dayImage = $entry !== null && is_string($entry['background_image_url'] ?? null) ? (string) $entry['background_image_url'] : null;
|
||||
?>
|
||||
<a class="range-day-card range-day-card--<?= e($dayTone) ?><?= empty($day['has_content']) ? ' is-empty' : '' ?> glass-panel" href="/?view=day&date=<?= e(rawurlencode((string) $day['date'])) ?>">
|
||||
<?php if ($dayImage !== null): ?>
|
||||
<img class="range-day-card__image" src="<?= e($dayImage) ?>" alt="">
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="range-day-card__body">
|
||||
<p class="eyebrow"><?= e((string) $day['weekday']) ?></p>
|
||||
<p class="range-day-card__score">Bilanz <?= e($formatBalanceValue($entry)) ?></p>
|
||||
<p class="range-day-card__summary"><?= $summaryText !== '' ? e($summaryText) : 'Noch keine Tagesbilanz.' ?></p>
|
||||
|
||||
<?php if ($events !== []): ?>
|
||||
<ul class="range-moment-list">
|
||||
<?php foreach ($events as $event): ?>
|
||||
<?php if (!is_array($event)) { continue; } ?>
|
||||
<?php
|
||||
$eventType = (string) ($event['type'] ?? 'event');
|
||||
$eventScore = signal_combo_score($event['mood'] ?? 0, $event['energy'] ?? 0, $event['stress'] ?? 0);
|
||||
$eventTone = signal_value_class($eventScore);
|
||||
$sportType = $eventType === 'sport' ? find_sport_type($settings, (string) ($event['sport_type_id'] ?? '')) : null;
|
||||
$eventValue = (float) ($event['value'] ?? 0);
|
||||
$eventValueText = $eventValue > 0 ? ($eventType === 'sleep' ? format_duration_hours($eventValue) : rtrim(rtrim(number_format($eventValue, 2, ',', '.'), '0'), ',') . ' ' . (string) ($event['unit'] ?? '')) : '';
|
||||
$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' && trim((string) ($event['comment'] ?? '')) === '') {
|
||||
$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&date=<?= e(rawurlencode((string) ($month['key'] ?? $dashboardDate))) ?>">
|
||||
<h3><?= e((string) $month['title']) ?></h3>
|
||||
</a>
|
||||
</header>
|
||||
|
||||
<nav class="range-score-strip range-score-strip--month glass-panel" aria-label="Tage dieses Monats">
|
||||
<span class="score-scale score-scale--range score-scale--month" aria-hidden="true"><span>+2</span><span>+1</span><span>0</span><span>-1</span><span>-2</span></span>
|
||||
<?php foreach ($month['days'] as $day): ?>
|
||||
<a class="range-score-day<?= empty($day['has_content']) ? ' is-empty' : '' ?><?= !empty($day['is_future']) ? ' is-future' : '' ?>" href="/?view=month&date=<?= e(rawurlencode((string) $day['date'])) ?>" title="<?= e((string) $day['weekday']) ?>">
|
||||
<span class="compare-day__line score-<?= e((string) ($day['score_level'] ?? 'empty')) ?> compare-tone-<?= e((string) ($day['line_tone'] ?? 'empty')) ?>">
|
||||
<span class="compare-day__marker"></span>
|
||||
</span>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</nav>
|
||||
</article>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<?php $monthDetailDays = array_values(array_filter($dashboardMonth['days'], static function (array $day): bool {
|
||||
$entry = is_array($day['entry'] ?? null) ? $day['entry'] : null;
|
||||
$summaryText = $entry !== null ? trim((string) ($entry['summary']['comment'] ?? $entry['summary_comment'] ?? $entry['note'] ?? '')) : '';
|
||||
|
||||
return !empty($day['has_content']) || $summaryText !== '';
|
||||
})); ?>
|
||||
<?php $monthDetailDays = array_reverse($monthDetailDays); ?>
|
||||
<?php if ($monthDetailDays !== []): ?>
|
||||
<div class="range-day-list range-day-list--month">
|
||||
<?php foreach ($monthDetailDays as $day): ?>
|
||||
<?php
|
||||
$entry = is_array($day['entry'] ?? null) ? $day['entry'] : null;
|
||||
$summaryText = $entry !== null ? trim((string) ($entry['summary']['comment'] ?? $entry['summary_comment'] ?? $entry['note'] ?? '')) : '';
|
||||
$dayTone = (string) ($day['line_tone'] ?? 'empty');
|
||||
?>
|
||||
<a class="range-day-card range-day-card--summary-only range-day-card--<?= e($dayTone) ?> glass-panel" href="/?view=day&date=<?= e(rawurlencode((string) $day['date'])) ?>">
|
||||
<div class="range-day-card__body">
|
||||
<p class="eyebrow"><?= e((string) $day['weekday']) ?></p>
|
||||
<p class="range-day-card__score">Bilanz <?= e($formatBalanceValue($entry)) ?></p>
|
||||
<p class="range-day-card__summary"><?= $summaryText !== '' ? e($summaryText) : 'Tagesbilanz' ?></p>
|
||||
</div>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="dashboard-overlay" data-settings-menu-overlay hidden>
|
||||
<div class="dashboard-overlay__backdrop" data-settings-menu-close></div>
|
||||
<section class="dashboard-modal dashboard-modal--settings glass-panel" role="dialog" aria-modal="true">
|
||||
<div class="dashboard-modal__controls">
|
||||
<button class="dashboard-modal__round" type="button" data-settings-menu-close>×</button>
|
||||
</div>
|
||||
|
||||
<h2 class="dashboard-modal__title">Einstellungen und Bereiche</h2>
|
||||
<div class="settings-menu-grid">
|
||||
<a class="options-menu-card" href="/options?panel=sports"><strong>Sportarten anpassen</strong><span>Eigene Sportarten und Bonuspunkte</span></a>
|
||||
<a class="options-menu-card" href="/options?panel=walk"><strong>Spaziergang anpassen</strong><span>Zeit oder Schritte auswerten</span></a>
|
||||
<a class="options-menu-card" href="/options?panel=sleep"><strong>Schlaf anpassen</strong><span>Optimale Schlafmenge</span></a>
|
||||
<a class="options-menu-card" href="/options?panel=health"><strong>Health Import</strong><span>Apple Health automatisch übernehmen</span></a>
|
||||
<a class="options-menu-card" href="/options?panel=reminders"><strong>Erinnerungen setzen</strong><span>Push und tägliche Erinnerung</span></a>
|
||||
<a class="options-menu-card" href="/options?panel=ratings"><strong>Bewertungsskala ändern</strong><span>Labels und Schutzregeln</span></a>
|
||||
<a class="options-menu-card" href="/options?panel=stats"><strong>Statistik</strong><span>Verlauf und Aktivität</span></a>
|
||||
<?php if (!empty($authUser['is_admin'])): ?>
|
||||
<a class="options-menu-card" href="/options?panel=users"><strong>Neue Nutzer anlegen</strong><span>Accounts und Adminrechte</span></a>
|
||||
<?php endif; ?>
|
||||
<form method="post" action="/logout" class="options-logout-form">
|
||||
<?= csrf_field() ?>
|
||||
<button class="options-menu-card options-menu-card--danger" type="submit"><strong>Abmelden</strong><span>Sitzung sicher beenden</span></button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="media-lightbox" data-media-lightbox hidden>
|
||||
<button class="media-lightbox__backdrop" type="button" data-media-lightbox-close aria-label="Ansicht schließen"></button>
|
||||
<div class="media-lightbox__panel" role="dialog" aria-modal="true" aria-label="Medienansicht">
|
||||
<button class="media-lightbox__close" type="button" data-media-lightbox-close aria-label="Ansicht schließen">×</button>
|
||||
<div class="media-lightbox__content" data-media-lightbox-content></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
+232
-374
@@ -1,429 +1,275 @@
|
||||
<section class="page-grid">
|
||||
<article class="glass-panel form-panel form-panel--wide">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<p class="eyebrow">Dein Account</p>
|
||||
<h3>Score und Sportarten persönlich anpassen</h3>
|
||||
<p class="helper-text">Alle Einstellungen auf dieser Seite gelten nur für deinen eigenen Account und beeinflussen keine anderen Nutzer.</p>
|
||||
</div>
|
||||
<span class="chart-chip">Maximal <?= e(format_points((float) $maxScore)) ?> Punkte</span>
|
||||
<section class="options-shell">
|
||||
<div class="options-overlay" data-options-overlay data-options-standalone="1" data-open-panel="<?= e((string) ($optionsOpenPanel ?? '')) ?>">
|
||||
<div class="options-overlay__backdrop" data-options-close></div>
|
||||
<section class="options-modal glass-panel" role="dialog" aria-modal="true">
|
||||
<div class="options-modal__controls">
|
||||
<button class="dashboard-modal__round" type="button" data-options-back>‹</button>
|
||||
<button class="dashboard-modal__round" type="button" data-options-close>×</button>
|
||||
</div>
|
||||
|
||||
<div class="options-menu-panel" data-options-menu>
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<p class="eyebrow">Optionen</p>
|
||||
<h3>Einstellungen und Bereiche</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="options-menu-grid">
|
||||
<button class="options-menu-card" type="button" data-options-open="sports"><strong>Sportarten anpassen</strong><span>Eigene Sportarten und Bonuspunkte</span></button>
|
||||
<button class="options-menu-card" type="button" data-options-open="walk"><strong>Spaziergang anpassen</strong><span>Zeit oder Schritte auswerten</span></button>
|
||||
<button class="options-menu-card" type="button" data-options-open="sleep"><strong>Schlaf anpassen</strong><span>Optimale Schlafmenge markieren</span></button>
|
||||
<button class="options-menu-card" type="button" data-options-open="reminders"><strong>Erinnerungen setzen</strong><span>Push und tägliche Erinnerung</span></button>
|
||||
<button class="options-menu-card" type="button" data-options-open="health"><strong>Health Import</strong><span>Apple Health automatisch übernehmen</span></button>
|
||||
<button class="options-menu-card" type="button" data-options-open="ratings"><strong>Bewertungsskala ändern</strong><span>Labels und Schutzregeln</span></button>
|
||||
<button class="options-menu-card" type="button" data-options-open="stats"><strong>Statistik</strong><span>Verlauf und Aktivität</span></button>
|
||||
<?php if (!empty($authUser['is_admin'])): ?>
|
||||
<button class="options-menu-card" type="button" data-options-open="users"><strong>Neue Nutzer anlegen</strong><span>Accounts und Adminrechte</span></button>
|
||||
<?php endif; ?>
|
||||
<button class="options-menu-card" type="button" data-options-open="security"><strong>Sicherheit</strong><span>Passwort und Backup</span></button>
|
||||
<?php if (!empty($authUser['is_admin'])): ?>
|
||||
<button class="options-menu-card" type="button" data-options-open="ai"><strong>KI</strong><span>OpenAI und Zusammenfassungen</span></button>
|
||||
<?php endif; ?>
|
||||
<form method="post" action="/logout" class="options-logout-form">
|
||||
<?= csrf_field() ?>
|
||||
<button class="options-menu-card options-menu-card--danger" type="submit"><strong>Abmelden</strong><span>Sitzung sicher beenden</span></button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="options-panel" data-options-panel="sports" hidden>
|
||||
<h2>Sportarten anpassen</h2>
|
||||
<form method="post" action="/options" class="stack-form stack-form--spacious">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="form_name" value="settings">
|
||||
|
||||
<div class="settings-section">
|
||||
<h4>Multiplikatoren</h4>
|
||||
<div class="field-grid field-grid--four">
|
||||
<label><span>Stimmung</span><input type="number" name="settings[scoring][mood_multiplier]" value="<?= e((string) $settings['scoring']['mood_multiplier']) ?>" min="0" max="10"></label>
|
||||
<label><span>Energie</span><input type="number" name="settings[scoring][energy_multiplier]" value="<?= e((string) $settings['scoring']['energy_multiplier']) ?>" min="0" max="10"></label>
|
||||
<label><span>Stress</span><input type="number" name="settings[scoring][stress_multiplier]" value="<?= e((string) $settings['scoring']['stress_multiplier']) ?>" min="0" max="10"></label>
|
||||
<label><span>Schlafgefühl</span><input type="number" name="settings[scoring][sleep_feeling_multiplier]" value="<?= e((string) $settings['scoring']['sleep_feeling_multiplier']) ?>" min="0" max="10"></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<div class="section-head section-head--compact">
|
||||
<div>
|
||||
<h4>Tracking-Felder</h4>
|
||||
<p class="helper-text">Hier steuerst du, welche Zusatzfelder auf deiner Tracking-Seite sichtbar und in die Bewertung einbezogen werden.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-grid field-grid--two">
|
||||
<label class="checkbox-row checkbox-row--panel">
|
||||
<input type="checkbox" name="settings[tracking][pain_enabled]" value="1" <?= !empty($settings['tracking']['pain_enabled']) ? 'checked' : '' ?>>
|
||||
<span>
|
||||
<strong>Schmerzen aktivieren</strong>
|
||||
<small>1 bedeutet keine Schmerzen, 10 starke Schmerzen. Der Wert wird wie bei Stress positiv gewichtet, wenn die Schmerzen niedrig sind.</small>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Schmerzfaktor</span>
|
||||
<input type="number" name="settings[scoring][pain_multiplier]" value="<?= e((string) $settings['scoring']['pain_multiplier']) ?>" min="0" max="10">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h4>Schlafdauerpunkte</h4>
|
||||
<div class="field-grid field-grid--four">
|
||||
<?php foreach ($settings['scoring']['sleep_duration_points'] as $key => $value): ?>
|
||||
<label>
|
||||
<span><?= e($key) ?></span>
|
||||
<input type="number" name="settings[scoring][sleep_duration_points][<?= e($key) ?>]" value="<?= e((string) $value) ?>" min="0" max="20">
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h4>Sport-Bänder</h4>
|
||||
<div class="band-grid">
|
||||
<?php foreach ($settings['scoring']['sport_bands'] as $index => $band): ?>
|
||||
<div class="band-card">
|
||||
<label><span>Min</span><input type="number" name="settings[scoring][sport_bands][<?= e((string) $index) ?>][min]" value="<?= e((string) $band['min']) ?>"></label>
|
||||
<label><span>Max</span><input type="number" name="settings[scoring][sport_bands][<?= e((string) $index) ?>][max]" value="<?= e((string) $band['max']) ?>"></label>
|
||||
<label><span>Punkte</span><input type="number" name="settings[scoring][sport_bands][<?= e((string) $index) ?>][points]" value="<?= e((string) $band['points']) ?>"></label>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<div class="section-head section-head--compact">
|
||||
<div>
|
||||
<h4>Spaziergang</h4>
|
||||
<p class="helper-text">Du kannst Spaziergänge pro Account entweder über Zeit oder über Schritte bewerten lassen.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
<span>Spaziergang auswerten nach</span>
|
||||
<select name="settings[walk][mode]">
|
||||
<?php foreach ($walkModeOptions as $modeValue => $modeLabel): ?>
|
||||
<option value="<?= e($modeValue) ?>" <?= ($settings['walk']['mode'] ?? 'time') === $modeValue ? 'selected' : '' ?>><?= e($modeLabel) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<?php if (($settings['walk']['mode'] ?? 'time') === 'steps'): ?>
|
||||
<div class="band-card">
|
||||
<h5>Schritte mit Bestwert bei 10.000</h5>
|
||||
<p class="helper-text">Bei Schritten liegt der beste Wert bei 10.000. Darunter steigt die Punktzahl schrittweise an, darüber fällt sie wieder sanft ab.</p>
|
||||
<p class="helper-text">Aktueller Verlauf: 0 / 3.000 / 5.000 / 7.500 / 10.000 / 12.500 / 15.000 / 20.000 Schritte</p>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="band-grid">
|
||||
<?php foreach ($settings['scoring']['walk_bands'] as $index => $band): ?>
|
||||
<div class="band-card">
|
||||
<label><span>Min</span><input type="number" name="settings[scoring][walk_bands][<?= e((string) $index) ?>][min]" value="<?= e((string) $band['min']) ?>"></label>
|
||||
<label><span>Max</span><input type="number" name="settings[scoring][walk_bands][<?= e((string) $index) ?>][max]" value="<?= e((string) $band['max']) ?>"></label>
|
||||
<label><span>Punkte</span><input type="number" name="settings[scoring][walk_bands][<?= e((string) $index) ?>][points]" value="<?= e((string) $band['points']) ?>"></label>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<div class="section-head section-head--compact">
|
||||
<div>
|
||||
<h4>Sportarten und Bonuspunkte</h4>
|
||||
<p class="helper-text">Lege fest, welche Sportarten nur in deinem eigenen Tracking auswählbar sind. Entfernte Sportarten kannst du darunter jederzeit wieder für deinen Account hinzufügen.</p>
|
||||
<p class="helper-text">Diese Sportarten stehen in deinen Momenten zur Auswahl.</p>
|
||||
</div>
|
||||
<button class="ghost-button" type="button" data-add-sport-type>Sportart hinzufügen</button>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="settings[sport_types_present]" value="1">
|
||||
|
||||
<?php if (!empty($sportTypePresets)): ?>
|
||||
<div class="preset-list">
|
||||
<?php foreach ($sportTypePresets as $preset): ?>
|
||||
<button
|
||||
class="preset-pill"
|
||||
type="button"
|
||||
data-sport-preset
|
||||
data-id="<?= e($preset['id']) ?>"
|
||||
data-label="<?= e($preset['label']) ?>"
|
||||
data-icon="<?= e($preset['icon']) ?>"
|
||||
data-location="<?= e($preset['location'] ?? '') ?>"
|
||||
data-recovery-group="<?= e($preset['recovery_group']) ?>"
|
||||
data-bonus-points="<?= e((string) $preset['bonus_points']) ?>"
|
||||
data-allow-consecutive="<?= !empty($preset['allow_consecutive']) ? '1' : '0' ?>"
|
||||
>
|
||||
<img src="<?= e(sport_icon_path($preset['icon'])) ?>" alt="">
|
||||
<span><?= e($preset['label']) ?></span>
|
||||
<button class="preset-pill" type="button" data-sport-preset data-id="<?= e($preset['id']) ?>" data-label="<?= e($preset['label']) ?>" data-icon="<?= e($preset['icon']) ?>" data-location="<?= e($preset['location'] ?? '') ?>" data-recovery-group="<?= e($preset['recovery_group']) ?>" data-bonus-points="<?= e((string) $preset['bonus_points']) ?>" data-allow-consecutive="<?= !empty($preset['allow_consecutive']) ? '1' : '0' ?>">
|
||||
<img src="<?= e(sport_icon_path($preset['icon'])) ?>" alt=""><span><?= e($preset['label']) ?></span>
|
||||
</button>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="sport-type-list" data-sport-type-list>
|
||||
<?php foreach ($settings['sport_types'] as $index => $sportType): ?>
|
||||
<div class="sport-type-card band-card" data-sport-type-row>
|
||||
<input type="hidden" name="settings[sport_types][<?= e((string) $index) ?>][id]" value="<?= e($sportType['id']) ?>" data-name-template="settings[sport_types][__INDEX__][id]">
|
||||
|
||||
<div class="field-grid field-grid--four">
|
||||
<label>
|
||||
<span>Bezeichnung</span>
|
||||
<input type="text" name="settings[sport_types][<?= e((string) $index) ?>][label]" value="<?= e($sportType['label']) ?>" data-name-template="settings[sport_types][__INDEX__][label]">
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Icon</span>
|
||||
<select name="settings[sport_types][<?= e((string) $index) ?>][icon]" data-name-template="settings[sport_types][__INDEX__][icon]">
|
||||
<?php foreach (sport_icon_options() as $iconValue => $iconLabel): ?>
|
||||
<option value="<?= e($iconValue) ?>" <?= $sportType['icon'] === $iconValue ? 'selected' : '' ?>><?= e($iconLabel) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Ort</span>
|
||||
<select name="settings[sport_types][<?= e((string) $index) ?>][location]" data-name-template="settings[sport_types][__INDEX__][location]">
|
||||
<?php foreach ($sportLocationOptions as $locationValue => $locationLabel): ?>
|
||||
<option value="<?= e($locationValue) ?>" <?= ($sportType['location'] ?? '') === $locationValue ? 'selected' : '' ?>><?= e($locationLabel) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Erholungsgruppe optional</span>
|
||||
<input type="text" name="settings[sport_types][<?= e((string) $index) ?>][recovery_group]" value="<?= e($sportType['recovery_group']) ?>" data-name-template="settings[sport_types][__INDEX__][recovery_group]">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="field-grid field-grid--four">
|
||||
<label>
|
||||
<span>Bonuspunkte</span>
|
||||
<input type="number" name="settings[sport_types][<?= e((string) $index) ?>][bonus_points]" value="<?= e((string) $sportType['bonus_points']) ?>" min="0" max="20" data-name-template="settings[sport_types][__INDEX__][bonus_points]">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="checkbox-row">
|
||||
<input type="checkbox" name="settings[sport_types][<?= e((string) $index) ?>][allow_consecutive]" value="1" <?= !empty($sportType['allow_consecutive']) ? 'checked' : '' ?> data-name-template="settings[sport_types][__INDEX__][allow_consecutive]">
|
||||
<span>Darf an aufeinanderfolgenden Tagen Bonus geben</span>
|
||||
</label>
|
||||
|
||||
<p class="helper-text">Die Erholungsgruppe brauchst du nur, wenn mehrere Varianten dieselbe Belastung teilen sollen, zum Beispiel Krafttraining Zuhause und Krafttraining Auswärts. Wenn du nichts Besonderes einträgst, reicht die Sportart selbst.</p>
|
||||
|
||||
<div class="sport-type-card__actions">
|
||||
<span class="sport-pill sport-pill--soft">
|
||||
<img src="<?= e(sport_icon_path($sportType['icon'])) ?>" alt="">
|
||||
<span><?= e($sportType['label']) ?><?= !empty($sportType['location']) ? ' · ' . e(sport_location_label((string) $sportType['location'])) : '' ?></span>
|
||||
</span>
|
||||
<button class="ghost-button ghost-button--small" type="button" data-remove-sport-type>Entfernen</button>
|
||||
<label><span>Bezeichnung</span><input type="text" name="settings[sport_types][<?= e((string) $index) ?>][label]" value="<?= e($sportType['label']) ?>" data-name-template="settings[sport_types][__INDEX__][label]"></label>
|
||||
<label><span>Icon</span><select name="settings[sport_types][<?= e((string) $index) ?>][icon]" data-name-template="settings[sport_types][__INDEX__][icon]"><?php foreach (sport_icon_options() as $iconValue => $iconLabel): ?><option value="<?= e($iconValue) ?>" <?= $sportType['icon'] === $iconValue ? 'selected' : '' ?>><?= e($iconLabel) ?></option><?php endforeach; ?></select></label>
|
||||
<label><span>Ort</span><select name="settings[sport_types][<?= e((string) $index) ?>][location]" data-name-template="settings[sport_types][__INDEX__][location]"><?php foreach ($sportLocationOptions as $locationValue => $locationLabel): ?><option value="<?= e($locationValue) ?>" <?= ($sportType['location'] ?? '') === $locationValue ? 'selected' : '' ?>><?= e($locationLabel) ?></option><?php endforeach; ?></select></label>
|
||||
<label><span>Erholungsgruppe</span><input type="text" name="settings[sport_types][<?= e((string) $index) ?>][recovery_group]" value="<?= e($sportType['recovery_group']) ?>" data-name-template="settings[sport_types][__INDEX__][recovery_group]"></label>
|
||||
</div>
|
||||
<div class="field-grid field-grid--four"><label><span>Bonuspunkte</span><input type="number" name="settings[sport_types][<?= e((string) $index) ?>][bonus_points]" value="<?= e((string) $sportType['bonus_points']) ?>" min="0" max="20" data-name-template="settings[sport_types][__INDEX__][bonus_points]"></label></div>
|
||||
<label class="checkbox-row"><input type="checkbox" name="settings[sport_types][<?= e((string) $index) ?>][allow_consecutive]" value="1" <?= !empty($sportType['allow_consecutive']) ? 'checked' : '' ?> data-name-template="settings[sport_types][__INDEX__][allow_consecutive]"><span>Darf an Folgetagen Bonus geben</span></label>
|
||||
<div class="sport-type-card__actions"><button class="ghost-button ghost-button--small" type="button" data-remove-sport-type>Entfernen</button></div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<div class="section-actions">
|
||||
<button class="primary-button" type="submit">Sportarten speichern</button>
|
||||
</div>
|
||||
|
||||
<template id="sport-type-row-template">
|
||||
<div class="sport-type-card band-card" data-sport-type-row>
|
||||
<input type="hidden" value="" data-name-template="settings[sport_types][__INDEX__][id]">
|
||||
|
||||
<div class="field-grid field-grid--four">
|
||||
<label>
|
||||
<span>Bezeichnung</span>
|
||||
<input type="text" value="" placeholder="z. B. Krafttraining Zuhause" data-name-template="settings[sport_types][__INDEX__][label]">
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Icon</span>
|
||||
<select data-name-template="settings[sport_types][__INDEX__][icon]">
|
||||
<?php foreach (sport_icon_options() as $iconValue => $iconLabel): ?>
|
||||
<option value="<?= e($iconValue) ?>"><?= e($iconLabel) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Ort</span>
|
||||
<select data-name-template="settings[sport_types][__INDEX__][location]">
|
||||
<?php foreach ($sportLocationOptions as $locationValue => $locationLabel): ?>
|
||||
<option value="<?= e($locationValue) ?>"><?= e($locationLabel) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Erholungsgruppe optional</span>
|
||||
<input type="text" value="" placeholder="z. B. krafttraining" data-name-template="settings[sport_types][__INDEX__][recovery_group]">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="field-grid field-grid--four">
|
||||
<label>
|
||||
<span>Bonuspunkte</span>
|
||||
<input type="number" value="2" min="0" max="20" data-name-template="settings[sport_types][__INDEX__][bonus_points]">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="checkbox-row">
|
||||
<input type="checkbox" value="1" data-name-template="settings[sport_types][__INDEX__][allow_consecutive]">
|
||||
<span>Darf an aufeinanderfolgenden Tagen Bonus geben</span>
|
||||
</label>
|
||||
|
||||
<p class="helper-text">Die Erholungsgruppe ist nur dann nützlich, wenn mehrere Varianten sportlich gleich belastend sind und denselben Bonusrhythmus teilen sollen.</p>
|
||||
|
||||
<div class="sport-type-card__actions">
|
||||
<span class="sport-pill sport-pill--soft">
|
||||
<img src="<?= e(sport_icon_path('run')) ?>" alt="">
|
||||
<span>Neue Sportart</span>
|
||||
</span>
|
||||
<button class="ghost-button ghost-button--small" type="button" data-remove-sport-type>Entfernen</button>
|
||||
<label><span>Bezeichnung</span><input type="text" value="" data-name-template="settings[sport_types][__INDEX__][label]"></label>
|
||||
<label><span>Icon</span><select data-name-template="settings[sport_types][__INDEX__][icon]"><?php foreach (sport_icon_options() as $iconValue => $iconLabel): ?><option value="<?= e($iconValue) ?>"><?= e($iconLabel) ?></option><?php endforeach; ?></select></label>
|
||||
<label><span>Ort</span><select data-name-template="settings[sport_types][__INDEX__][location]"><?php foreach ($sportLocationOptions as $locationValue => $locationLabel): ?><option value="<?= e($locationValue) ?>"><?= e($locationLabel) ?></option><?php endforeach; ?></select></label>
|
||||
<label><span>Erholungsgruppe</span><input type="text" value="" data-name-template="settings[sport_types][__INDEX__][recovery_group]"></label>
|
||||
</div>
|
||||
<div class="field-grid field-grid--four"><label><span>Bonuspunkte</span><input type="number" value="2" min="0" max="20" data-name-template="settings[sport_types][__INDEX__][bonus_points]"></label></div>
|
||||
<label class="checkbox-row"><input type="checkbox" value="1" data-name-template="settings[sport_types][__INDEX__][allow_consecutive]"><span>Darf an Folgetagen Bonus geben</span></label>
|
||||
<div class="sport-type-card__actions"><button class="ghost-button ghost-button--small" type="button" data-remove-sport-type>Entfernen</button></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<button class="primary-button" type="submit">Sportarten speichern</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="options-panel" data-options-panel="walk" hidden>
|
||||
<h2>Spaziergang und Schritte</h2>
|
||||
<form method="post" action="/options" class="stack-form">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="form_name" value="settings">
|
||||
<p class="helper-text">Spaziergänge werden als Momente angezeigt. Punkte kommen nicht mehr aus einzelnen Spaziergängen, sondern aus der täglichen Gesamtschrittzahl.</p>
|
||||
<label><span>Spaziergang anzeigen nach</span><select name="settings[walk][mode]"><?php foreach ($walkModeOptions as $modeValue => $modeLabel): ?><option value="<?= e($modeValue) ?>" <?= ($settings['walk']['mode'] ?? 'time') === $modeValue ? 'selected' : '' ?>><?= e($modeLabel) ?></option><?php endforeach; ?></select></label>
|
||||
<div class="settings-section">
|
||||
<div class="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>
|
||||
<h4>Schritte-Bonus</h4>
|
||||
<div class="field-grid field-grid--three">
|
||||
<label><span>Mehr als</span><input type="number" name="settings[scoring][step_bonus][min]" value="<?= e((string) ($settings['scoring']['step_bonus']['min'] ?? 10000)) ?>" min="0" max="100000"></label>
|
||||
<label><span>Bis einschließlich</span><input type="number" name="settings[scoring][step_bonus][max]" value="<?= e((string) ($settings['scoring']['step_bonus']['max'] ?? 15000)) ?>" min="0" max="100000"></label>
|
||||
<label><span>Bonuspunkte</span><input type="number" name="settings[scoring][step_bonus][points]" value="<?= e((string) ($settings['scoring']['step_bonus']['points'] ?? 1)) ?>" min="0" max="20"></label>
|
||||
</div>
|
||||
<span class="chart-chip"><?= e((string) $pushSubscriptionCount) ?> Gerät<?= $pushSubscriptionCount === 1 ? '' : 'e' ?></span>
|
||||
</div>
|
||||
<div class="settings-section">
|
||||
<h4>Schritte-Zielkurve</h4>
|
||||
<p class="helper-text">Diese Punkte fließen als kleiner Bonus oder Malus in die Tagesbilanz ein. Positive Werte motivieren, negative Werte markieren zu wenig oder zu viel Bewegung.</p>
|
||||
<div class="band-grid">
|
||||
<?php foreach (($settings['scoring']['walk_step_targets'] ?? []) as $index => $target): ?>
|
||||
<div class="band-card">
|
||||
<label><span>Schritte</span><input type="number" name="settings[scoring][walk_step_targets][<?= e((string) $index) ?>][steps]" value="<?= e((string) ($target['steps'] ?? 0)) ?>" min="0" max="100000"></label>
|
||||
<label><span>Punkte</span><input type="number" name="settings[scoring][walk_step_targets][<?= e((string) $index) ?>][points]" value="<?= e((string) ($target['points'] ?? 0)) ?>" min="-20" max="20"></label>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
<button class="primary-button" type="submit">Schritte speichern</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="options-panel" data-options-panel="sleep" hidden>
|
||||
<h2>Schlaf anpassen</h2>
|
||||
<form method="post" action="/options" class="stack-form">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="form_name" value="settings">
|
||||
<p class="helper-text">Diese Zielmenge wird im importierten Schlafbalken als horizontale Markierung angezeigt und fließt in die automatische Stimmung/Energie/Stress-Einschätzung ein.</p>
|
||||
<label><span>Optimale Schlafdauer</span><input type="number" name="settings[sleep][optimal_hours]" value="<?= e((string) ($settings['sleep']['optimal_hours'] ?? 7.0)) ?>" min="1" max="16" step="0.1"></label>
|
||||
<button class="primary-button" type="submit">Schlaf speichern</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="options-panel" data-options-panel="reminders" hidden>
|
||||
<h2>Erinnerungen setzen</h2>
|
||||
<form method="post" action="/options" class="stack-form">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="form_name" value="settings">
|
||||
<div class="field-grid field-grid--two">
|
||||
<label class="checkbox-row checkbox-row--panel">
|
||||
<input type="checkbox" name="settings[notifications][enabled]" value="1" <?= !empty($settings['notifications']['enabled']) ? 'checked' : '' ?>>
|
||||
<span>Tägliche Push-Erinnerung aktivieren</span>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Uhrzeit der Erinnerung</span>
|
||||
<input type="time" name="settings[notifications][time]" value="<?= e((string) ($settings['notifications']['time'] ?? '20:30')) ?>">
|
||||
</label>
|
||||
<label class="checkbox-row checkbox-row--panel"><input type="checkbox" name="settings[notifications][enabled]" value="1" <?= !empty($settings['notifications']['enabled']) ? 'checked' : '' ?>><span>Tägliche Push-Erinnerung aktivieren</span></label>
|
||||
<label><span>Uhrzeit der Erinnerung</span><input type="time" name="settings[notifications][time]" value="<?= e((string) ($settings['notifications']['time'] ?? '20:30')) ?>"></label>
|
||||
</div>
|
||||
<div class="push-panel band-card" data-push-panel data-push-ready="<?= !empty($pushAvailable) && !empty($pushPublicKey) ? '1' : '0' ?>">
|
||||
<div><h5>Push auf diesem Gerät</h5><p class="helper-text" data-push-status><?php if (!empty($pushAvailable) && !empty($pushPublicKey)): ?>Installiere die App auf Wunsch als PWA und aktiviere dann Push direkt auf diesem Gerät.<?php else: ?>Push ist auf diesem Server gerade noch nicht verfügbar.<?php endif; ?></p></div>
|
||||
<div class="push-actions"><button class="ghost-button" type="button" data-push-enable <?= empty($pushAvailable) || empty($pushPublicKey) ? 'disabled' : '' ?>>Push aktivieren</button><button class="ghost-button" type="button" data-push-disable <?= empty($pushAvailable) || empty($pushPublicKey) ? 'disabled' : '' ?>>Auf diesem Gerät entfernen</button><button class="ghost-button" type="button" data-push-test <?= empty($pushAvailable) || empty($pushPublicKey) ? 'disabled' : '' ?>>Test senden</button></div>
|
||||
</div>
|
||||
<button class="primary-button" type="submit">Erinnerungen speichern</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="push-panel band-card"
|
||||
data-push-panel
|
||||
data-push-ready="<?= !empty($pushAvailable) && !empty($pushPublicKey) ? '1' : '0' ?>"
|
||||
>
|
||||
<div>
|
||||
<h5>Push auf diesem Gerät</h5>
|
||||
<p class="helper-text" data-push-status>
|
||||
<?php if (!empty($pushAvailable) && !empty($pushPublicKey)): ?>
|
||||
Installiere die App auf Wunsch als PWA und aktiviere dann Push direkt auf diesem Gerät.
|
||||
<div class="options-panel" data-options-panel="health" hidden>
|
||||
<h2>Health Import</h2>
|
||||
<article class="detail-card detail-card--overlay">
|
||||
<p class="eyebrow">REST-Endpunkt</p>
|
||||
<div class="stack-form">
|
||||
<label><span>URL in Health Auto Export</span><input type="text" value="<?= e((string) $healthImportUrl) ?>" readonly></label>
|
||||
<label><span>HTTP-Header</span><input type="text" value="Authorization: Bearer <?= !empty($healthImportConfig['token_prefix']) ? e((string) $healthImportConfig['token_prefix']) . '…' : 'TOKEN' ?>" readonly></label>
|
||||
</div>
|
||||
<p class="helper-text">Lege in Health Auto Export zwei REST-API-Automationen an: Gesundheitsmetriken mit <code>step_count</code> und <code>sleep_analysis</code>, sowie Workouts mit JSON Version 2 und Routendaten.</p>
|
||||
</article>
|
||||
|
||||
<?php if (!empty($healthImportToken)): ?>
|
||||
<article class="detail-card detail-card--overlay health-token-card">
|
||||
<p class="eyebrow">Neuer Token</p>
|
||||
<label><span>Nur jetzt sichtbar</span><input type="text" value="<?= e((string) $healthImportToken) ?>" readonly></label>
|
||||
<p class="helper-text">Kopiere diesen Token als Bearer-Token in Health Auto Export. Danach wird nur noch der Anfang angezeigt.</p>
|
||||
</article>
|
||||
<?php endif; ?>
|
||||
|
||||
<article class="detail-card detail-card--overlay" data-health-import-status>
|
||||
<p class="eyebrow">Status</p>
|
||||
<div class="health-import-progress" data-health-progress-wrap data-progress-done="<?= e((string) ($healthImportConfig['progress_done'] ?? 0)) ?>" data-progress-total="<?= e((string) ($healthImportConfig['progress_total'] ?? 0)) ?>">
|
||||
<progress class="health-import-progress__bar" data-health-progress-bar max="100" value="0">0%</progress>
|
||||
<p class="helper-text" data-health-progress-text>
|
||||
<?php if (($healthImportConfig['last_status'] ?? '') === 'running'): ?>
|
||||
Import läuft: <?= e((string) ($healthImportConfig['progress_done'] ?? 0)) ?> von <?= e((string) ($healthImportConfig['progress_total'] ?? 0)) ?> verarbeitet.
|
||||
<?php elseif (!empty($healthImportConfig['last_message'])): ?>
|
||||
<?= e((string) $healthImportConfig['last_message']) ?>
|
||||
<?php else: ?>
|
||||
Push ist auf diesem Server gerade noch nicht verfügbar.
|
||||
Noch kein Import gelaufen.
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="push-actions">
|
||||
<button class="ghost-button" type="button" data-push-enable <?= empty($pushAvailable) || empty($pushPublicKey) ? 'disabled' : '' ?>>Push aktivieren</button>
|
||||
<button class="ghost-button" type="button" data-push-disable <?= empty($pushAvailable) || empty($pushPublicKey) ? 'disabled' : '' ?>>Auf diesem Gerät entfernen</button>
|
||||
<button class="ghost-button" type="button" data-push-test <?= empty($pushAvailable) || empty($pushPublicKey) ? 'disabled' : '' ?>>Test senden</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-actions">
|
||||
<button class="primary-button" type="submit">Erinnerungen speichern</button>
|
||||
<div class="user-list">
|
||||
<div class="user-row"><strong>Token</strong><span data-health-token-state><?= !empty($healthImportConfig['enabled']) ? 'aktiv' : 'nicht eingerichtet' ?></span></div>
|
||||
<?php if (!empty($healthImportConfig['last_import_at'])): ?>
|
||||
<div class="user-row"><strong>Letzter Import</strong><span data-health-last-import><?= e(format_display_datetime((string) $healthImportConfig['last_import_at'])) ?></span></div>
|
||||
<?php else: ?>
|
||||
<div class="user-row"><strong>Letzter Import</strong><span data-health-last-import>-</span></div>
|
||||
<?php endif; ?>
|
||||
<div class="user-row"><strong>Statusmeldung</strong><span data-health-last-message><?= !empty($healthImportConfig['last_message']) ? e((string) $healthImportConfig['last_message']) : '-' ?></span></div>
|
||||
</div>
|
||||
<form method="post" action="/options" class="stack-form">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="form_name" value="health_import_token">
|
||||
<button class="primary-button" type="submit"><?= !empty($healthImportConfig['enabled']) ? 'Token neu erstellen' : 'Token erstellen' ?></button>
|
||||
</form>
|
||||
<?php if (!empty($healthImportConfig['enabled'])): ?>
|
||||
<form method="post" action="/options" class="stack-form">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="form_name" value="health_import_revoke">
|
||||
<button class="ghost-button" type="submit">Token deaktivieren</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="options-panel" data-options-panel="ratings" hidden>
|
||||
<h2>Bewertungsskala ändern</h2>
|
||||
<form method="post" action="/options" class="stack-form stack-form--spacious">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="form_name" value="settings">
|
||||
<div class="settings-section">
|
||||
<h4>Bewertungsskala</h4>
|
||||
<p class="helper-text">Diese Score-Grenzen und Schutzregeln gelten ebenfalls nur für deinen eigenen Account.</p>
|
||||
<div class="band-grid">
|
||||
<?php foreach ($settings['ratings'] as $index => $rating): ?>
|
||||
<div class="band-card">
|
||||
<label><span>Label</span><input type="text" name="settings[ratings][<?= e((string) $index) ?>][label]" value="<?= e($rating['label']) ?>"></label>
|
||||
<label><span>Min</span><input type="number" name="settings[ratings][<?= e((string) $index) ?>][min]" value="<?= e((string) $rating['min']) ?>"></label>
|
||||
<label><span>Max</span><input type="number" name="settings[ratings][<?= e((string) $index) ?>][max]" value="<?= e((string) $rating['max']) ?>"></label>
|
||||
<h4>Tagesbilanz als Hauptmetrik</h4>
|
||||
<p class="helper-text">Stimmung, Energie und Stress bilden die Basis. Schlaf, Schritte, Sport, Spaziergang und Notizen verschieben den Tag nur gedeckelt in eine positivere oder negativere Richtung.</p>
|
||||
<div class="field-grid field-grid--three">
|
||||
<label><span>Gewicht Stimmung</span><input type="number" name="settings[day_balance][mood_weight]" value="<?= e((string) ($settings['day_balance']['mood_weight'] ?? 3)) ?>" min="0" max="10"></label>
|
||||
<label><span>Gewicht Energie</span><input type="number" name="settings[day_balance][energy_weight]" value="<?= e((string) ($settings['day_balance']['energy_weight'] ?? 2)) ?>" min="0" max="10"></label>
|
||||
<label><span>Gewicht Stress</span><input type="number" name="settings[day_balance][stress_weight]" value="<?= e((string) ($settings['day_balance']['stress_weight'] ?? 2)) ?>" min="0" max="10"></label>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<div class="field-grid field-grid--two">
|
||||
<label><span>Max. Bonus/Malus in Stufen</span><input type="number" name="settings[day_balance][adjustment_cap]" value="<?= e((string) ($settings['day_balance']['adjustment_cap'] ?? 1.0)) ?>" min="0" max="2" step="0.1"></label>
|
||||
<label><span>Punkte pro Stufenverschiebung</span><input type="number" name="settings[day_balance][points_per_step]" value="<?= e((string) ($settings['day_balance']['points_per_step'] ?? 12)) ?>" min="1" max="50"></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h4>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>
|
||||
|
||||
<div class="settings-section"><h4>Bewertungsskala</h4><div class="band-grid"><?php foreach ($settings['ratings'] as $index => $rating): ?><div class="band-card"><label><span>Label</span><input type="text" name="settings[ratings][<?= e((string) $index) ?>][label]" value="<?= e($rating['label']) ?>"></label><label><span>Min</span><input type="number" name="settings[ratings][<?= e((string) $index) ?>][min]" value="<?= e((string) $rating['min']) ?>"></label><label><span>Max</span><input type="number" name="settings[ratings][<?= e((string) $index) ?>][max]" value="<?= e((string) $rating['max']) ?>"></label></div><?php endforeach; ?></div></div>
|
||||
<div class="settings-section"><h4>Schutzregeln</h4><div class="band-grid"><?php foreach ($settings['guardrails'] as $index => $guardrail): ?><div class="band-card"><label><span>Stimmung max</span><input type="number" name="settings[guardrails][<?= e((string) $index) ?>][mood_max]" value="<?= e((string) $guardrail['mood_max']) ?>" min="1" max="10"></label><label><span>Energie max</span><input type="number" name="settings[guardrails][<?= e((string) $index) ?>][energy_max]" value="<?= e((string) ($guardrail['energy_max'] ?? '')) ?>" min="1" max="10"></label><label><span>Maximales Label</span><input type="text" name="settings[guardrails][<?= e((string) $index) ?>][cap_label]" value="<?= e($guardrail['cap_label']) ?>"></label></div><?php endforeach; ?></div></div>
|
||||
<label><span>Tagebuchpunkte bei nicht-leerer Notiz</span><input type="number" name="settings[scoring][journal_points]" value="<?= e((string) $settings['scoring']['journal_points']) ?>" min="0" max="20"></label>
|
||||
<button class="primary-button" type="submit">Bewertung speichern</button>
|
||||
</form>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<aside class="stack-column">
|
||||
<article class="glass-panel detail-card">
|
||||
<p class="eyebrow">Backup</p>
|
||||
<h3>Eigene Einträge sichern</h3>
|
||||
<p class="helper-text">Deine Tagesdateien liegen auf dem Server verschlüsselt. Beim Download bekommst du automatisch ein lesbares Backup mit allen Tagen als `.txt`-Dateien in einer ZIP-Datei. Beim Import werden diese Dateien wieder still im Hintergrund geschützt gespeichert.</p>
|
||||
|
||||
<form method="post" action="/options" class="stack-form">
|
||||
<div class="options-panel" data-options-panel="stats" hidden>
|
||||
<h2>Statistik</h2>
|
||||
<form method="post" action="/options" class="stack-form stack-form--spacious stats-settings-form">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="form_name" value="export_backup">
|
||||
<button class="primary-button" type="submit" <?= empty($backupAvailable) ? 'disabled' : '' ?>>Backup herunterladen</button>
|
||||
<?php if (empty($backupAvailable)): ?>
|
||||
<p class="helper-text">Auf diesem Server fehlt gerade die ZIP-Erweiterung für den Download.</p>
|
||||
<?php endif; ?>
|
||||
<input type="hidden" name="form_name" value="settings">
|
||||
<div class="settings-section">
|
||||
<h4>Statistik-Darstellung</h4>
|
||||
<label><span>Hauptwert anzeigen als</span><select name="settings[display][score_mode]">
|
||||
<?php foreach (['scale' => '5-Stufen-Bilanz', 'percent' => 'Prozentwert', 'points' => 'Punkte'] as $mode => $label): ?>
|
||||
<option value="<?= e($mode) ?>" <?= ($settings['display']['score_mode'] ?? 'scale') === $mode ? 'selected' : '' ?>><?= e($label) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select></label>
|
||||
</div>
|
||||
<button class="primary-button" type="submit">Statistik speichern</button>
|
||||
</form>
|
||||
|
||||
<form method="post" action="/options" enctype="multipart/form-data" class="stack-form">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="form_name" value="import_backup">
|
||||
<label>
|
||||
<span>Backup importieren</span>
|
||||
<input type="file" name="backup_files[]" accept=".zip,.txt" multiple>
|
||||
</label>
|
||||
<p class="helper-text">Du kannst ein komplettes ZIP-Backup oder einzelne Tagesdateien wie `2026-04-13.txt` importieren. Vorhandene Tage mit demselben Datum werden dabei aktualisiert.</p>
|
||||
<button class="ghost-button" type="submit">Backup importieren</button>
|
||||
</form>
|
||||
</article>
|
||||
|
||||
<article class="glass-panel detail-card">
|
||||
<p class="eyebrow">Sicherheit</p>
|
||||
<h3>Passwort ändern</h3>
|
||||
<form method="post" action="/options" class="stack-form">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="form_name" value="password">
|
||||
<label><span>Aktuelles Passwort</span><input type="password" name="current_password" required></label>
|
||||
<label><span>Neues Passwort</span><input type="password" name="new_password" minlength="10" required></label>
|
||||
<label><span>Neues Passwort wiederholen</span><input type="password" name="new_password_confirm" minlength="10" required></label>
|
||||
<button class="primary-button" type="submit">Passwort aktualisieren</button>
|
||||
</form>
|
||||
</article>
|
||||
<section class="stats-grid">
|
||||
<article class="metric-card glass-panel"><span>Getrackte Tage</span><strong><?= e((string) $statsSummary['tracked_days']) ?></strong></article>
|
||||
<article class="metric-card glass-panel"><span>Ø Score</span><strong><?= e(format_points((float) $statsSummary['average_score'])) ?></strong></article>
|
||||
<article class="metric-card glass-panel"><span>Ø Bilanz</span><strong><?= ((float) $statsSummary['average_balance']) > 0 ? '+' : '' ?><?= e(format_points((float) $statsSummary['average_balance'])) ?></strong></article>
|
||||
<article class="metric-card glass-panel"><span>Ø Stimmung</span><strong><?= e(format_points((float) $statsSummary['average_mood'])) ?>/10</strong></article>
|
||||
<article class="metric-card glass-panel"><span>Ø Stress</span><strong><?= e(format_points((float) $statsSummary['average_stress'])) ?>/10</strong></article>
|
||||
<article class="metric-card glass-panel"><span>Serie</span><strong><?= e((string) $statsSummary['streak']) ?> Tage</strong></article>
|
||||
</section>
|
||||
<section class="dashboard-grid dashboard-grid--embedded-stats">
|
||||
<article class="glass-panel chart-card chart-card--calendar"><div class="section-head"><div><p class="eyebrow">Kalender</p><h3>Gesamtstimmung pro Tag</h3></div></div><div id="calendar-heatmap" class="calendar-heatmap" data-payload="<?= e($statsChartPayload) ?>"></div></article>
|
||||
<article class="glass-panel chart-card"><div class="section-head"><div><p class="eyebrow">Trend</p><h3>Errechnete Tagesbilanz</h3></div></div><div class="line-chart" data-chart-type="line" data-series="balance" data-payload="<?= e($statsChartPayload) ?>"></div></article>
|
||||
<article class="glass-panel chart-card"><div class="section-head"><div><p class="eyebrow">Belastung</p><h3>Stressverlauf</h3></div></div><div class="line-chart" data-chart-type="line" data-series="stress" data-payload="<?= e($statsChartPayload) ?>"></div></article>
|
||||
<article class="glass-panel chart-card chart-card--wide"><div class="section-head"><div><p class="eyebrow">Aktivität</p><h3>Sport und Spaziergang</h3></div></div><div class="bar-chart" data-chart-type="bars" data-series="sport" data-payload="<?= e($statsChartPayload) ?>"></div></article>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($authUser['is_admin'])): ?>
|
||||
<article class="glass-panel detail-card">
|
||||
<p class="eyebrow">KI</p>
|
||||
<h3>OpenAI für Zusammenfassungen</h3>
|
||||
<p class="helper-text">Diese Einstellung gilt zentral für alle Nutzer. Der API-Key bleibt in der Umgebung des Servers und wird hier bewusst nicht gespeichert.</p>
|
||||
|
||||
<?php if (!empty($aiStatus)): ?>
|
||||
<div class="user-list">
|
||||
<div class="user-row">
|
||||
<strong>API-Key</strong>
|
||||
<span><?= !empty($aiStatus['has_api_key']) ? 'vorhanden' : 'fehlt' ?></span>
|
||||
</div>
|
||||
<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>
|
||||
<div class="options-panel" data-options-panel="users" hidden>
|
||||
<h2>Neue Nutzer anlegen</h2>
|
||||
<form method="post" action="/options" class="stack-form">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="form_name" value="create_user">
|
||||
@@ -432,18 +278,30 @@
|
||||
<label class="checkbox-row"><input type="checkbox" name="is_admin" value="1"><span>Als Admin anlegen</span></label>
|
||||
<button class="primary-button" type="submit">Account erstellen</button>
|
||||
</form>
|
||||
<?php if ($users !== []): ?><div class="user-list"><?php foreach ($users as $account): ?><div class="user-row"><strong><?= e($account['username']) ?></strong><span><?= !empty($account['is_admin']) ? 'Admin' : 'Nutzer' ?></span></div><?php endforeach; ?></div><?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?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 class="options-panel" data-options-panel="security" hidden>
|
||||
<h2>Sicherheit</h2>
|
||||
<article class="detail-card detail-card--overlay">
|
||||
<p class="eyebrow">Backup</p>
|
||||
<form method="post" action="/options" class="stack-form"><?= csrf_field() ?><input type="hidden" name="form_name" value="export_backup"><button class="primary-button" type="submit" <?= empty($backupAvailable) ? 'disabled' : '' ?>>Backup herunterladen</button></form>
|
||||
<form method="post" action="/options" enctype="multipart/form-data" class="stack-form"><?= csrf_field() ?><input type="hidden" name="form_name" value="import_backup"><label><span>Backup importieren</span><input type="file" name="backup_files[]" accept=".zip,.txt" multiple></label><button class="ghost-button" type="submit">Backup importieren</button></form>
|
||||
</article>
|
||||
<article class="detail-card detail-card--overlay">
|
||||
<p class="eyebrow">Passwort</p>
|
||||
<form method="post" action="/options" class="stack-form"><?= csrf_field() ?><input type="hidden" name="form_name" value="password"><label><span>Aktuelles Passwort</span><input type="password" name="current_password" required></label><label><span>Neues Passwort</span><input type="password" name="new_password" minlength="10" required></label><label><span>Neues Passwort wiederholen</span><input type="password" name="new_password_confirm" minlength="10" required></label><button class="primary-button" type="submit">Passwort aktualisieren</button></form>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($authUser['is_admin'])): ?>
|
||||
<div class="options-panel" data-options-panel="ai" hidden>
|
||||
<h2>KI</h2>
|
||||
<?php if (!empty($aiStatus)): ?><div class="user-list"><div class="user-row"><strong>API-Key</strong><span><?= !empty($aiStatus['has_api_key']) ? 'vorhanden' : 'fehlt' ?></span></div><div class="user-row"><strong>Aktuelles Modell</strong><span><?= e((string) ($aiStatus['model'] ?? '')) ?></span></div><div class="user-row"><strong>Timeout</strong><span><?= e((string) ($aiStatus['timeout'] ?? '')) ?> s</span></div></div><?php endif; ?>
|
||||
<form method="post" action="/options" class="stack-form"><?= csrf_field() ?><input type="hidden" name="form_name" value="ai_config"><label><span>OpenAI-Modell</span><input type="text" name="ai[model]" value="<?= e((string) ($aiConfig['model'] ?? 'gpt-4o-mini')) ?>" required></label><label><span>Timeout in Sekunden</span><input type="number" name="ai[timeout]" value="<?= e((string) ($aiConfig['timeout'] ?? 25)) ?>" min="5" max="120" required></label><button class="primary-button" type="submit">KI-Konfiguration speichern</button></form>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</aside>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user