feat(dashboard): add immersive day range views
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Projektueberblick
|
||||
|
||||
Mood ist ein dateibasierter Stimmungstracker fuer klassische PHP/LAMP-Deployments ohne Datenbank.
|
||||
Die App rendert serverseitig PHP-Templates und speichert Nutzer-, Einstellungs- und Trackingdaten unter `storage/`.
|
||||
|
||||
## Einstiegspunkte
|
||||
|
||||
- `index.php`: Front-Controller, bootet die App.
|
||||
- `src/bootstrap.php`: laedt Dateien, initialisiert Session und stellt `storage/` sicher.
|
||||
- `src/App.php`: zentrales Routing und Grossteil der Anwendungslogik.
|
||||
|
||||
## Wichtige Struktur
|
||||
|
||||
- `src/Domain/`: dateibasierte Repositories und Fachlogik.
|
||||
- `src/Support/`: Auth, View, Verschluesselung, OpenAI, Web Push.
|
||||
- `templates/layout.php`: globales Layout.
|
||||
- `templates/pages/`: serverseitige Seiten.
|
||||
- `assets/css/app.css`: gesamtes Styling.
|
||||
- `assets/js/app.js`: Frontend-Logik fuer Charts, Formulare, Archiv, Push und PWA.
|
||||
- `storage/system/`: globale Systemdaten wie Nutzer, Throttle, Notifications, Key-Dateien.
|
||||
- `storage/users/<user>/`: Nutzerdaten, Einstellungen, Tage, Zusammenfassungen und Push-Status.
|
||||
|
||||
## Routing
|
||||
|
||||
Die App nutzt keinen Router von aussen. Routen werden direkt in `App::run()` per `switch ($path)` behandelt.
|
||||
|
||||
Wichtige Routen:
|
||||
|
||||
- `/setup`
|
||||
- `/login`
|
||||
- `/logout`
|
||||
- `/`
|
||||
- `/track`
|
||||
- `/archive`
|
||||
- `/options`
|
||||
- `/push/subscribe`
|
||||
- `/push/unsubscribe`
|
||||
- `/push/test`
|
||||
- `/reminders/run`
|
||||
|
||||
## Datenmodell
|
||||
|
||||
- Nutzer stehen in `storage/system/users.json`.
|
||||
- Einstellungen pro Nutzer in `storage/users/<user>/settings.json`.
|
||||
- Tagesdaten in `storage/users/<user>/days/YYYY-MM-DD.txt`.
|
||||
- Wochen- und Monatszusammenfassungen unter `storage/users/<user>/summaries/`.
|
||||
- Push-Subscriptions und Reminder-State liegen ebenfalls pro Nutzer unter `storage/users/<user>/`.
|
||||
|
||||
Tagesdateien und Zusammenfassungen koennen serverseitig verschluesselt gespeichert werden. Die Logik liegt in `src/Support/EntryCrypto.php`.
|
||||
|
||||
## Sicherheitsrelevante Regeln
|
||||
|
||||
- Form-POSTs nutzen CSRF-Token via `csrf_field()` und `App::enforceCsrf()`.
|
||||
- JSON-POSTs nutzen `App::enforceRequestCsrf()`.
|
||||
- Auth-Logik liegt in `src/Support/Auth.php`.
|
||||
- Security-Header werden zentral in `App::sendSecurityHeaders()` gesetzt.
|
||||
- Aendere keine Auth-, Cookie-, CSRF- oder Reminder-Token-Logik leichtfertig.
|
||||
|
||||
## Arbeitsregeln fuer Aenderungen
|
||||
|
||||
- Bevorzuge kleine, lokale Aenderungen. Die App ist bewusst simpel und frameworkfrei.
|
||||
- Ziehe bestehende Hilfsfunktionen in `src/helpers.php` vor, statt neue Utility-Dateien einzufuehren.
|
||||
- Wenn moeglich dem bestehenden Muster folgen: Daten lesen/schreiben in Repositories, Seiten in `App`, Ausgabe in Templates.
|
||||
- Fuehre keine grossen Architekturumbauten ohne konkreten Bedarf ein. `src/App.php` ist zentral und gewollt monolithisch.
|
||||
- Beruehre `storage/` nur, wenn die Aufgabe das wirklich erfordert. Dort koennen echte Nutzerdaten liegen.
|
||||
- Fuehre keine Massenformatierung oder kosmetische Grossumbauten ohne Anlass durch.
|
||||
|
||||
## Frontend-Hinweise
|
||||
|
||||
- Das UI ist servergerendert; JavaScript erweitert nur interaktive Teile.
|
||||
- Neue UI-Logik moeglichst in `assets/js/app.js` integrieren, statt neue Build-Schritte einzufuehren.
|
||||
- Externe CDNs oder Frontend-Frameworks nicht einfuehren.
|
||||
|
||||
## KI- und Push-Integrationen
|
||||
|
||||
- OpenAI-Zusammenfassungen laufen ueber `src/Support/OpenAiSummaryService.php`.
|
||||
- Web Push und VAPID laufen ueber `src/Support/WebPushService.php` und `src/Domain/NotificationRepository.php`.
|
||||
- Bei Aenderungen in diesen Bereichen besonders auf Datenschutz, Fehlerbehandlung und Rueckwaertskompatibilitaet der gespeicherten Daten achten.
|
||||
|
||||
## Lokale Checks
|
||||
|
||||
Es gibt aktuell keine sichtbare Composer- oder PHPUnit-Konfiguration im Projekt.
|
||||
|
||||
Sinnvolle manuelle Checks:
|
||||
|
||||
- PHP-Syntax fuer geaenderte Dateien pruefen: `php -l <datei>`
|
||||
- Setup/Login/Tracken/Archiv/Optionen im Browser kurz durchklicken
|
||||
- Falls Push oder Reminder betroffen sind: relevante Endpunkte gezielt testen
|
||||
|
||||
## Deployment-Annahmen
|
||||
|
||||
- Ziel ist klassisches Apache/LAMP bzw. Cloudron.
|
||||
- `.htaccess` und Schreibrechte auf `storage/` sind wichtig.
|
||||
- Die App erwartet keinen Datenbankserver und keinen JS-Buildprozess.
|
||||
+1261
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 |
+535
-3
@@ -39,6 +39,10 @@
|
||||
return `/assets/icons/mood-${sentiment}.svg`;
|
||||
}
|
||||
|
||||
function dashboardDayPath(date) {
|
||||
return `/?view=day&date=${encodeURIComponent(date)}`;
|
||||
}
|
||||
|
||||
function sportIconPath(icon) {
|
||||
return `/assets/icons/sport-${icon}.svg`;
|
||||
}
|
||||
@@ -705,7 +709,7 @@
|
||||
}
|
||||
|
||||
return `
|
||||
<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>
|
||||
@@ -730,7 +734,7 @@
|
||||
<span data-calendar-score>${formatNumber(Number(latestVisibleEntry.score))}</span>
|
||||
<small>Punkte</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 +777,7 @@
|
||||
detailDate.textContent = formatDateLabel(entry.date);
|
||||
detailLabel.textContent = entry.label;
|
||||
detailScore.textContent = formatNumber(Number(entry.score));
|
||||
detailLink.href = `/track?date=${encodeURIComponent(entry.date)}`;
|
||||
detailLink.href = dashboardDayPath(entry.date);
|
||||
|
||||
container.querySelectorAll(".calendar-cell--selected").forEach(cell => {
|
||||
cell.classList.remove("calendar-cell--selected");
|
||||
@@ -971,6 +975,532 @@
|
||||
syncPresets();
|
||||
}
|
||||
|
||||
function initDashboardExperience() {
|
||||
const summaryOverlay = document.querySelector("[data-summary-overlay]");
|
||||
const openSummary = document.querySelector("[data-summary-overlay-open]");
|
||||
const closeSummary = [...document.querySelectorAll("[data-summary-overlay-close]")];
|
||||
const momentOverlay = document.querySelector("[data-moment-overlay]");
|
||||
const settingsMenuOverlay = document.querySelector("[data-settings-menu-overlay]");
|
||||
const openSettingsMenu = document.querySelector("[data-settings-menu-open]");
|
||||
const closeSettingsMenu = [...document.querySelectorAll("[data-settings-menu-close]")];
|
||||
const openMoment = document.querySelector("[data-moment-overlay-open]");
|
||||
const closeMoment = [...document.querySelectorAll("[data-moment-overlay-close]")];
|
||||
const chooseStep = document.querySelector('[data-moment-step="choose"]');
|
||||
const formStep = document.querySelector('[data-moment-step="form"]');
|
||||
const momentSubmit = document.querySelector("[data-moment-submit]");
|
||||
const typeInput = document.querySelector("[data-moment-type-input]");
|
||||
const formNameInput = document.querySelector("[data-moment-form-name]");
|
||||
const eventIdInput = document.querySelector("[data-moment-event-id]");
|
||||
const typeLabel = document.querySelector("[data-moment-type-label]");
|
||||
const valueField = document.querySelector("[data-moment-value-field]");
|
||||
const valueLabel = document.querySelector("[data-moment-value-label]");
|
||||
const valueInput = document.querySelector("[data-moment-value-input]");
|
||||
const walkField = document.querySelector("[data-moment-walk-field]");
|
||||
const walkModeInput = document.querySelector("[data-walk-mode-input]");
|
||||
const sportField = document.querySelector("[data-moment-sport-field]");
|
||||
const alcoholField = document.querySelector("[data-moment-alcohol-field]");
|
||||
const momentComment = document.querySelector("[data-moment-comment]");
|
||||
const backButton = document.querySelector("[data-moment-back]");
|
||||
const deleteForm = document.querySelector("[data-moment-delete-form]");
|
||||
const deleteIdInput = document.querySelector("[data-moment-delete-id]");
|
||||
const typeSelect = document.querySelector("[data-event-type-select]");
|
||||
const unitInput = document.querySelector("[data-event-unit]");
|
||||
const swipeContainer = document.querySelector("[data-day-swipe]");
|
||||
const periodRail = document.querySelector(".range-period-rail");
|
||||
|
||||
const walkMode = document.body.dataset.walkMode || "time";
|
||||
const walkUnit = walkMode === "steps" ? "steps" : "min";
|
||||
|
||||
const hoistOverlay = overlay => {
|
||||
if (!(overlay instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (overlay.parentElement !== document.body) {
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
};
|
||||
|
||||
hoistOverlay(summaryOverlay);
|
||||
hoistOverlay(momentOverlay);
|
||||
hoistOverlay(settingsMenuOverlay);
|
||||
|
||||
if (periodRail instanceof HTMLElement) {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const view = params.get("view") || "day";
|
||||
const storageKey = `mood:${view}:period-scroll`;
|
||||
const storedScroll = window.sessionStorage.getItem(storageKey);
|
||||
|
||||
if (storedScroll !== null) {
|
||||
periodRail.scrollLeft = Number(storedScroll) || 0;
|
||||
}
|
||||
|
||||
periodRail.addEventListener("click", event => {
|
||||
const link = event.target.closest("a[href]");
|
||||
if (!link) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.sessionStorage.setItem(storageKey, String(periodRail.scrollLeft));
|
||||
});
|
||||
}
|
||||
|
||||
const stepperConfigs = {
|
||||
event: { label: "Ereignis", valueLabel: "Wert", unit: "", placeholder: "optional", showValue: false, showSport: false, showAlcohol: false, commentPlaceholder: "Was hast du erlebt?" },
|
||||
walk: { label: "Spaziergang", valueLabel: "Spaziergang", unit: walkUnit, placeholder: walkMode === "steps" ? "Schritte" : "Minuten", showValue: true, showSport: false, showAlcohol: false, showWalk: true, commentPlaceholder: "Was war dabei besonders?" },
|
||||
sport: { label: "Sport", valueLabel: "Dauer", unit: "min", placeholder: "Minuten", showValue: true, showSport: true, showAlcohol: false, commentPlaceholder: "Was hast du gemacht?" },
|
||||
sleep: { label: "Schlaf", valueLabel: "Dauer", unit: "h", placeholder: "Stunden", showValue: true, showSport: false, showAlcohol: false, commentPlaceholder: "Wie war der Schlaf?" },
|
||||
alcohol: { label: "Alkohol", valueLabel: "", unit: "", placeholder: "", showValue: false, showSport: false, showAlcohol: true, commentPlaceholder: "Optionaler Kommentar" },
|
||||
};
|
||||
|
||||
const toneClass = (value, metric = "mood") => {
|
||||
const current = Math.max(-2, Math.min(2, Number(value || 0)));
|
||||
if (metric === "stress") {
|
||||
if (current <= -2) return "tone-pos2";
|
||||
if (current === -1) return "tone-pos1";
|
||||
if (current === 1) return "tone-neg1";
|
||||
if (current >= 2) return "tone-neg2";
|
||||
return "tone-zero";
|
||||
}
|
||||
if (current <= -2) return "tone-neg2";
|
||||
if (current === -1) return "tone-neg1";
|
||||
if (current === 1) return "tone-pos1";
|
||||
if (current >= 2) return "tone-pos2";
|
||||
return "tone-zero";
|
||||
};
|
||||
|
||||
const setHidden = (element, hidden) => {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hidden) {
|
||||
element.setAttribute("hidden", "hidden");
|
||||
} else {
|
||||
element.removeAttribute("hidden");
|
||||
}
|
||||
};
|
||||
|
||||
const setOverlay = (overlay, open) => {
|
||||
if (!overlay) {
|
||||
return;
|
||||
}
|
||||
|
||||
setHidden(overlay, !open);
|
||||
document.body.classList.toggle("is-dashboard-overlay-open", open);
|
||||
|
||||
if (open) {
|
||||
const focusTarget = overlay.querySelector("input, textarea, select, button");
|
||||
if (focusTarget instanceof HTMLElement) {
|
||||
window.setTimeout(() => focusTarget.focus(), 10);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.querySelectorAll("[data-stepper]").forEach(stepper => {
|
||||
const input = stepper.querySelector("[data-stepper-input]");
|
||||
const value = stepper.querySelector("[data-stepper-value]");
|
||||
const minus = stepper.querySelector("[data-stepper-minus]");
|
||||
const plus = stepper.querySelector("[data-stepper-plus]");
|
||||
|
||||
if (!input || !value || !minus || !plus) {
|
||||
return;
|
||||
}
|
||||
|
||||
const render = () => {
|
||||
const current = Math.max(-2, Math.min(2, Number(input.value || 0)));
|
||||
const metric = stepper.dataset.stepperMetric || "mood";
|
||||
input.value = String(current);
|
||||
value.textContent = `${current > 0 ? "+" : ""}${current}`;
|
||||
minus.disabled = current <= -2;
|
||||
plus.disabled = current >= 2;
|
||||
const ring = stepper.querySelector(".overlay-signal-card__ring");
|
||||
if (ring) {
|
||||
ring.classList.remove("tone-neg2", "tone-neg1", "tone-zero", "tone-pos1", "tone-pos2");
|
||||
ring.classList.add(toneClass(current, metric));
|
||||
}
|
||||
};
|
||||
|
||||
minus.addEventListener("click", () => {
|
||||
input.value = String(Number(input.value || 0) - 1);
|
||||
render();
|
||||
});
|
||||
|
||||
plus.addEventListener("click", () => {
|
||||
input.value = String(Number(input.value || 0) + 1);
|
||||
render();
|
||||
});
|
||||
|
||||
render();
|
||||
});
|
||||
|
||||
if (openSummary) {
|
||||
openSummary.addEventListener("click", event => {
|
||||
event.preventDefault();
|
||||
setOverlay(summaryOverlay, true);
|
||||
});
|
||||
}
|
||||
|
||||
closeSummary.forEach(button => {
|
||||
button.addEventListener("click", event => {
|
||||
event.preventDefault();
|
||||
setOverlay(summaryOverlay, false);
|
||||
});
|
||||
});
|
||||
|
||||
if (openSettingsMenu) {
|
||||
openSettingsMenu.addEventListener("click", event => {
|
||||
event.preventDefault();
|
||||
setOverlay(settingsMenuOverlay, true);
|
||||
});
|
||||
}
|
||||
|
||||
closeSettingsMenu.forEach(button => {
|
||||
button.addEventListener("click", event => {
|
||||
event.preventDefault();
|
||||
setOverlay(settingsMenuOverlay, false);
|
||||
});
|
||||
});
|
||||
|
||||
const setMomentEditMode = payload => {
|
||||
if (!formNameInput || !eventIdInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload) {
|
||||
formNameInput.value = "update_event";
|
||||
eventIdInput.value = payload.id || "";
|
||||
if (deleteForm && deleteIdInput) {
|
||||
deleteIdInput.value = payload.id || "";
|
||||
setHidden(deleteForm, false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
formNameInput.value = "add_event";
|
||||
eventIdInput.value = "";
|
||||
if (deleteForm && deleteIdInput) {
|
||||
deleteIdInput.value = "";
|
||||
setHidden(deleteForm, true);
|
||||
}
|
||||
};
|
||||
|
||||
const showMomentChoose = () => {
|
||||
setMomentEditMode(null);
|
||||
setHidden(chooseStep, false);
|
||||
setHidden(formStep, true);
|
||||
document.querySelectorAll("[data-sport-choice]").forEach(item => item.classList.remove("is-selected"));
|
||||
if (momentSubmit) {
|
||||
momentSubmit.disabled = true;
|
||||
}
|
||||
};
|
||||
|
||||
const showMomentForm = type => {
|
||||
const config = stepperConfigs[type] || stepperConfigs.event;
|
||||
if (typeInput) typeInput.value = type;
|
||||
if (typeLabel) typeLabel.textContent = config.label;
|
||||
if (valueLabel) valueLabel.textContent = config.valueLabel || "Wert";
|
||||
if (valueInput) {
|
||||
valueInput.placeholder = config.placeholder;
|
||||
valueInput.required = !!config.showValue;
|
||||
valueInput.value = config.showValue ? valueInput.value : "";
|
||||
valueInput.step = type === "sleep" ? "0.25" : "1";
|
||||
}
|
||||
if (unitInput) {
|
||||
unitInput.value = config.unit;
|
||||
}
|
||||
if (walkModeInput) {
|
||||
walkModeInput.value = walkMode;
|
||||
}
|
||||
if (momentComment) {
|
||||
momentComment.placeholder = config.commentPlaceholder;
|
||||
momentComment.required = type !== "alcohol";
|
||||
}
|
||||
|
||||
if (sportField) setHidden(sportField, !config.showSport);
|
||||
if (alcoholField) setHidden(alcoholField, !config.showAlcohol);
|
||||
if (valueField) setHidden(valueField, !config.showValue);
|
||||
if (walkField) setHidden(walkField, !config.showWalk);
|
||||
if (momentSubmit) {
|
||||
momentSubmit.disabled = false;
|
||||
}
|
||||
|
||||
if (config.showWalk) {
|
||||
document.querySelectorAll('input[name="event_walk_mode"]').forEach(radio => {
|
||||
radio.checked = radio.value === walkMode;
|
||||
});
|
||||
}
|
||||
|
||||
setHidden(chooseStep, true);
|
||||
setHidden(formStep, false);
|
||||
};
|
||||
|
||||
const populateMomentForm = payload => {
|
||||
showMomentForm(payload.type || "event");
|
||||
setMomentEditMode(payload);
|
||||
|
||||
const form = document.querySelector("#moment-form");
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
|
||||
const setValue = (selector, value) => {
|
||||
const field = form.querySelector(selector);
|
||||
if (field) {
|
||||
field.value = value;
|
||||
}
|
||||
};
|
||||
|
||||
setValue('input[name="event_time"]', payload.time || "");
|
||||
setValue('[name="event_comment"]', payload.comment || "");
|
||||
setValue('[name="event_value"]', payload.value || "");
|
||||
setValue('[name="event_sport_type_id"]', payload.sport_type_id || "");
|
||||
setValue('[name="event_unit"]', payload.unit || "");
|
||||
setValue('[name="event_walk_mode"]', payload.unit === "steps" ? "steps" : "time");
|
||||
setValue('[name="event_mood"]', payload.mood ?? 0);
|
||||
setValue('[name="event_energy"]', payload.energy ?? 0);
|
||||
setValue('[name="event_stress"]', payload.stress ?? 0);
|
||||
|
||||
const consumedYes = form.querySelector('input[name="event_consumed"][value="1"]');
|
||||
const consumedNo = form.querySelector('input[name="event_consumed"][value="0"]');
|
||||
if (consumedYes && consumedNo) {
|
||||
if (payload.consumed) {
|
||||
consumedYes.checked = true;
|
||||
} else {
|
||||
consumedNo.checked = true;
|
||||
}
|
||||
}
|
||||
|
||||
form.querySelectorAll('input[name="event_walk_mode"]').forEach(radio => {
|
||||
radio.checked = radio.value === (payload.unit === "steps" ? "steps" : "time");
|
||||
});
|
||||
|
||||
form.querySelectorAll("[data-stepper]").forEach(stepper => {
|
||||
const input = stepper.querySelector("[data-stepper-input]");
|
||||
const value = stepper.querySelector("[data-stepper-value]");
|
||||
const minus = stepper.querySelector("[data-stepper-minus]");
|
||||
const plus = stepper.querySelector("[data-stepper-plus]");
|
||||
if (!input || !value || !minus || !plus) return;
|
||||
const current = Math.max(-2, Math.min(2, Number(input.value || 0)));
|
||||
const metric = stepper.dataset.stepperMetric || "mood";
|
||||
input.value = String(current);
|
||||
value.textContent = `${current > 0 ? "+" : ""}${current}`;
|
||||
minus.disabled = current <= -2;
|
||||
plus.disabled = current >= 2;
|
||||
const ring = stepper.querySelector(".overlay-signal-card__ring");
|
||||
if (ring) {
|
||||
ring.classList.remove("tone-neg2", "tone-neg1", "tone-zero", "tone-pos1", "tone-pos2");
|
||||
ring.classList.add(toneClass(current, metric));
|
||||
}
|
||||
});
|
||||
|
||||
form.querySelectorAll("[data-sport-choice]").forEach(button => {
|
||||
button.classList.toggle("is-selected", button.dataset.sportChoice === (payload.sport_type_id || ""));
|
||||
});
|
||||
};
|
||||
|
||||
if (openMoment) {
|
||||
openMoment.addEventListener("click", event => {
|
||||
event.preventDefault();
|
||||
showMomentChoose();
|
||||
setOverlay(momentOverlay, true);
|
||||
});
|
||||
}
|
||||
|
||||
closeMoment.forEach(button => {
|
||||
button.addEventListener("click", event => {
|
||||
event.preventDefault();
|
||||
setOverlay(momentOverlay, false);
|
||||
showMomentChoose();
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-moment-type-choice]").forEach(button => {
|
||||
button.addEventListener("click", event => {
|
||||
event.preventDefault();
|
||||
showMomentForm(button.dataset.momentTypeChoice || "event");
|
||||
});
|
||||
});
|
||||
|
||||
if (backButton) {
|
||||
backButton.addEventListener("click", event => {
|
||||
event.preventDefault();
|
||||
showMomentChoose();
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll("[data-sport-choice]").forEach(button => {
|
||||
button.addEventListener("click", event => {
|
||||
event.preventDefault();
|
||||
const hidden = document.querySelector('input[name="event_sport_type_id"]');
|
||||
if (!hidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
hidden.value = button.dataset.sportChoice || "";
|
||||
document.querySelectorAll("[data-sport-choice]").forEach(item => item.classList.remove("is-selected"));
|
||||
button.classList.add("is-selected");
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('input[name="event_walk_mode"]').forEach(radio => {
|
||||
radio.addEventListener("change", () => {
|
||||
if (!valueInput || !valueLabel || !unitInput || !walkModeInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mode = radio.checked ? radio.value : walkModeInput.value;
|
||||
walkModeInput.value = mode;
|
||||
unitInput.value = mode === "steps" ? "steps" : "min";
|
||||
valueLabel.textContent = mode === "steps" ? "Schritte" : "Dauer";
|
||||
valueInput.placeholder = mode === "steps" ? "Schritte" : "Minuten";
|
||||
valueInput.step = "1";
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-confirm-delete]").forEach(button => {
|
||||
button.addEventListener("click", event => {
|
||||
if (!window.confirm("Diesen Moment wirklich löschen?")) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-event-editable]").forEach(card => {
|
||||
card.addEventListener("click", event => {
|
||||
if (event.target.closest("form") || event.target.closest("button")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = decodePayload(card.dataset.eventPayload || "");
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
populateMomentForm(payload);
|
||||
setOverlay(momentOverlay, true);
|
||||
});
|
||||
});
|
||||
|
||||
if (typeSelect && unitInput) {
|
||||
const syncUnit = () => {
|
||||
const option = typeSelect.options[typeSelect.selectedIndex];
|
||||
unitInput.value = option?.dataset.defaultUnit || "";
|
||||
};
|
||||
|
||||
syncUnit();
|
||||
typeSelect.addEventListener("change", syncUnit);
|
||||
}
|
||||
|
||||
if (swipeContainer) {
|
||||
let pointerStartX = 0;
|
||||
let pointerStartY = 0;
|
||||
let dragging = false;
|
||||
|
||||
const handleSwipe = (deltaX, deltaY) => {
|
||||
if (Math.abs(deltaX) < 70 || Math.abs(deltaY) > 50) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (deltaX < 0 && swipeContainer.dataset.nextDate) {
|
||||
window.location.href = dashboardDayPath(swipeContainer.dataset.nextDate);
|
||||
} else if (deltaX > 0 && swipeContainer.dataset.prevDate) {
|
||||
window.location.href = dashboardDayPath(swipeContainer.dataset.prevDate);
|
||||
}
|
||||
};
|
||||
|
||||
swipeContainer.addEventListener("pointerdown", event => {
|
||||
dragging = true;
|
||||
pointerStartX = event.clientX;
|
||||
pointerStartY = event.clientY;
|
||||
});
|
||||
|
||||
swipeContainer.addEventListener("pointerup", event => {
|
||||
if (!dragging) {
|
||||
return;
|
||||
}
|
||||
|
||||
dragging = false;
|
||||
handleSwipe(event.clientX - pointerStartX, event.clientY - pointerStartY);
|
||||
});
|
||||
|
||||
swipeContainer.addEventListener("pointercancel", () => {
|
||||
dragging = false;
|
||||
});
|
||||
|
||||
window.addEventListener("keydown", event => {
|
||||
if (document.body.classList.contains("is-dashboard-overlay-open")) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowLeft" && swipeContainer.dataset.prevDate) {
|
||||
window.location.href = dashboardDayPath(swipeContainer.dataset.prevDate);
|
||||
}
|
||||
|
||||
if (event.key === "ArrowRight" && swipeContainer.dataset.nextDate) {
|
||||
window.location.href = dashboardDayPath(swipeContainer.dataset.nextDate);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function initOptionsPanels() {
|
||||
const overlay = document.querySelector("[data-options-overlay]");
|
||||
if (!overlay) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (overlay.parentElement !== document.body) {
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
const panels = [...overlay.querySelectorAll("[data-options-panel]")];
|
||||
const closeButtons = [...overlay.querySelectorAll("[data-options-close]")];
|
||||
const backButtons = [...overlay.querySelectorAll("[data-options-back]")];
|
||||
const initialPanel = overlay.dataset.openPanel || null;
|
||||
|
||||
const setOpen = (panelName) => {
|
||||
overlay.hidden = panelName === null;
|
||||
document.body.classList.toggle("is-dashboard-overlay-open", panelName !== null);
|
||||
|
||||
panels.forEach(panel => {
|
||||
panel.hidden = panel.dataset.optionsPanel !== panelName;
|
||||
});
|
||||
|
||||
if (panelName === "stats") {
|
||||
window.setTimeout(() => {
|
||||
initDashboardCharts();
|
||||
}, 40);
|
||||
}
|
||||
};
|
||||
|
||||
document.querySelectorAll("[data-options-open]").forEach(button => {
|
||||
button.addEventListener("click", event => {
|
||||
event.preventDefault();
|
||||
setOpen(button.dataset.optionsOpen || null);
|
||||
});
|
||||
});
|
||||
|
||||
closeButtons.forEach(button => {
|
||||
button.addEventListener("click", event => {
|
||||
event.preventDefault();
|
||||
setOpen(null);
|
||||
});
|
||||
});
|
||||
|
||||
backButtons.forEach(button => {
|
||||
button.addEventListener("click", event => {
|
||||
event.preventDefault();
|
||||
setOpen(null);
|
||||
});
|
||||
});
|
||||
|
||||
if (initialPanel) {
|
||||
setOpen(initialPanel);
|
||||
}
|
||||
}
|
||||
|
||||
function csrfToken() {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") || "";
|
||||
}
|
||||
@@ -1300,6 +1830,8 @@
|
||||
initTrackPreview();
|
||||
initArchiveMobileDetail();
|
||||
initDashboardCharts();
|
||||
initDashboardExperience();
|
||||
initOptionsPanels();
|
||||
initSportTypeManager();
|
||||
initPwaShell();
|
||||
initPullToRefresh();
|
||||
|
||||
+573
-2
@@ -87,7 +87,11 @@ final class App
|
||||
redirect('/login');
|
||||
|
||||
case '/':
|
||||
$this->showDashboard();
|
||||
$method === 'POST' ? $this->handleDashboard() : $this->showDashboard();
|
||||
return;
|
||||
|
||||
case '/day-image':
|
||||
$this->serveDayImage();
|
||||
return;
|
||||
|
||||
case '/track':
|
||||
@@ -232,21 +236,584 @@ final class App
|
||||
$settings = $this->hydrateSettings($this->settings->forUser($user['username']));
|
||||
$entries = $this->entries->all($user['username']);
|
||||
$evaluatedEntries = $this->evaluateEntriesWithContext($entries, $settings);
|
||||
$evaluatedEntries = array_map(
|
||||
fn (array $entry): array => $this->withDashboardImageState($user['username'], $entry),
|
||||
$evaluatedEntries
|
||||
);
|
||||
$dashboardView = $this->normalizeDashboardView((string) ($_GET['view'] ?? 'day'));
|
||||
$dashboardDate = (string) ($_GET['date'] ?? today());
|
||||
|
||||
if (!$this->isValidDate($dashboardDate)) {
|
||||
$dashboardDate = today();
|
||||
}
|
||||
|
||||
$entryMap = [];
|
||||
foreach ($evaluatedEntries as $entry) {
|
||||
$entryMap[(string) ($entry['date'] ?? '')] = $entry;
|
||||
}
|
||||
|
||||
$selectedEntry = $entryMap[$dashboardDate] ?? $this->withDashboardImageState($user['username'], $this->buildEmptyDashboardEntry($dashboardDate, $settings, $entryMap[shift_date($dashboardDate, -1)] ?? null));
|
||||
|
||||
$summary = $this->buildDashboardSummary($evaluatedEntries);
|
||||
$chartData = $this->buildDashboardCharts($evaluatedEntries);
|
||||
|
||||
View::render('dashboard', [
|
||||
'pageTitle' => 'Dashboard',
|
||||
'pageTitle' => 'Mood',
|
||||
'page' => 'dashboard',
|
||||
'pageBodyClass' => 'page-dashboard-immersive',
|
||||
'authUser' => $user,
|
||||
'settings' => $settings,
|
||||
'summary' => $summary,
|
||||
'entries' => array_reverse($evaluatedEntries),
|
||||
'chartPayload' => encode_payload($chartData),
|
||||
'dashboardView' => $dashboardView,
|
||||
'dashboardDate' => $dashboardDate,
|
||||
'dayEntry' => $selectedEntry,
|
||||
'dashboardEventTypes' => day_event_type_options(),
|
||||
'dashboardSignals' => signal_scale_options(),
|
||||
'dashboardTimeline' => $this->buildDashboardTimeline($selectedEntry),
|
||||
'dashboardCompareDays' => $this->buildDashboardCompareDays($dashboardDate, $entryMap, $settings),
|
||||
'dashboardWeek' => $this->buildDashboardWeekView($dashboardDate, $entryMap),
|
||||
'dashboardMonth' => $this->buildDashboardMonthView($dashboardDate, $entryMap),
|
||||
'dashboardPrevDate' => shift_date($dashboardDate, -1),
|
||||
'dashboardNextDate' => shift_date($dashboardDate, 1),
|
||||
'dashboardSportTypes' => normalized_sport_types($settings),
|
||||
'dashboardWalkMode' => (string) ($settings['walk']['mode'] ?? 'time'),
|
||||
'dashboardHasContent' => $this->entryHasContent($selectedEntry, isset($entryMap[$dashboardDate])),
|
||||
'dashboardVisualScore' => $this->dashboardVisualScore($selectedEntry, isset($entryMap[$dashboardDate])),
|
||||
'dashboardLineLevel' => $this->dashboardLineLevel($selectedEntry, isset($entryMap[$dashboardDate])),
|
||||
'dashboardLineTone' => $this->dashboardLineTone($selectedEntry, isset($entryMap[$dashboardDate])),
|
||||
]);
|
||||
}
|
||||
|
||||
private function handleDashboard(): void
|
||||
{
|
||||
$this->enforceCsrf();
|
||||
|
||||
$user = $this->requireUser();
|
||||
$settings = $this->hydrateSettings($this->settings->forUser($user['username']));
|
||||
$form = (string) ($_POST['form_name'] ?? '');
|
||||
$date = (string) ($_POST['date'] ?? today());
|
||||
|
||||
if (!$this->isValidDate($date)) {
|
||||
flash('error', 'Bitte wähle einen gültigen Tag.');
|
||||
redirect('/');
|
||||
}
|
||||
|
||||
$entries = $this->entries->all($user['username']);
|
||||
$entryMap = [];
|
||||
foreach ($entries as $existingEntry) {
|
||||
$entryMap[(string) ($existingEntry['date'] ?? '')] = $existingEntry;
|
||||
}
|
||||
|
||||
$current = $entryMap[$date] ?? $this->buildEmptyDashboardEntry($date, $settings, $entryMap[shift_date($date, -1)] ?? null);
|
||||
|
||||
try {
|
||||
if ($form === 'save_day_summary') {
|
||||
$current['summary'] = [
|
||||
'comment' => trim((string) ($_POST['summary_comment'] ?? '')),
|
||||
'mood' => normalize_signal_value($_POST['summary_mood'] ?? 0),
|
||||
'energy' => normalize_signal_value($_POST['summary_energy'] ?? 0),
|
||||
'stress' => normalize_signal_value($_POST['summary_stress'] ?? 0),
|
||||
'alcohol' => isset($_POST['summary_alcohol']) && (string) $_POST['summary_alcohol'] === '1',
|
||||
];
|
||||
$current['summary_comment'] = $current['summary']['comment'];
|
||||
$current['summary_mood'] = $current['summary']['mood'];
|
||||
$current['summary_energy'] = $current['summary']['energy'];
|
||||
$current['summary_stress'] = $current['summary']['stress'];
|
||||
$current['summary_alcohol'] = !empty($current['summary']['alcohol']);
|
||||
$current['note'] = $current['summary']['comment'];
|
||||
$current['alcohol'] = !empty($current['summary']['alcohol']);
|
||||
|
||||
$upload = uploaded_files('background_image')[0] ?? null;
|
||||
if (is_array($upload) && (int) ($upload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) {
|
||||
$this->deleteDashboardBackgroundImage($user['username'], $date, (string) ($current['background_image'] ?? ''));
|
||||
$current['background_image'] = $this->storeDashboardBackgroundImage($user['username'], $date, $upload);
|
||||
}
|
||||
|
||||
$entryMap[$date] = $current;
|
||||
$this->persistUserEntries($user['username'], $settings, array_values($entryMap));
|
||||
flash('success', 'Die Tagesbilanz wurde gespeichert.');
|
||||
redirect('/?view=day&date=' . rawurlencode($date));
|
||||
}
|
||||
|
||||
if ($form === 'add_event') {
|
||||
$event = $this->dashboardEventFromPost($_POST);
|
||||
$events = is_array($current['events'] ?? null) ? $current['events'] : [];
|
||||
$events[] = $event;
|
||||
$current['events'] = $events;
|
||||
$entryMap[$date] = $current;
|
||||
$this->persistUserEntries($user['username'], $settings, array_values($entryMap));
|
||||
flash('success', 'Die Aktivität wurde hinzugefügt.');
|
||||
redirect('/?view=day&date=' . rawurlencode($date));
|
||||
}
|
||||
|
||||
if ($form === 'update_event') {
|
||||
$eventID = trim((string) ($_POST['event_id'] ?? ''));
|
||||
$updatedEvent = $this->dashboardEventFromPost($_POST);
|
||||
$updatedEvent['id'] = $eventID !== '' ? $eventID : $updatedEvent['id'];
|
||||
$events = [];
|
||||
|
||||
foreach (is_array($current['events'] ?? null) ? $current['events'] : [] as $event) {
|
||||
if (!is_array($event)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((string) ($event['id'] ?? '') === $eventID) {
|
||||
$events[] = $updatedEvent;
|
||||
continue;
|
||||
}
|
||||
|
||||
$events[] = $event;
|
||||
}
|
||||
|
||||
$current['events'] = $events;
|
||||
$entryMap[$date] = $current;
|
||||
$this->persistUserEntries($user['username'], $settings, array_values($entryMap));
|
||||
flash('success', 'Der Moment wurde aktualisiert.');
|
||||
redirect('/?view=day&date=' . rawurlencode($date));
|
||||
}
|
||||
|
||||
if ($form === 'delete_event') {
|
||||
$eventID = trim((string) ($_POST['event_id'] ?? ''));
|
||||
$current['events'] = array_values(array_filter(
|
||||
is_array($current['events'] ?? null) ? $current['events'] : [],
|
||||
static fn (array $event): bool => (string) ($event['id'] ?? '') !== $eventID
|
||||
));
|
||||
$entryMap[$date] = $current;
|
||||
$this->persistUserEntries($user['username'], $settings, array_values($entryMap));
|
||||
flash('success', 'Die Aktivität wurde entfernt.');
|
||||
redirect('/?view=day&date=' . rawurlencode($date));
|
||||
}
|
||||
|
||||
if ($form === 'remove_background') {
|
||||
$this->deleteDashboardBackgroundImage($user['username'], $date, (string) ($current['background_image'] ?? ''));
|
||||
$current['background_image'] = '';
|
||||
$entryMap[$date] = $current;
|
||||
$this->persistUserEntries($user['username'], $settings, array_values($entryMap));
|
||||
flash('success', 'Das Tagesbild wurde entfernt.');
|
||||
redirect('/?view=day&date=' . rawurlencode($date));
|
||||
}
|
||||
} catch (RuntimeException $exception) {
|
||||
flash('error', $exception->getMessage());
|
||||
redirect('/?view=day&date=' . rawurlencode($date));
|
||||
}
|
||||
|
||||
redirect('/?view=day&date=' . rawurlencode($date));
|
||||
}
|
||||
|
||||
private function normalizeDashboardView(string $view): string
|
||||
{
|
||||
return in_array($view, ['day', 'week', 'month'], true) ? $view : 'day';
|
||||
}
|
||||
|
||||
private function buildEmptyDashboardEntry(string $date, array $settings, ?array $previousEntry = null): array
|
||||
{
|
||||
$entry = $this->scoring->normalize([
|
||||
'date' => $date,
|
||||
'pain_enabled' => !empty($settings['tracking']['pain_enabled']),
|
||||
'walk_mode' => (string) ($settings['walk']['mode'] ?? 'time'),
|
||||
'summary' => [
|
||||
'comment' => '',
|
||||
'mood' => 0,
|
||||
'energy' => 0,
|
||||
'stress' => 0,
|
||||
'alcohol' => false,
|
||||
],
|
||||
'events' => [],
|
||||
'background_image' => '',
|
||||
]);
|
||||
|
||||
return array_merge($entry, [
|
||||
'evaluation' => $this->scoring->evaluate($entry, $settings, $previousEntry),
|
||||
'sport_type_meta' => [],
|
||||
]);
|
||||
}
|
||||
|
||||
private function buildDashboardTimeline(array $entry): array
|
||||
{
|
||||
$timeline = [];
|
||||
|
||||
foreach (is_array($entry['events'] ?? null) ? $entry['events'] : [] as $event) {
|
||||
if (!is_array($event)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$timeline[] = [
|
||||
'kind' => 'event',
|
||||
'id' => (string) ($event['id'] ?? ''),
|
||||
'type' => (string) ($event['type'] ?? 'event'),
|
||||
'time' => (string) ($event['time'] ?? ''),
|
||||
'comment' => (string) ($event['comment'] ?? ''),
|
||||
'value' => (float) ($event['value'] ?? 0),
|
||||
'unit' => (string) ($event['unit'] ?? ''),
|
||||
'sport_type_id' => (string) ($event['sport_type_id'] ?? ''),
|
||||
'consumed' => !empty($event['consumed']),
|
||||
'mood' => normalize_signal_value($event['mood'] ?? 0),
|
||||
'energy' => normalize_signal_value($event['energy'] ?? 0),
|
||||
'stress' => normalize_signal_value($event['stress'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
return $timeline;
|
||||
}
|
||||
|
||||
private function buildDashboardCompareDays(string $date, array $entryMap, array $settings): array
|
||||
{
|
||||
$days = [];
|
||||
|
||||
for ($offset = -3; $offset <= 1; $offset++) {
|
||||
$dayDate = shift_date($date, $offset);
|
||||
$entry = $entryMap[$dayDate] ?? $this->buildEmptyDashboardEntry($dayDate, $settings, $entryMap[shift_date($dayDate, -1)] ?? null);
|
||||
$isPersisted = isset($entryMap[$dayDate]);
|
||||
$hasContent = $isPersisted || $this->entryHasContent($entry);
|
||||
$visualScore = $this->dashboardVisualScore($entry, $isPersisted);
|
||||
|
||||
$days[] = [
|
||||
'date' => $dayDate,
|
||||
'short' => (new DateTimeImmutable($dayDate))->format('D'),
|
||||
'day' => format_compact_date($dayDate),
|
||||
'offset' => $offset,
|
||||
'is_current' => $dayDate === $date,
|
||||
'has_content' => $hasContent,
|
||||
'visual_score' => $visualScore,
|
||||
'score_level' => $visualScore,
|
||||
'line_level' => $this->dashboardLineLevel($entry, $isPersisted),
|
||||
'line_tone' => $visualScore === null ? 'empty' : signal_value_class($visualScore),
|
||||
];
|
||||
}
|
||||
|
||||
return $days;
|
||||
}
|
||||
|
||||
private function buildDashboardWeekView(string $date, array $entryMap): array
|
||||
{
|
||||
$current = new DateTimeImmutable($date);
|
||||
$selectedStart = $current->modify('monday this week');
|
||||
$selectedKey = $selectedStart->format('Y-m-d');
|
||||
$currentStart = (new DateTimeImmutable(today()))->modify('monday this week');
|
||||
$currentKey = $currentStart->format('Y-m-d');
|
||||
$weekKeys = [$currentKey => true, $selectedKey => true];
|
||||
|
||||
foreach (array_keys($entryMap) as $entryDate) {
|
||||
if (!$this->isValidDate((string) $entryDate)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$weekKeys[(new DateTimeImmutable((string) $entryDate))->modify('monday this week')->format('Y-m-d')] = true;
|
||||
}
|
||||
|
||||
unset($weekKeys[$currentKey]);
|
||||
$otherWeekKeys = array_keys($weekKeys);
|
||||
rsort($otherWeekKeys, SORT_STRING);
|
||||
$orderedWeekKeys = array_merge([$currentKey], $otherWeekKeys);
|
||||
|
||||
$periods = [];
|
||||
foreach ($orderedWeekKeys as $weekKey) {
|
||||
$periods[] = $this->buildDashboardWeekPeriod(new DateTimeImmutable((string) $weekKey), $date, $entryMap, $weekKey === $selectedKey);
|
||||
}
|
||||
|
||||
$selectedPeriod = $this->buildDashboardWeekPeriod($selectedStart, $date, $entryMap, true);
|
||||
|
||||
return array_merge($selectedPeriod, [
|
||||
'periods' => $periods,
|
||||
]);
|
||||
}
|
||||
|
||||
private function buildDashboardWeekPeriod(DateTimeImmutable $start, string $selectedDate, array $entryMap, bool $isSelected): array
|
||||
{
|
||||
$days = [];
|
||||
|
||||
for ($index = 0; $index < 7; $index++) {
|
||||
$day = $start->modify('+' . $index . ' day');
|
||||
$iso = $day->format('Y-m-d');
|
||||
$entry = $entryMap[$iso] ?? null;
|
||||
$hasContent = $entry !== null && $this->entryHasContent($entry);
|
||||
$visualScore = $entry !== null ? $this->dashboardVisualScore($entry, true) : null;
|
||||
|
||||
$days[] = [
|
||||
'date' => $iso,
|
||||
'weekday' => format_display_date($iso, true),
|
||||
'short' => $day->format('D'),
|
||||
'day' => $day->format('j'),
|
||||
'entry' => $entry,
|
||||
'has_content' => $hasContent,
|
||||
'score_level' => $visualScore,
|
||||
'line_tone' => $visualScore === null ? 'empty' : signal_value_class($visualScore),
|
||||
'is_current' => $iso === $selectedDate,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'key' => $start->format('Y-m-d'),
|
||||
'title' => 'Woche ' . $start->format('W'),
|
||||
'range' => $start->format('j.n.Y') . ' - ' . $start->modify('+6 day')->format('j.n.Y'),
|
||||
'is_selected' => $isSelected,
|
||||
'days' => $days,
|
||||
];
|
||||
}
|
||||
|
||||
private function buildDashboardMonthView(string $date, array $entryMap): array
|
||||
{
|
||||
$current = new DateTimeImmutable($date);
|
||||
$selectedStart = $current->modify('first day of this month');
|
||||
$selectedKey = $selectedStart->format('Y-m-d');
|
||||
$currentStart = (new DateTimeImmutable(today()))->modify('first day of this month');
|
||||
$currentKey = $currentStart->format('Y-m-d');
|
||||
$monthKeys = [$currentKey => true, $selectedKey => true];
|
||||
|
||||
foreach (array_keys($entryMap) as $entryDate) {
|
||||
if (!$this->isValidDate((string) $entryDate)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$monthKeys[(new DateTimeImmutable((string) $entryDate))->modify('first day of this month')->format('Y-m-d')] = true;
|
||||
}
|
||||
|
||||
unset($monthKeys[$currentKey]);
|
||||
$otherMonthKeys = array_keys($monthKeys);
|
||||
rsort($otherMonthKeys, SORT_STRING);
|
||||
$orderedMonthKeys = array_merge([$currentKey], $otherMonthKeys);
|
||||
|
||||
$periods = [];
|
||||
foreach ($orderedMonthKeys as $monthKey) {
|
||||
$periods[] = $this->buildDashboardMonthPeriod(new DateTimeImmutable((string) $monthKey), $date, $entryMap, $monthKey === $selectedKey);
|
||||
}
|
||||
|
||||
$selectedPeriod = $this->buildDashboardMonthPeriod($selectedStart, $date, $entryMap, true);
|
||||
|
||||
return array_merge($selectedPeriod, [
|
||||
'periods' => $periods,
|
||||
]);
|
||||
}
|
||||
|
||||
private function buildDashboardMonthPeriod(DateTimeImmutable $start, string $selectedDate, array $entryMap, bool $isSelected): array
|
||||
{
|
||||
$end = $start->modify('last day of this month');
|
||||
$days = [];
|
||||
|
||||
for ($day = $start; $day <= $end; $day = $day->modify('+1 day')) {
|
||||
$iso = $day->format('Y-m-d');
|
||||
$entry = $entryMap[$iso] ?? null;
|
||||
$hasContent = $entry !== null && $this->entryHasContent($entry);
|
||||
$visualScore = $entry !== null ? $this->dashboardVisualScore($entry, true) : null;
|
||||
$days[] = [
|
||||
'date' => $iso,
|
||||
'day' => $day->format('j'),
|
||||
'weekday' => format_display_date($iso, true),
|
||||
'entry' => $entry,
|
||||
'has_content' => $hasContent,
|
||||
'score_level' => $visualScore,
|
||||
'line_tone' => $visualScore === null ? 'empty' : signal_value_class($visualScore),
|
||||
'is_future' => $iso > $selectedDate,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'key' => $start->format('Y-m-d'),
|
||||
'title' => month_label($start->format('Y-m')),
|
||||
'is_selected' => $isSelected,
|
||||
'days' => $days,
|
||||
];
|
||||
}
|
||||
|
||||
private function entryHasContent(array $entry, bool $isPersisted = false): bool
|
||||
{
|
||||
if ($isPersisted) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (trim((string) ($entry['summary']['comment'] ?? '')) !== '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!empty($entry['summary']['alcohol'] ?? $entry['summary_alcohol'] ?? false)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (trim((string) ($entry['background_image'] ?? '')) !== '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return count(array_filter(is_array($entry['events'] ?? null) ? $entry['events'] : [], 'is_array')) > 0;
|
||||
}
|
||||
|
||||
private function dashboardVisualScore(array $entry, bool $isPersisted = false): ?int
|
||||
{
|
||||
if (!$this->entryHasContent($entry, $isPersisted)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$summary = is_array($entry['summary'] ?? null) ? $entry['summary'] : [];
|
||||
$mood = normalize_signal_value($summary['mood'] ?? $entry['summary_mood'] ?? legacy_to_signal_scale($entry['mood'] ?? 5));
|
||||
$energy = normalize_signal_value($summary['energy'] ?? $entry['summary_energy'] ?? legacy_to_signal_scale($entry['energy'] ?? 5));
|
||||
$stress = normalize_signal_value($summary['stress'] ?? $entry['summary_stress'] ?? legacy_to_signal_scale($entry['stress'] ?? 5));
|
||||
|
||||
return signal_combo_score($mood, $energy, $stress);
|
||||
}
|
||||
|
||||
private function dashboardLineLevel(array $entry, bool $isPersisted = false): ?int
|
||||
{
|
||||
if (!$this->entryHasContent($entry, $isPersisted)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$percentage = max(0.0, min(100.0, (float) ($entry['evaluation']['percentage'] ?? 0)));
|
||||
|
||||
return (int) round($percentage / 5);
|
||||
}
|
||||
|
||||
private function dashboardLineTone(array $entry, bool $isPersisted = false): string
|
||||
{
|
||||
return signal_value_class($this->dashboardVisualScore($entry, $isPersisted) ?? 0);
|
||||
}
|
||||
|
||||
private function dashboardEventFromPost(array $input): array
|
||||
{
|
||||
$type = trim((string) ($input['event_type'] ?? 'event'));
|
||||
if (!array_key_exists($type, day_event_type_options())) {
|
||||
$type = 'event';
|
||||
}
|
||||
|
||||
$time = trim((string) ($input['event_time'] ?? ''));
|
||||
if (!$this->isValidTime($time)) {
|
||||
$time = date('H:i');
|
||||
}
|
||||
|
||||
$comment = trim((string) ($input['event_comment'] ?? ''));
|
||||
|
||||
$value = max(0, min(50000, (float) ($input['event_value'] ?? 0)));
|
||||
if (in_array($type, ['walk', 'sport', 'sleep'], true) && $value <= 0) {
|
||||
throw new RuntimeException('Für diese Aktivität braucht es einen Wert oder eine Dauer.');
|
||||
}
|
||||
|
||||
$sportTypeID = trim((string) ($input['event_sport_type_id'] ?? ''));
|
||||
if ($type === 'sport' && $sportTypeID === '') {
|
||||
throw new RuntimeException('Bitte wähle eine Sportart.');
|
||||
}
|
||||
|
||||
$unit = trim((string) ($input['event_unit'] ?? day_event_type_unit($type)));
|
||||
if ($type === 'walk') {
|
||||
$walkMode = trim((string) ($input['event_walk_mode'] ?? 'time'));
|
||||
$unit = $walkMode === 'steps' ? 'steps' : 'min';
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => 'evt-' . substr(sha1((string) microtime(true) . $type . $time . (string) ($input['event_comment'] ?? '')), 0, 12),
|
||||
'type' => $type,
|
||||
'time' => $time,
|
||||
'comment' => $comment,
|
||||
'value' => $value,
|
||||
'unit' => $unit,
|
||||
'sport_type_id' => $sportTypeID,
|
||||
'consumed' => isset($input['event_consumed']) ? ((string) $input['event_consumed'] === '1') : true,
|
||||
'mood' => normalize_signal_value($input['event_mood'] ?? 0),
|
||||
'energy' => normalize_signal_value($input['event_energy'] ?? 0),
|
||||
'stress' => normalize_signal_value($input['event_stress'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
private function dashboardMediaDirectory(string $username): string
|
||||
{
|
||||
return storage_path('users/' . normalize_username($username) . '/media');
|
||||
}
|
||||
|
||||
private function withDashboardImageState(string $username, array $entry): array
|
||||
{
|
||||
$fileName = trim((string) ($entry['background_image'] ?? ''));
|
||||
$date = (string) ($entry['date'] ?? '');
|
||||
|
||||
$entry['background_image_url'] = null;
|
||||
if ($fileName === '' || !$this->isValidDate($date)) {
|
||||
return $entry;
|
||||
}
|
||||
|
||||
$path = $this->dashboardMediaDirectory($username) . '/' . basename($fileName);
|
||||
if (is_file($path)) {
|
||||
$entry['background_image_url'] = '/day-image?date=' . rawurlencode($date);
|
||||
}
|
||||
|
||||
return $entry;
|
||||
}
|
||||
|
||||
private function storeDashboardBackgroundImage(string $username, string $date, array $upload): string
|
||||
{
|
||||
$error = (int) ($upload['error'] ?? UPLOAD_ERR_NO_FILE);
|
||||
if ($error !== UPLOAD_ERR_OK) {
|
||||
throw new RuntimeException('Das Tagesbild konnte nicht hochgeladen werden.');
|
||||
}
|
||||
|
||||
$tmpName = (string) ($upload['tmp_name'] ?? '');
|
||||
if ($tmpName === '' || !is_uploaded_file($tmpName)) {
|
||||
throw new RuntimeException('Die hochgeladene Bilddatei ist ungültig.');
|
||||
}
|
||||
|
||||
$mime = mime_content_type($tmpName) ?: '';
|
||||
$extension = match ($mime) {
|
||||
'image/jpeg' => 'jpg',
|
||||
'image/png' => 'png',
|
||||
'image/webp' => 'webp',
|
||||
default => '',
|
||||
};
|
||||
|
||||
if ($extension === '') {
|
||||
throw new RuntimeException('Bitte nutze JPG, PNG oder WebP als Tagesbild.');
|
||||
}
|
||||
|
||||
$directory = $this->dashboardMediaDirectory($username);
|
||||
if (!is_dir($directory)) {
|
||||
mkdir($directory, 0775, true);
|
||||
}
|
||||
|
||||
$fileName = $date . '-' . substr(sha1((string) microtime(true) . $username), 0, 10) . '.' . $extension;
|
||||
$target = $directory . '/' . $fileName;
|
||||
|
||||
if (!move_uploaded_file($tmpName, $target)) {
|
||||
throw new RuntimeException('Das Tagesbild konnte nicht gespeichert werden.');
|
||||
}
|
||||
|
||||
return $fileName;
|
||||
}
|
||||
|
||||
private function deleteDashboardBackgroundImage(string $username, string $date, string $fileName): void
|
||||
{
|
||||
$path = $this->dashboardMediaDirectory($username) . '/' . basename($fileName);
|
||||
if (is_file($path)) {
|
||||
@unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
private function serveDayImage(): void
|
||||
{
|
||||
$user = $this->requireUser();
|
||||
$date = (string) ($_GET['date'] ?? '');
|
||||
|
||||
if (!$this->isValidDate($date)) {
|
||||
http_response_code(404);
|
||||
exit('Nicht gefunden');
|
||||
}
|
||||
|
||||
$entry = $this->entries->find($user['username'], $date);
|
||||
$fileName = trim((string) ($entry['background_image'] ?? ''));
|
||||
if ($fileName === '') {
|
||||
http_response_code(404);
|
||||
exit('Nicht gefunden');
|
||||
}
|
||||
|
||||
$path = $this->dashboardMediaDirectory($user['username']) . '/' . basename($fileName);
|
||||
if (!is_file($path)) {
|
||||
http_response_code(404);
|
||||
exit('Nicht gefunden');
|
||||
}
|
||||
|
||||
$mime = mime_content_type($path) ?: 'application/octet-stream';
|
||||
header('Content-Type: ' . $mime);
|
||||
header('Content-Length: ' . (string) filesize($path));
|
||||
header('Cache-Control: private, max-age=3600');
|
||||
readfile($path);
|
||||
exit;
|
||||
}
|
||||
|
||||
private function showTrack(): void
|
||||
{
|
||||
$user = $this->requireUser();
|
||||
@@ -507,6 +1074,7 @@ final class App
|
||||
{
|
||||
$user = $this->requireUser();
|
||||
$settings = $this->hydrateSettings($this->settings->forUser($user['username']));
|
||||
$evaluatedEntries = $this->evaluateEntriesWithContext($this->entries->all($user['username']), $settings);
|
||||
$sportTypePresets = array_values(array_filter(
|
||||
Defaults::settings()['sport_types'],
|
||||
static function (array $preset) use ($settings): bool {
|
||||
@@ -534,6 +1102,7 @@ final class App
|
||||
'pageTitle' => 'Optionen',
|
||||
'page' => 'options',
|
||||
'authUser' => $user,
|
||||
'optionsOpenPanel' => trim((string) ($_GET['panel'] ?? '')),
|
||||
'settings' => $settings,
|
||||
'sportTypePresets' => $sportTypePresets,
|
||||
'sportLocationOptions' => sport_location_options(),
|
||||
@@ -545,6 +1114,8 @@ final class App
|
||||
'aiConfig' => $user['is_admin'] ? $this->aiConfig->get() : null,
|
||||
'aiStatus' => $user['is_admin'] ? $this->openAi->configuration() : null,
|
||||
'users' => $user['is_admin'] ? $this->users->all() : [],
|
||||
'statsSummary' => $this->buildDashboardSummary($evaluatedEntries),
|
||||
'statsChartPayload' => encode_payload($this->buildDashboardCharts($evaluatedEntries)),
|
||||
'maxScore' => $this->scoring->evaluate([
|
||||
'mood' => 10,
|
||||
'energy' => 10,
|
||||
|
||||
@@ -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,56 @@ final class EntryRepository
|
||||
|
||||
private function toMarkdown(string $username, string $date, array $entry, array $evaluation): string
|
||||
{
|
||||
$summary = is_array($entry['summary'] ?? null) ? $entry['summary'] : [
|
||||
'comment' => (string) ($entry['summary_comment'] ?? $entry['note'] ?? ''),
|
||||
'mood' => normalize_signal_value($entry['summary_mood'] ?? legacy_to_signal_scale($entry['mood'] ?? 5)),
|
||||
'energy' => normalize_signal_value($entry['summary_energy'] ?? legacy_to_signal_scale($entry['energy'] ?? 5)),
|
||||
'stress' => normalize_signal_value($entry['summary_stress'] ?? legacy_to_signal_scale($entry['stress'] ?? 5)),
|
||||
];
|
||||
$events = is_array($entry['events'] ?? null) ? $entry['events'] : [];
|
||||
$sportTypes = $evaluation['sport_types'] ?? [];
|
||||
$sportTypeValues = array_map(
|
||||
static fn (array $type): string => (string) $type['label'] . ' [' . (string) $type['id'] . ']',
|
||||
array_filter($sportTypes, 'is_array')
|
||||
);
|
||||
|
||||
$eventLines = [];
|
||||
foreach ($events as $event) {
|
||||
if (!is_array($event)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$eventLines[] = '### ' . ((string) ($event['id'] ?? 'evt'));
|
||||
$eventLines[] = '- Typ: ' . day_event_type_label((string) ($event['type'] ?? 'event')) . ' [' . (string) ($event['type'] ?? 'event') . ']';
|
||||
$eventLines[] = '- Uhrzeit: ' . (string) ($event['time'] ?? '');
|
||||
$eventLines[] = '- Wert: ' . (string) ($event['value'] ?? 0);
|
||||
$eventLines[] = '- Einheit: ' . (string) ($event['unit'] ?? '');
|
||||
$eventLines[] = '- Sportart: ' . (string) ($event['sport_type_id'] ?? '');
|
||||
$eventLines[] = '- Getrunken: ' . (!empty($event['consumed']) ? 'ja' : 'nein');
|
||||
$eventLines[] = '- Kommentar: ' . (string) ($event['comment'] ?? '');
|
||||
$eventLines[] = '- Stimmung: ' . (string) ($event['mood'] ?? 0);
|
||||
$eventLines[] = '- Energie: ' . (string) ($event['energy'] ?? 0);
|
||||
$eventLines[] = '- Stress: ' . (string) ($event['stress'] ?? 0);
|
||||
$eventLines[] = '';
|
||||
}
|
||||
|
||||
$lines = [
|
||||
'<!-- 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', '']),
|
||||
'## Tracking',
|
||||
'## Werte',
|
||||
'- Stimmung: ' . $entry['mood'],
|
||||
'- Energie: ' . $entry['energy'],
|
||||
@@ -202,14 +257,93 @@ final class EntryRepository
|
||||
'- Sport: ' . format_points((float) $evaluation['components']['sport_minutes']),
|
||||
'- Sportbonus: ' . format_points((float) ($evaluation['components']['sport_bonus'] ?? 0)),
|
||||
'- Spaziergang: ' . format_points((float) $evaluation['components']['walk_minutes']),
|
||||
'- Momente: ' . format_points((float) ($evaluation['components']['events'] ?? 0)),
|
||||
'- Alkohol: ' . format_points((float) ($evaluation['components']['alcohol'] ?? 0)),
|
||||
'- Notiz: ' . format_points((float) $evaluation['components']['note']),
|
||||
'',
|
||||
'## Notiz',
|
||||
trim((string) $entry['note']),
|
||||
trim((string) ($summary['comment'] ?? $entry['note'] ?? '')),
|
||||
'',
|
||||
];
|
||||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
private function parseV3(string $content, string $fallbackDate): ?array
|
||||
{
|
||||
$date = $this->extract('/^Datum:\s*(\d{4}-\d{2}-\d{2})$/m', $content) ?? $fallbackDate;
|
||||
$backgroundImage = trim((string) ($this->extract('/^Hintergrundbild:[ \t]*([^\r\n]*)$/mu', $content) ?? ''));
|
||||
if (preg_match('/^[A-Za-z0-9._-]+\.(?:jpe?g|png|webp)$/i', $backgroundImage) !== 1) {
|
||||
$backgroundImage = '';
|
||||
}
|
||||
$summarySection = $this->extractSection($content, '## Tagesbilanz', '## Ereignisse');
|
||||
$eventsSection = $this->extractSection($content, '## Ereignisse', '## Tracking');
|
||||
$legacySection = $this->extractSection($content, '## Werte', '## Bewertung');
|
||||
$legacyContent = $legacySection !== null ? "## Werte\n" . $legacySection : $content;
|
||||
|
||||
$base = $this->parse(str_replace('<!-- mood-tracker:v3 -->', '<!-- mood-tracker:v2 -->', $legacyContent), $fallbackDate);
|
||||
if ($base === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$summary = [
|
||||
'comment' => (string) ($this->extract('/^- Kommentar:\s*(.*)$/mu', $summarySection ?? '') ?? ''),
|
||||
'mood' => normalize_signal_value($this->extract('/^- Stimmung:\s*(-?\d+)$/m', $summarySection ?? '') ?? 0),
|
||||
'energy' => normalize_signal_value($this->extract('/^- Energie:\s*(-?\d+)$/m', $summarySection ?? '') ?? 0),
|
||||
'stress' => normalize_signal_value($this->extract('/^- Stress:\s*(-?\d+)$/m', $summarySection ?? '') ?? 0),
|
||||
'alcohol' => in_array(strtolower((string) ($this->extract('/^- Alkohol:\s*(.*)$/mu', $summarySection ?? '') ?? 'nein')), ['ja', 'yes', 'true', '1'], true),
|
||||
];
|
||||
|
||||
$events = [];
|
||||
foreach (preg_split('/^###\s+/m', trim((string) $eventsSection)) ?: [] as $chunk) {
|
||||
$chunk = trim($chunk);
|
||||
if ($chunk === '' || str_starts_with($chunk, '- Keine Ereignisse')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$lines = preg_split('/\R/', $chunk) ?: [];
|
||||
$id = trim((string) array_shift($lines));
|
||||
$block = implode("\n", $lines);
|
||||
$typeLine = (string) ($this->extract('/^- Typ:\s*.*\[([^\]]+)\]$/mu', $block) ?? 'event');
|
||||
|
||||
$events[] = [
|
||||
'id' => $id,
|
||||
'type' => $typeLine,
|
||||
'time' => (string) ($this->extract('/^- Uhrzeit:\s*(.*)$/mu', $block) ?? ''),
|
||||
'value' => (float) ($this->extract('/^- Wert:\s*(.*)$/mu', $block) ?? 0),
|
||||
'unit' => (string) ($this->extract('/^- Einheit:\s*(.*)$/mu', $block) ?? ''),
|
||||
'sport_type_id' => (string) ($this->extract('/^- Sportart:\s*(.*)$/mu', $block) ?? ''),
|
||||
'consumed' => in_array(strtolower((string) ($this->extract('/^- Getrunken:\s*(.*)$/mu', $block) ?? 'ja')), ['ja', 'yes', 'true', '1'], true),
|
||||
'comment' => (string) ($this->extract('/^- Kommentar:\s*(.*)$/mu', $block) ?? ''),
|
||||
'mood' => normalize_signal_value($this->extract('/^- Stimmung:\s*(-?\d+)$/m', $block) ?? 0),
|
||||
'energy' => normalize_signal_value($this->extract('/^- Energie:\s*(-?\d+)$/m', $block) ?? 0),
|
||||
'stress' => normalize_signal_value($this->extract('/^- Stress:\s*(-?\d+)$/m', $block) ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
$base['date'] = $date;
|
||||
$base['background_image'] = $backgroundImage;
|
||||
$base['summary'] = $summary;
|
||||
$base['summary_comment'] = $summary['comment'];
|
||||
$base['summary_mood'] = $summary['mood'];
|
||||
$base['summary_energy'] = $summary['energy'];
|
||||
$base['summary_stress'] = $summary['stress'];
|
||||
$base['summary_alcohol'] = !empty($summary['alcohol']);
|
||||
$base['events'] = $events;
|
||||
$base['alcohol'] = !empty($summary['alcohol']);
|
||||
$base['note'] = $summary['comment'];
|
||||
|
||||
return $base;
|
||||
}
|
||||
|
||||
private function extractSection(string $content, string $startHeading, string $endHeading): ?string
|
||||
{
|
||||
$pattern = '/' . preg_quote($startHeading, '/') . '\s*(.*?)\s*' . preg_quote($endHeading, '/') . '/su';
|
||||
|
||||
if (preg_match($pattern, $content, $matches) !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trim((string) ($matches[1] ?? ''));
|
||||
}
|
||||
}
|
||||
|
||||
+180
-12
@@ -6,25 +6,47 @@ final class ScoringService
|
||||
{
|
||||
public function normalize(array $input): array
|
||||
{
|
||||
$sportTypes = normalize_sport_type_selection($input['sport_types'] ?? ($input['sport_type'] ?? []));
|
||||
$hasSummaryInput = is_array($input['summary'] ?? null)
|
||||
|| array_key_exists('summary_mood', $input)
|
||||
|| array_key_exists('summary_energy', $input)
|
||||
|| array_key_exists('summary_stress', $input);
|
||||
$hasEventInput = is_array($input['events'] ?? null) && $input['events'] !== [];
|
||||
$summary = $this->normalizeSummary($input['summary'] ?? [
|
||||
'comment' => $input['summary_comment'] ?? ($input['note'] ?? ''),
|
||||
'mood' => $input['summary_mood'] ?? legacy_to_signal_scale($input['mood'] ?? 5),
|
||||
'energy' => $input['summary_energy'] ?? legacy_to_signal_scale($input['energy'] ?? 5),
|
||||
'stress' => $input['summary_stress'] ?? legacy_to_signal_scale($input['stress'] ?? 5),
|
||||
'alcohol' => !empty($input['summary_alcohol'] ?? $input['alcohol'] ?? false),
|
||||
]);
|
||||
$events = $this->normalizeEvents($input['events'] ?? []);
|
||||
$derived = $this->deriveLegacyFieldsFromEvents($events, $summary, $input);
|
||||
$sportTypes = normalize_sport_type_selection($hasEventInput ? ($derived['sport_types'] ?? []) : ($input['sport_types'] ?? ($derived['sport_types'] ?? ($input['sport_type'] ?? []))));
|
||||
|
||||
return [
|
||||
'date' => $input['date'] ?? today(),
|
||||
'mood' => max(1, min(10, (int) ($input['mood'] ?? 5))),
|
||||
'energy' => max(1, min(10, (int) ($input['energy'] ?? 5))),
|
||||
'stress' => max(1, min(10, (int) ($input['stress'] ?? 5))),
|
||||
'mood' => max(1, min(10, (int) ($hasSummaryInput ? $derived['mood'] : ($input['mood'] ?? $derived['mood'])))),
|
||||
'energy' => max(1, min(10, (int) ($hasSummaryInput ? $derived['energy'] : ($input['energy'] ?? $derived['energy'])))),
|
||||
'stress' => max(1, min(10, (int) ($hasSummaryInput ? $derived['stress'] : ($input['stress'] ?? $derived['stress'])))),
|
||||
'pain' => max(1, min(10, (int) ($input['pain'] ?? 1))),
|
||||
'pain_enabled' => $this->normalizeBoolean($input['pain_enabled'] ?? false),
|
||||
'sleep_hours' => max(0, min(24, (float) ($input['sleep_hours'] ?? 0))),
|
||||
'sleep_feeling' => max(1, min(5, (int) ($input['sleep_feeling'] ?? 3))),
|
||||
'sport_minutes' => max(0, min(1440, (int) ($input['sport_minutes'] ?? 0))),
|
||||
'sleep_hours' => max(0, min(24, (float) ($hasEventInput ? $derived['sleep_hours'] : ($input['sleep_hours'] ?? $derived['sleep_hours'])))),
|
||||
'sleep_feeling' => max(1, min(5, (int) ($hasSummaryInput ? $derived['sleep_feeling'] : ($input['sleep_feeling'] ?? $derived['sleep_feeling'])))),
|
||||
'sport_minutes' => max(0, min(1440, (int) ($hasEventInput ? $derived['sport_minutes'] : ($input['sport_minutes'] ?? $derived['sport_minutes'])))),
|
||||
'sport_type' => $sportTypes[0] ?? '',
|
||||
'sport_types' => $sportTypes,
|
||||
'walk_mode' => $this->normalizeWalkMode((string) ($input['walk_mode'] ?? ((isset($input['walk_steps']) && !isset($input['walk_minutes'])) ? 'steps' : 'time'))),
|
||||
'walk_minutes' => max(0, min(1440, (int) ($input['walk_minutes'] ?? 0))),
|
||||
'walk_steps' => max(0, min(50000, (int) ($input['walk_steps'] ?? 0))),
|
||||
'alcohol' => $this->normalizeBoolean($input['alcohol'] ?? false),
|
||||
'note' => trim((string) ($input['note'] ?? '')),
|
||||
'walk_mode' => $this->normalizeWalkMode((string) ($input['walk_mode'] ?? $derived['walk_mode'] ?? ((isset($input['walk_steps']) && !isset($input['walk_minutes'])) ? 'steps' : 'time'))),
|
||||
'walk_minutes' => max(0, min(1440, (int) ($hasEventInput ? $derived['walk_minutes'] : ($input['walk_minutes'] ?? $derived['walk_minutes'])))),
|
||||
'walk_steps' => max(0, min(50000, (int) ($hasEventInput ? $derived['walk_steps'] : ($input['walk_steps'] ?? $derived['walk_steps'])))),
|
||||
'alcohol' => $this->normalizeBoolean($hasSummaryInput ? $derived['alcohol'] : ($input['alcohol'] ?? $derived['alcohol'])),
|
||||
'note' => trim((string) ($input['note'] ?? $summary['comment'])),
|
||||
'summary' => $summary,
|
||||
'summary_comment' => $summary['comment'],
|
||||
'summary_mood' => $summary['mood'],
|
||||
'summary_energy' => $summary['energy'],
|
||||
'summary_stress' => $summary['stress'],
|
||||
'summary_alcohol' => !empty($summary['alcohol']),
|
||||
'background_image' => trim((string) ($input['background_image'] ?? '')),
|
||||
'events' => $events,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -36,6 +58,7 @@ final class ScoringService
|
||||
$ratings = $this->sortedRatings($settings['ratings'] ?? []);
|
||||
$sportTypes = find_sport_types($settings, $entry['sport_types']);
|
||||
$sportBonus = $this->sportBonusPoints($entry, $previousEntry, $settings, $sportTypes);
|
||||
$eventSignalPoints = $this->eventSignalPoints($entry['events']);
|
||||
$painEnabled = !empty($settings['tracking']['pain_enabled']);
|
||||
|
||||
$components = [
|
||||
@@ -47,6 +70,7 @@ final class ScoringService
|
||||
'sport_minutes' => $this->bandPoints((int) $entry['sport_minutes'], $scoring['sport_bands']),
|
||||
'sport_bonus' => $sportBonus,
|
||||
'walk_minutes' => $this->walkPoints($entry, $settings),
|
||||
'events' => $eventSignalPoints,
|
||||
'alcohol' => !empty($entry['alcohol']) ? ((float) abs((float) ($scoring['alcohol_penalty'] ?? 5)) * -1) : 0.0,
|
||||
'note' => $entry['note'] === '' ? 0 : (float) $scoring['journal_points'],
|
||||
];
|
||||
@@ -66,6 +90,7 @@ final class ScoringService
|
||||
$this->maxBandPoints($scoring['sport_bands']) +
|
||||
$this->maxSportBonusPoints($settings) +
|
||||
$this->maxWalkPoints($entry, $settings) +
|
||||
($eventSignalPoints !== 0.0 ? 8.0 : 0.0) +
|
||||
(float) $scoring['journal_points'],
|
||||
1
|
||||
);
|
||||
@@ -100,6 +125,28 @@ final class ScoringService
|
||||
];
|
||||
}
|
||||
|
||||
private function eventSignalPoints(array $events): float
|
||||
{
|
||||
if ($events === []) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$scores = [];
|
||||
foreach ($events as $event) {
|
||||
if (!is_array($event)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$scores[] = signal_combo_score($event['mood'] ?? 0, $event['energy'] ?? 0, $event['stress'] ?? 0);
|
||||
}
|
||||
|
||||
if ($scores === []) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return round(max(-8.0, min(8.0, (array_sum($scores) / count($scores)) * 4.0)), 1);
|
||||
}
|
||||
|
||||
private function sleepDurationPoints(float $hours, array $points): float
|
||||
{
|
||||
if ($hours < 4) {
|
||||
@@ -304,6 +351,127 @@ final class ScoringService
|
||||
return $total;
|
||||
}
|
||||
|
||||
private function normalizeSummary(mixed $summary): array
|
||||
{
|
||||
$summary = is_array($summary) ? $summary : [];
|
||||
|
||||
return [
|
||||
'comment' => trim((string) ($summary['comment'] ?? '')),
|
||||
'mood' => normalize_signal_value($summary['mood'] ?? 0),
|
||||
'energy' => normalize_signal_value($summary['energy'] ?? 0),
|
||||
'stress' => normalize_signal_value($summary['stress'] ?? 0),
|
||||
'alcohol' => $this->normalizeBoolean($summary['alcohol'] ?? false),
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeEvents(mixed $events): array
|
||||
{
|
||||
if (!is_array($events)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$normalized = [];
|
||||
|
||||
foreach ($events as $event) {
|
||||
if (!is_array($event)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$type = trim((string) ($event['type'] ?? 'event'));
|
||||
if (!array_key_exists($type, day_event_type_options())) {
|
||||
$type = 'event';
|
||||
}
|
||||
|
||||
$time = trim((string) ($event['time'] ?? ''));
|
||||
if (preg_match('/^(?:[01]\d|2[0-3]):[0-5]\d$/', $time) !== 1) {
|
||||
$time = '';
|
||||
}
|
||||
|
||||
$unit = trim((string) ($event['unit'] ?? day_event_type_unit($type)));
|
||||
$value = is_numeric($event['value'] ?? null) ? (float) $event['value'] : 0.0;
|
||||
|
||||
$normalized[] = [
|
||||
'id' => trim((string) ($event['id'] ?? '')) ?: ('evt-' . substr(sha1(json_encode($event) . microtime(true)), 0, 12)),
|
||||
'type' => $type,
|
||||
'time' => $time,
|
||||
'comment' => trim(preg_replace('/\s+/u', ' ', (string) ($event['comment'] ?? '')) ?? ''),
|
||||
'value' => max(0, min(50000, $value)),
|
||||
'unit' => $unit,
|
||||
'sport_type_id' => trim((string) ($event['sport_type_id'] ?? '')),
|
||||
'consumed' => $this->normalizeBoolean($event['consumed'] ?? true),
|
||||
'mood' => normalize_signal_value($event['mood'] ?? 0),
|
||||
'energy' => normalize_signal_value($event['energy'] ?? 0),
|
||||
'stress' => normalize_signal_value($event['stress'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
usort($normalized, static function (array $left, array $right): int {
|
||||
return strcmp((string) ($left['time'] ?? ''), (string) ($right['time'] ?? ''));
|
||||
});
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
private function deriveLegacyFieldsFromEvents(array $events, array $summary, array $input): array
|
||||
{
|
||||
$sportMinutes = 0;
|
||||
$walkMinutes = 0;
|
||||
$walkSteps = 0;
|
||||
$sleepHours = 0.0;
|
||||
$alcohol = false;
|
||||
$walkMode = $this->normalizeWalkMode((string) ($input['walk_mode'] ?? 'time'));
|
||||
$sportTypes = [];
|
||||
|
||||
foreach ($events as $event) {
|
||||
$type = (string) ($event['type'] ?? 'event');
|
||||
$unit = (string) ($event['unit'] ?? '');
|
||||
$value = (float) ($event['value'] ?? 0);
|
||||
|
||||
if ($type === 'sport') {
|
||||
$sportMinutes += (int) round($value);
|
||||
$sportTypeID = trim((string) ($event['sport_type_id'] ?? ''));
|
||||
if ($sportTypeID !== '') {
|
||||
$sportTypes[$sportTypeID] = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($type === 'walk') {
|
||||
if ($unit === 'steps') {
|
||||
$walkMode = 'steps';
|
||||
$walkSteps += (int) round($value);
|
||||
} else {
|
||||
$walkMinutes += (int) round($value);
|
||||
}
|
||||
}
|
||||
|
||||
if ($type === 'sleep') {
|
||||
$sleepHours += $unit === 'min' ? ($value / 60) : $value;
|
||||
}
|
||||
|
||||
if ($type === 'alcohol') {
|
||||
$alcohol = !empty($event['consumed']);
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($summary['alcohol'])) {
|
||||
$alcohol = true;
|
||||
}
|
||||
|
||||
return [
|
||||
'mood' => signal_to_legacy_scale($summary['mood']),
|
||||
'energy' => signal_to_legacy_scale($summary['energy']),
|
||||
'stress' => signal_to_legacy_scale($summary['stress']),
|
||||
'sleep_hours' => $sleepHours,
|
||||
'sleep_feeling' => max(1, min(5, 3 + $summary['energy'])),
|
||||
'sport_minutes' => $sportMinutes,
|
||||
'walk_mode' => $walkMode,
|
||||
'walk_minutes' => $walkMinutes,
|
||||
'walk_steps' => $walkSteps,
|
||||
'alcohol' => $alcohol,
|
||||
'sport_types' => array_keys($sportTypes),
|
||||
];
|
||||
}
|
||||
|
||||
private function sortedRatings(array $ratings): array
|
||||
{
|
||||
usort($ratings, static fn (array $a, array $b): int => ((int) $a['min']) <=> ((int) $b['min']));
|
||||
|
||||
+165
@@ -634,3 +634,168 @@ function find_sport_types(array $settings, array $ids): array
|
||||
|
||||
return $types;
|
||||
}
|
||||
|
||||
function signal_scale_options(): array
|
||||
{
|
||||
return [
|
||||
-2 => 'sehr niedrig',
|
||||
-1 => 'niedrig',
|
||||
0 => 'neutral',
|
||||
1 => 'hoch',
|
||||
2 => 'sehr hoch',
|
||||
];
|
||||
}
|
||||
|
||||
function signal_labels_for_metric(string $metric): array
|
||||
{
|
||||
return match ($metric) {
|
||||
'stress' => [
|
||||
-2 => 'sehr ruhig',
|
||||
-1 => 'ruhig',
|
||||
0 => 'neutral',
|
||||
1 => 'angespannt',
|
||||
2 => 'sehr angespannt',
|
||||
],
|
||||
'energy' => [
|
||||
-2 => 'leer',
|
||||
-1 => 'matt',
|
||||
0 => 'okay',
|
||||
1 => 'wach',
|
||||
2 => 'kraftvoll',
|
||||
],
|
||||
default => [
|
||||
-2 => 'sehr niedrig',
|
||||
-1 => 'niedrig',
|
||||
0 => 'neutral',
|
||||
1 => 'hoch',
|
||||
2 => 'sehr hoch',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function normalize_signal_value(mixed $value): int
|
||||
{
|
||||
return max(-2, min(2, (int) $value));
|
||||
}
|
||||
|
||||
function signal_to_legacy_scale(mixed $value): int
|
||||
{
|
||||
return match (normalize_signal_value($value)) {
|
||||
-2 => 1,
|
||||
-1 => 3,
|
||||
0 => 5,
|
||||
1 => 7,
|
||||
2 => 9,
|
||||
};
|
||||
}
|
||||
|
||||
function legacy_to_signal_scale(mixed $value): int
|
||||
{
|
||||
$legacy = max(1, min(10, (int) $value));
|
||||
|
||||
return match (true) {
|
||||
$legacy <= 2 => -2,
|
||||
$legacy <= 4 => -1,
|
||||
$legacy <= 6 => 0,
|
||||
$legacy <= 8 => 1,
|
||||
default => 2,
|
||||
};
|
||||
}
|
||||
|
||||
function day_event_type_options(): array
|
||||
{
|
||||
return [
|
||||
'event' => [
|
||||
'label' => 'Ereignis',
|
||||
'icon' => '/assets/icons/activity-event.svg',
|
||||
'unit' => '',
|
||||
],
|
||||
'walk' => [
|
||||
'label' => 'Spaziergang',
|
||||
'icon' => sport_icon_path('hike'),
|
||||
'unit' => 'min',
|
||||
],
|
||||
'sport' => [
|
||||
'label' => 'Sport',
|
||||
'icon' => sport_icon_path('run'),
|
||||
'unit' => 'min',
|
||||
],
|
||||
'sleep' => [
|
||||
'label' => 'Schlaf',
|
||||
'icon' => '/assets/icons/activity-sleep.svg',
|
||||
'unit' => 'h',
|
||||
],
|
||||
'alcohol' => [
|
||||
'label' => 'Alkohol',
|
||||
'icon' => icon_path('alcohol'),
|
||||
'unit' => '',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
function day_event_type_label(string $type): string
|
||||
{
|
||||
return day_event_type_options()[$type]['label'] ?? day_event_type_options()['event']['label'];
|
||||
}
|
||||
|
||||
function day_event_type_icon(string $type): string
|
||||
{
|
||||
return day_event_type_options()[$type]['icon'] ?? day_event_type_options()['event']['icon'];
|
||||
}
|
||||
|
||||
function day_event_type_unit(string $type): string
|
||||
{
|
||||
return day_event_type_options()[$type]['unit'] ?? '';
|
||||
}
|
||||
|
||||
function signal_badge_tone(int $value, string $metric): string
|
||||
{
|
||||
$value = normalize_signal_value($value);
|
||||
|
||||
if ($metric === 'stress') {
|
||||
return match (true) {
|
||||
$value <= -1 => 'good',
|
||||
$value === 0 => 'neutral',
|
||||
default => 'warn',
|
||||
};
|
||||
}
|
||||
|
||||
return match (true) {
|
||||
$value <= -1 => 'warn',
|
||||
$value === 0 => 'neutral',
|
||||
default => 'good',
|
||||
};
|
||||
}
|
||||
|
||||
function signal_combo_score(mixed $mood, mixed $energy, mixed $stress): int
|
||||
{
|
||||
return max(-2, min(2, (int) round((
|
||||
normalize_signal_value($mood) +
|
||||
normalize_signal_value($energy) -
|
||||
normalize_signal_value($stress)
|
||||
) / 3)));
|
||||
}
|
||||
|
||||
function day_entry_has_content(array $entry): bool
|
||||
{
|
||||
if (trim((string) ($entry['summary']['comment'] ?? '')) !== '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (trim((string) ($entry['background_image'] ?? '')) !== '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return count(array_filter(is_array($entry['events'] ?? null) ? $entry['events'] : [], 'is_array')) > 0;
|
||||
}
|
||||
|
||||
function signal_value_class(int $value): string
|
||||
{
|
||||
return match (normalize_signal_value($value)) {
|
||||
-2 => 'neg2',
|
||||
-1 => 'neg1',
|
||||
0 => 'zero',
|
||||
1 => 'pos1',
|
||||
2 => 'pos2',
|
||||
};
|
||||
}
|
||||
|
||||
+19
-22
@@ -3,7 +3,7 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
$brandSubtitle = match ($page) {
|
||||
'dashboard' => 'Statistiken und Verlauf',
|
||||
'dashboard' => '',
|
||||
'track' => 'Tag erfassen und bewerten',
|
||||
'archive' => '',
|
||||
'options' => 'Logik, Erinnerungen, Sicherheit und Accounts',
|
||||
@@ -11,6 +11,9 @@ $brandSubtitle = match ($page) {
|
||||
'setup' => 'Erstkonfiguration',
|
||||
default => 'Stimmungstracker',
|
||||
};
|
||||
$immersiveDashboard = $page === 'dashboard';
|
||||
$cssVersion = is_file(base_path('assets/css/app.css')) ? (string) filemtime(base_path('assets/css/app.css')) : '1';
|
||||
$jsVersion = is_file(base_path('assets/js/app.js')) ? (string) filemtime(base_path('assets/js/app.js')) : '1';
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
@@ -33,15 +36,15 @@ $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>
|
||||
<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 +59,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 +85,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,21 +114,19 @@ $brandSubtitle = match ($page) {
|
||||
|
||||
<?= $content ?>
|
||||
|
||||
<footer class="site-footer glass-panel">
|
||||
<a class="site-footer__link" href="https://git.hnz.io/hnzio/mood-tracking/releases" target="_blank" rel="noreferrer">Version 1.2.1</a>
|
||||
<a class="site-footer__link" href="https://hnz.io" target="_blank" rel="noreferrer">(c) 2026 @hnz.io</a>
|
||||
</footer>
|
||||
<?php if (!$immersiveDashboard): ?>
|
||||
<footer class="site-footer glass-panel">
|
||||
<a class="site-footer__link" href="https://git.hnz.io/hnzio/mood-tracking/releases" target="_blank" rel="noreferrer">Version 1.5</a>
|
||||
<a class="site-footer__link" href="https://hnz.io" target="_blank" rel="noreferrer">(c) 2026 @hnz.io</a>
|
||||
</footer>
|
||||
<?php endif; ?>
|
||||
</main>
|
||||
|
||||
<?php if ($authUser !== null): ?>
|
||||
<?php if ($authUser !== null && !$immersiveDashboard): ?>
|
||||
<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>
|
||||
<span>Start</span>
|
||||
</a>
|
||||
<a class="<?= is_active_path('/archive') ? 'active' : '' ?>" href="/archive">
|
||||
<img class="nav-icon" src="<?= e(icon_path('archive')) ?>" alt="">
|
||||
|
||||
+507
-92
@@ -1,99 +1,514 @@
|
||||
<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'] ?? ''));
|
||||
$summaryMood = normalize_signal_value($dayEntry['summary']['mood'] ?? $dayEntry['summary_mood'] ?? 0);
|
||||
$summaryEnergy = normalize_signal_value($dayEntry['summary']['energy'] ?? $dayEntry['summary_energy'] ?? 0);
|
||||
$summaryStress = normalize_signal_value($dayEntry['summary']['stress'] ?? $dayEntry['summary_stress'] ?? 0);
|
||||
$summaryAlcohol = !empty($dayEntry['summary']['alcohol'] ?? $dayEntry['summary_alcohol'] ?? false);
|
||||
?>
|
||||
|
||||
<article class="hero-card glass-panel">
|
||||
<p class="eyebrow">Heute</p>
|
||||
<?php if ($summary['today'] !== null): ?>
|
||||
<div class="hero-score"><?= e(format_points((float) $summary['today']['evaluation']['total'])) ?></div>
|
||||
<p class="hero-label"><?= e($summary['today']['evaluation']['label']) ?></p>
|
||||
<?php else: ?>
|
||||
<div class="hero-score">-</div>
|
||||
<p class="hero-label">Noch kein Eintrag für heute</p>
|
||||
<?php endif; ?>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="stats-grid">
|
||||
<article class="metric-card glass-panel">
|
||||
<span>Getrackte Tage</span>
|
||||
<strong><?= e((string) $summary['tracked_days']) ?></strong>
|
||||
</article>
|
||||
<article class="metric-card glass-panel">
|
||||
<span>Ø Score</span>
|
||||
<strong><?= e(format_points((float) $summary['average_score'])) ?></strong>
|
||||
</article>
|
||||
<article class="metric-card glass-panel">
|
||||
<span>Ø Stimmung</span>
|
||||
<strong><?= e(format_points((float) $summary['average_mood'])) ?>/10</strong>
|
||||
</article>
|
||||
<article class="metric-card glass-panel">
|
||||
<span>Ø Stress</span>
|
||||
<strong><?= e(format_points((float) $summary['average_stress'])) ?>/10</strong>
|
||||
</article>
|
||||
<article class="metric-card glass-panel">
|
||||
<span>Serie</span>
|
||||
<strong><?= e((string) $summary['streak']) ?> Tage</strong>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="dashboard-grid">
|
||||
<article class="glass-panel chart-card chart-card--calendar">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<p class="eyebrow">Kalender</p>
|
||||
<h3>Gesamtstimmung pro Tag</h3>
|
||||
</div>
|
||||
<section class="dashboard-shell<?= $dayBackground !== null ? ' dashboard-shell--with-image' : '' ?>" data-dashboard-root>
|
||||
<?php if ($dayBackground !== null): ?>
|
||||
<div class="dashboard-shell__background" aria-hidden="true">
|
||||
<img src="<?= e($dayBackground) ?>" alt="">
|
||||
</div>
|
||||
<div id="calendar-heatmap" class="calendar-heatmap" data-payload="<?= e($chartPayload) ?>"></div>
|
||||
</article>
|
||||
|
||||
<article class="glass-panel chart-card">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<p class="eyebrow">Trend</p>
|
||||
<h3>Tagesstimmung</h3>
|
||||
</div>
|
||||
<span class="chart-chip">letzte 30 Einträge</span>
|
||||
</div>
|
||||
<div class="line-chart" data-chart-type="line" data-series="mood" data-payload="<?= e($chartPayload) ?>"></div>
|
||||
</article>
|
||||
|
||||
<article class="glass-panel chart-card">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<p class="eyebrow">Belastung</p>
|
||||
<h3>Stressverlauf</h3>
|
||||
</div>
|
||||
<span class="chart-chip chart-chip--warm">weniger ist besser</span>
|
||||
</div>
|
||||
<div class="line-chart" data-chart-type="line" data-series="stress" data-payload="<?= e($chartPayload) ?>"></div>
|
||||
</article>
|
||||
|
||||
<?php if (!empty($settings['tracking']['pain_enabled'])): ?>
|
||||
<article class="glass-panel chart-card">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<p class="eyebrow">Körper</p>
|
||||
<h3>Schmerzverlauf</h3>
|
||||
</div>
|
||||
<span class="chart-chip chart-chip--warm">weniger ist besser</span>
|
||||
</div>
|
||||
<div class="line-chart" data-chart-type="line" data-series="pain" data-payload="<?= e($chartPayload) ?>"></div>
|
||||
</article>
|
||||
<?php endif; ?>
|
||||
|
||||
<article class="glass-panel chart-card chart-card--wide">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<p class="eyebrow">Aktivität</p>
|
||||
<h3>Sport und Spaziergang</h3>
|
||||
<header class="dashboard-topbar">
|
||||
<nav class="dashboard-switcher glass-panel" aria-label="Ansicht wechseln">
|
||||
<a class="<?= $dashboardView === 'day' ? 'active' : '' ?>" href="/?view=day&date=<?= e(rawurlencode($dashboardDate)) ?>">Tag</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__hero">
|
||||
<p class="dashboard-day__eyebrow"><?= e((string) $dayWeekday) ?></p>
|
||||
<h1><?= e(format_display_date((string) $dayEntry['date'], false)) ?></h1>
|
||||
|
||||
<nav class="dashboard-compare-strip" aria-label="Tagesvergleich">
|
||||
<?php foreach ($dashboardCompareDays as $compareDay): ?>
|
||||
<a class="compare-day offset-<?= e((string) ($compareDay['offset'] ?? 0)) ?><?= !empty($compareDay['is_current']) ? ' is-current' : '' ?><?= empty($compareDay['has_content']) ? ' is-empty' : '' ?>" href="/?view=day&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>
|
||||
<span class="chart-chip chart-chip--cool">Aktivität pro Tag</span>
|
||||
|
||||
<button class="day-summary-card glass-panel<?= $dashboardHasContent ? ' is-filled' : '' ?>" type="button" data-summary-overlay-open>
|
||||
<span class="day-summary-card__label">Tagesbilanz</span>
|
||||
<strong class="day-summary-card__title"><?= $summaryComment !== '' ? e($summaryComment) : 'Tagesbilanz' ?></strong>
|
||||
</button>
|
||||
|
||||
<section class="dashboard-moments-block">
|
||||
<div class="section-head section-head--compact section-head--dashboard">
|
||||
<div>
|
||||
<p class="eyebrow">Deine Momente</p>
|
||||
<h2>Momente des Tages</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="timeline-list">
|
||||
<?php if ($dashboardTimeline === []): ?>
|
||||
<article class="timeline-card timeline-card--empty glass-panel">
|
||||
<div class="timeline-card__body">
|
||||
<h3>Noch keine Momente</h3>
|
||||
<p>Du kannst auch vergangene Tage jederzeit nachtragen.</p>
|
||||
</div>
|
||||
</article>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php foreach ($dashboardTimeline as $item): ?>
|
||||
<?php $sportType = ($item['type'] ?? '') === 'sport' ? find_sport_type($settings, (string) ($item['sport_type_id'] ?? '')) : null; ?>
|
||||
<?php $eventTone = signal_value_class(normalize_signal_value($item['mood'] ?? 0)); ?>
|
||||
<?php $eventPayload = encode_payload([
|
||||
'id' => (string) ($item['id'] ?? ''),
|
||||
'type' => (string) ($item['type'] ?? 'event'),
|
||||
'time' => (string) ($item['time'] ?? ''),
|
||||
'comment' => (string) ($item['comment'] ?? ''),
|
||||
'value' => (float) ($item['value'] ?? 0),
|
||||
'unit' => (string) ($item['unit'] ?? ''),
|
||||
'sport_type_id' => (string) ($item['sport_type_id'] ?? ''),
|
||||
'consumed' => !empty($item['consumed']),
|
||||
'mood' => normalize_signal_value($item['mood'] ?? 0),
|
||||
'energy' => normalize_signal_value($item['energy'] ?? 0),
|
||||
'stress' => normalize_signal_value($item['stress'] ?? 0),
|
||||
]); ?>
|
||||
<article class="timeline-card timeline-card--event timeline-card--<?= e($eventTone) ?> glass-panel" data-event-editable data-event-payload="<?= e($eventPayload) ?>">
|
||||
<div class="timeline-card__meta">
|
||||
<div class="timeline-card__icon-wrap" title="<?= e(day_event_type_label((string) $item['type'])) ?>">
|
||||
<img class="timeline-card__icon" src="<?= e(day_event_type_icon((string) $item['type'])) ?>" alt="">
|
||||
</div>
|
||||
<div>
|
||||
<strong><?= e($item['time'] !== '' ? $item['time'] : '--:--') ?></strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="timeline-card__body">
|
||||
<h3>
|
||||
<?php if ((string) ($item['type'] ?? '') === 'alcohol'): ?>
|
||||
<?= !empty($item['consumed']) ? 'Heute getrunken' : 'Heute nicht getrunken' ?>
|
||||
<?php else: ?>
|
||||
<?= e($item['comment'] !== '' ? $item['comment'] : day_event_type_label((string) $item['type'])) ?>
|
||||
<?php endif; ?>
|
||||
</h3>
|
||||
<?php if ((float) $item['value'] > 0): ?>
|
||||
<p class="timeline-card__value">
|
||||
<?= e(rtrim(rtrim(number_format((float) $item['value'], 2, ',', '.'), '0'), ',')) ?>
|
||||
<?= e((string) $item['unit']) ?>
|
||||
<?php if ($sportType !== null): ?>
|
||||
· <?= e((string) ($sportType['label'] ?? '')) ?>
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
<?php elseif ($sportType !== null): ?>
|
||||
<p class="timeline-card__value"><?= e((string) ($sportType['label'] ?? '')) ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ((string) ($item['comment'] ?? '') !== '' && (string) ($item['type'] ?? '') === 'alcohol'): ?>
|
||||
<p class="timeline-card__value"><?= e((string) $item['comment']) ?></p>
|
||||
<?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); ?>
|
||||
<span class="signal-pill signal-pill--<?= e(signal_badge_tone($value, $metric)) ?>">
|
||||
<strong><?= e($label) ?></strong>
|
||||
<span><?= $value >= 0 ? '+' : '' ?><?= e((string) $value) ?></span>
|
||||
</span>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<div class="bar-chart" data-chart-type="bars" data-series="sport" data-payload="<?= e($chartPayload) ?>"></div>
|
||||
</article>
|
||||
|
||||
<div class="dashboard-overlay" data-summary-overlay hidden>
|
||||
<div class="dashboard-overlay__backdrop" data-summary-overlay-close></div>
|
||||
<section class="dashboard-modal glass-panel dashboard-modal--summary" role="dialog" aria-modal="true">
|
||||
<div class="dashboard-modal__controls">
|
||||
<button class="dashboard-modal__round" type="button" data-summary-overlay-close>×</button>
|
||||
<button class="dashboard-modal__round dashboard-modal__round--confirm" type="submit" form="day-summary-form">✓</button>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/" enctype="multipart/form-data" class="dashboard-modal__form" id="day-summary-form">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="form_name" value="save_day_summary">
|
||||
<input type="hidden" name="date" value="<?= e((string) $dayEntry['date']) ?>">
|
||||
|
||||
<h2 class="dashboard-modal__title"><?= e(format_display_date((string) $dayEntry['date'], false)) ?></h2>
|
||||
<p class="dashboard-modal__subtitle">Deine Tagesbilanz</p>
|
||||
|
||||
<label class="dashboard-modal__textarea">
|
||||
<textarea name="summary_comment" rows="5" placeholder="Fasse deinen Tag zusammen"><?= e($summaryComment) ?></textarea>
|
||||
</label>
|
||||
|
||||
<div class="overlay-signal-grid overlay-signal-grid--summary-row">
|
||||
<?php foreach (['summary_mood' => ['Stimmung', $summaryMood], 'summary_energy' => ['Energie', $summaryEnergy], 'summary_stress' => ['Stress', $summaryStress]] as $field => [$label, $value]): ?>
|
||||
<?php $metric = str_replace('summary_', '', $field); ?>
|
||||
<div class="overlay-signal-card overlay-signal-card--inline" data-stepper data-stepper-metric="<?= e($metric) ?>">
|
||||
<div>
|
||||
<h3><?= e($label) ?></h3>
|
||||
<p>
|
||||
<?= e(signal_labels_for_metric($metric)[$value]) ?>
|
||||
</p>
|
||||
</div>
|
||||
<div class="overlay-signal-card__control">
|
||||
<div class="overlay-signal-card__ring tone-<?= e(signal_value_class($value)) ?>">
|
||||
<span data-stepper-value><?= $value >= 0 ? '+' : '' ?><?= e((string) $value) ?></span>
|
||||
</div>
|
||||
<div class="overlay-signal-card__buttons">
|
||||
<button type="button" data-stepper-minus>-</button>
|
||||
<button type="button" data-stepper-plus>+</button>
|
||||
</div>
|
||||
<input type="hidden" name="<?= e($field) ?>" value="<?= e((string) $value) ?>" data-stepper-input>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<fieldset class="moment-alcohol-field moment-alcohol-field--summary">
|
||||
<legend>Alkohol</legend>
|
||||
<div class="moment-choice-row">
|
||||
<label class="moment-choice-pill">
|
||||
<input type="radio" name="summary_alcohol" value="1" <?= $summaryAlcohol ? 'checked' : '' ?>>
|
||||
<span>Ja</span>
|
||||
</label>
|
||||
<label class="moment-choice-pill">
|
||||
<input type="radio" name="summary_alcohol" value="0" <?= !$summaryAlcohol ? 'checked' : '' ?>>
|
||||
<span>Nein</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<label>
|
||||
<span>Tagesbild</span>
|
||||
<input type="file" name="background_image" accept="image/jpeg,image/png,image/webp">
|
||||
</label>
|
||||
</form>
|
||||
|
||||
<?php if ($dayBackground !== null): ?>
|
||||
<form method="post" action="/" class="dashboard-modal__secondary-action">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="form_name" value="remove_background">
|
||||
<input type="hidden" name="date" value="<?= e((string) $dayEntry['date']) ?>">
|
||||
<button class="ghost-button" type="submit">Bild entfernen</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-overlay" data-moment-overlay hidden>
|
||||
<div class="dashboard-overlay__backdrop" data-moment-overlay-close></div>
|
||||
<section class="dashboard-modal glass-panel dashboard-modal--moment" role="dialog" aria-modal="true" data-moment-modal>
|
||||
<div class="dashboard-modal__controls">
|
||||
<button class="dashboard-modal__round" type="button" data-moment-overlay-close>×</button>
|
||||
<button class="dashboard-modal__round dashboard-modal__round--confirm" type="submit" form="moment-form" data-moment-submit disabled>✓</button>
|
||||
</div>
|
||||
|
||||
<div data-moment-step="choose">
|
||||
<h2 class="dashboard-modal__title">Neuer Moment</h2>
|
||||
<div class="moment-type-grid">
|
||||
<?php foreach ($dashboardEventTypes as $type => $meta): ?>
|
||||
<?php if ($type === 'alcohol') { continue; } ?>
|
||||
<button class="moment-type-card" type="button" data-moment-type-choice="<?= e($type) ?>">
|
||||
<img src="<?= e((string) $meta['icon']) ?>" alt="">
|
||||
<span><?= e((string) $meta['label']) ?></span>
|
||||
</button>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/" class="dashboard-modal__form" id="moment-form" data-moment-step="form" hidden>
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="form_name" value="add_event" data-moment-form-name>
|
||||
<input type="hidden" name="date" value="<?= e((string) $dayEntry['date']) ?>">
|
||||
<input type="hidden" name="event_id" value="" data-moment-event-id>
|
||||
<input type="hidden" name="event_type" value="event" data-moment-type-input>
|
||||
<input type="hidden" name="event_unit" value="" data-event-unit>
|
||||
<input type="hidden" name="event_walk_mode" value="time" data-walk-mode-input>
|
||||
|
||||
<div class="dashboard-modal__heading-row">
|
||||
<div>
|
||||
<p class="dashboard-modal__subtitle" data-moment-type-label>Neuer Moment</p>
|
||||
<h2 class="dashboard-modal__title">Was ist passiert?</h2>
|
||||
</div>
|
||||
<button class="ghost-button ghost-button--small" type="button" data-moment-back>Typ ändern</button>
|
||||
</div>
|
||||
|
||||
<label class="dashboard-modal__textarea">
|
||||
<textarea name="event_comment" rows="4" placeholder="Was hast du erlebt?" data-moment-comment></textarea>
|
||||
</label>
|
||||
|
||||
<div class="field-grid field-grid--two">
|
||||
<label>
|
||||
<span>Erfasst um</span>
|
||||
<input type="time" name="event_time" value="<?= e(date('H:i')) ?>" required>
|
||||
</label>
|
||||
<label data-moment-value-field>
|
||||
<span data-moment-value-label>Wert</span>
|
||||
<input type="number" name="event_value" min="0" max="50000" step="0.25" placeholder="optional" data-moment-value-input>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<fieldset data-moment-sport-field hidden>
|
||||
<legend>Sportart</legend>
|
||||
<input type="hidden" name="event_sport_type_id" value="">
|
||||
<div class="moment-type-grid moment-type-grid--sport">
|
||||
<?php foreach ($dashboardSportTypes as $sportType): ?>
|
||||
<button class="moment-type-card moment-type-card--sport" type="button" data-sport-choice="<?= e((string) ($sportType['id'] ?? '')) ?>">
|
||||
<img src="<?= e(sport_icon_path((string) ($sportType['icon'] ?? 'run'))) ?>" alt="">
|
||||
<span><?= e((string) ($sportType['label'] ?? '')) ?></span>
|
||||
</button>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="moment-alcohol-field" data-moment-walk-field hidden>
|
||||
<legend>Spaziergang als</legend>
|
||||
<div class="moment-choice-row">
|
||||
<label class="moment-choice-pill"><input type="radio" name="event_walk_mode" value="time" checked><span>Dauer</span></label>
|
||||
<label class="moment-choice-pill"><input type="radio" name="event_walk_mode" value="steps"><span>Schritte</span></label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="moment-alcohol-field" data-moment-alcohol-field hidden>
|
||||
<legend>Heute Alkohol getrunken?</legend>
|
||||
<div class="moment-choice-row">
|
||||
<label class="moment-choice-pill">
|
||||
<input type="radio" name="event_consumed" value="1" checked>
|
||||
<span>Ja</span>
|
||||
</label>
|
||||
<label class="moment-choice-pill">
|
||||
<input type="radio" name="event_consumed" value="0">
|
||||
<span>Nein</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="overlay-signal-grid overlay-signal-grid--summary-row overlay-signal-grid--moment">
|
||||
<?php foreach (['event_mood' => ['Stimmung', 0], 'event_energy' => ['Energie', 0], 'event_stress' => ['Stress', 0]] as $field => [$label, $value]): ?>
|
||||
<?php $metric = str_replace('event_', '', $field); ?>
|
||||
<div class="overlay-signal-card overlay-signal-card--inline overlay-signal-card--moment" data-stepper data-stepper-metric="<?= e($metric) ?>">
|
||||
<div>
|
||||
<h3><?= e($label) ?></h3>
|
||||
</div>
|
||||
<div class="overlay-signal-card__control">
|
||||
<div class="overlay-signal-card__ring tone-zero">
|
||||
<span data-stepper-value><?= $value >= 0 ? '+' : '' ?><?= e((string) $value) ?></span>
|
||||
</div>
|
||||
<div class="overlay-signal-card__buttons">
|
||||
<button type="button" data-stepper-minus>-</button>
|
||||
<button type="button" data-stepper-plus>+</button>
|
||||
</div>
|
||||
<input type="hidden" name="<?= e($field) ?>" value="<?= e((string) $value) ?>" data-stepper-input>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form method="post" action="/" class="dashboard-modal__secondary-action" data-moment-delete-form hidden>
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="form_name" value="delete_event">
|
||||
<input type="hidden" name="date" value="<?= e((string) $dayEntry['date']) ?>">
|
||||
<input type="hidden" name="event_id" value="" data-moment-delete-id>
|
||||
<button class="ghost-button" type="submit">Moment löschen</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
<?php elseif ($dashboardView === 'week'): ?>
|
||||
<section class="dashboard-range-view dashboard-range-view--week">
|
||||
<header class="dashboard-range-view__hero">
|
||||
<p class="eyebrow">Wochenansicht</p>
|
||||
<h1><?= e($dashboardWeek['title']) ?></h1>
|
||||
<h2><?= e($dashboardWeek['range']) ?></h2>
|
||||
</header>
|
||||
|
||||
<div class="range-period-rail range-period-rail--week">
|
||||
<?php foreach (($dashboardWeek['periods'] ?? [$dashboardWeek]) as $week): ?>
|
||||
<article class="range-period-panel<?= !empty($week['is_selected']) ? ' is-selected' : '' ?>">
|
||||
<header class="range-period-panel__head">
|
||||
<a href="/?view=week&date=<?= e(rawurlencode((string) ($week['key'] ?? $dashboardDate))) ?>">
|
||||
<h3><?= e((string) $week['title']) ?></h3>
|
||||
<p><?= e((string) $week['range']) ?></p>
|
||||
</a>
|
||||
</header>
|
||||
|
||||
<nav class="range-score-strip range-score-strip--week glass-panel" aria-label="Tage dieser Woche">
|
||||
<?php foreach ($week['days'] as $day): ?>
|
||||
<a class="range-score-day<?= !empty($day['is_current']) ? ' is-current' : '' ?><?= empty($day['has_content']) ? ' is-empty' : '' ?>" href="/?view=week&date=<?= e(rawurlencode((string) $day['date'])) ?>" title="<?= e((string) $day['weekday']) ?>">
|
||||
<span class="compare-day__line<?= !empty($day['is_current']) ? ' is-primary' : '' ?> score-<?= e((string) ($day['score_level'] ?? 'empty')) ?> compare-tone-<?= e((string) ($day['line_tone'] ?? 'empty')) ?>">
|
||||
<span class="compare-day__marker"></span>
|
||||
</span>
|
||||
<span class="range-score-day__label"><?= e((string) $day['day']) ?></span>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</nav>
|
||||
</article>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<?php $weekDetailDays = array_values(array_filter($dashboardWeek['days'], static fn (array $day): bool => !empty($day['has_content']))); ?>
|
||||
<?php if ($weekDetailDays !== []): ?>
|
||||
<div class="range-day-list">
|
||||
<?php foreach ($weekDetailDays as $day): ?>
|
||||
<?php
|
||||
$entry = is_array($day['entry'] ?? null) ? $day['entry'] : null;
|
||||
$events = $entry !== null && is_array($entry['events'] ?? null) ? $entry['events'] : [];
|
||||
$summaryText = $entry !== null ? trim((string) ($entry['summary']['comment'] ?? $entry['summary_comment'] ?? '')) : '';
|
||||
$dayTone = (string) ($day['line_tone'] ?? 'empty');
|
||||
$dayImage = $entry !== null && is_string($entry['background_image_url'] ?? null) ? (string) $entry['background_image_url'] : null;
|
||||
?>
|
||||
<a class="range-day-card range-day-card--<?= e($dayTone) ?><?= empty($day['has_content']) ? ' is-empty' : '' ?> glass-panel" href="/?view=day&date=<?= e(rawurlencode((string) $day['date'])) ?>">
|
||||
<?php if ($dayImage !== null): ?>
|
||||
<img class="range-day-card__image" src="<?= e($dayImage) ?>" alt="">
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="range-day-card__body">
|
||||
<p class="eyebrow"><?= e((string) $day['weekday']) ?></p>
|
||||
<p class="range-day-card__summary"><?= $summaryText !== '' ? e($summaryText) : 'Noch keine Tagesbilanz.' ?></p>
|
||||
|
||||
<?php if ($events !== []): ?>
|
||||
<ul class="range-moment-list">
|
||||
<?php foreach ($events as $event): ?>
|
||||
<?php if (!is_array($event)) { continue; } ?>
|
||||
<?php
|
||||
$eventType = (string) ($event['type'] ?? 'event');
|
||||
$eventScore = signal_combo_score($event['mood'] ?? 0, $event['energy'] ?? 0, $event['stress'] ?? 0);
|
||||
$eventTone = signal_value_class($eventScore);
|
||||
$sportType = $eventType === 'sport' ? find_sport_type($settings, (string) ($event['sport_type_id'] ?? '')) : null;
|
||||
$eventValue = (float) ($event['value'] ?? 0);
|
||||
$eventValueText = $eventValue > 0 ? rtrim(rtrim(number_format($eventValue, 2, ',', '.'), '0'), ',') . ' ' . (string) ($event['unit'] ?? '') : '';
|
||||
$eventTitle = trim((string) ($event['comment'] ?? '')) !== '' ? trim((string) $event['comment']) : day_event_type_label($eventType);
|
||||
$eventDetail = $eventValueText;
|
||||
|
||||
if ($eventType === 'sport') {
|
||||
$eventTitle = (string) ($sportType['label'] ?? 'Sport');
|
||||
}
|
||||
|
||||
if ($eventType === 'sleep') {
|
||||
$eventTitle = 'Schlaf';
|
||||
}
|
||||
?>
|
||||
<li class="range-moment-list__item range-moment-list__item--<?= e($eventTone) ?>">
|
||||
<span class="range-moment-list__bullet" aria-hidden="true"></span>
|
||||
<span>
|
||||
<strong><?= e($eventTitle) ?></strong>
|
||||
<?php if ($eventDetail !== ''): ?>
|
||||
<span><?= e($eventDetail) ?></span>
|
||||
<?php endif; ?>
|
||||
</span>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
<?php else: ?>
|
||||
<section class="dashboard-range-view dashboard-range-view--month">
|
||||
<header class="dashboard-range-view__hero">
|
||||
<p class="eyebrow">Monatsansicht</p>
|
||||
<h1><?= e($dashboardMonth['title']) ?></h1>
|
||||
</header>
|
||||
|
||||
<div class="range-period-rail range-period-rail--month">
|
||||
<?php foreach (($dashboardMonth['periods'] ?? [$dashboardMonth]) as $month): ?>
|
||||
<article class="range-period-panel<?= !empty($month['is_selected']) ? ' is-selected' : '' ?>">
|
||||
<header class="range-period-panel__head">
|
||||
<a href="/?view=month&date=<?= e(rawurlencode((string) ($month['key'] ?? $dashboardDate))) ?>">
|
||||
<h3><?= e((string) $month['title']) ?></h3>
|
||||
</a>
|
||||
</header>
|
||||
|
||||
<nav class="range-score-strip range-score-strip--month glass-panel" aria-label="Tage dieses Monats">
|
||||
<?php foreach ($month['days'] as $day): ?>
|
||||
<a class="range-score-day<?= empty($day['has_content']) ? ' is-empty' : '' ?><?= !empty($day['is_future']) ? ' is-future' : '' ?>" href="/?view=month&date=<?= e(rawurlencode((string) $day['date'])) ?>" title="<?= e((string) $day['weekday']) ?>">
|
||||
<span class="compare-day__line score-<?= e((string) ($day['score_level'] ?? 'empty')) ?> compare-tone-<?= e((string) ($day['line_tone'] ?? 'empty')) ?>">
|
||||
<span class="compare-day__marker"></span>
|
||||
</span>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</nav>
|
||||
</article>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<?php $monthDetailDays = array_values(array_filter($dashboardMonth['days'], static function (array $day): bool {
|
||||
$entry = is_array($day['entry'] ?? null) ? $day['entry'] : null;
|
||||
$summaryText = $entry !== null ? trim((string) ($entry['summary']['comment'] ?? $entry['summary_comment'] ?? '')) : '';
|
||||
|
||||
return !empty($day['has_content']) || $summaryText !== '';
|
||||
})); ?>
|
||||
<?php if ($monthDetailDays !== []): ?>
|
||||
<div class="range-day-list range-day-list--month">
|
||||
<?php foreach ($monthDetailDays as $day): ?>
|
||||
<?php
|
||||
$entry = is_array($day['entry'] ?? null) ? $day['entry'] : null;
|
||||
$summaryText = $entry !== null ? trim((string) ($entry['summary']['comment'] ?? $entry['summary_comment'] ?? '')) : '';
|
||||
$dayTone = (string) ($day['line_tone'] ?? 'empty');
|
||||
?>
|
||||
<a class="range-day-card range-day-card--summary-only range-day-card--<?= e($dayTone) ?> glass-panel" href="/?view=day&date=<?= e(rawurlencode((string) $day['date'])) ?>">
|
||||
<div class="range-day-card__body">
|
||||
<p class="eyebrow"><?= e((string) $day['weekday']) ?></p>
|
||||
<p class="range-day-card__summary"><?= $summaryText !== '' ? e($summaryText) : 'Tagesbilanz' ?></p>
|
||||
</div>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="dashboard-overlay" data-settings-menu-overlay hidden>
|
||||
<div class="dashboard-overlay__backdrop" data-settings-menu-close></div>
|
||||
<section class="dashboard-modal dashboard-modal--settings glass-panel" role="dialog" aria-modal="true">
|
||||
<div class="dashboard-modal__controls">
|
||||
<button class="dashboard-modal__round" type="button" data-settings-menu-close>×</button>
|
||||
</div>
|
||||
|
||||
<h2 class="dashboard-modal__title">Einstellungen und Bereiche</h2>
|
||||
<div class="settings-menu-grid">
|
||||
<a class="options-menu-card" href="/options?panel=score"><strong>Score anpassen</strong><span>Multiplikatoren und Tageslogik</span></a>
|
||||
<a class="options-menu-card" href="/options?panel=sports"><strong>Sportarten anpassen</strong><span>Eigene Sportarten und Bonuspunkte</span></a>
|
||||
<a class="options-menu-card" href="/options?panel=walk"><strong>Spaziergang anpassen</strong><span>Zeit oder Schritte auswerten</span></a>
|
||||
<a class="options-menu-card" href="/options?panel=reminders"><strong>Erinnerungen setzen</strong><span>Push und tägliche Erinnerung</span></a>
|
||||
<a class="options-menu-card" href="/options?panel=ratings"><strong>Bewertungsskala ändern</strong><span>Labels und Schutzregeln</span></a>
|
||||
<a class="options-menu-card" href="/options?panel=stats"><strong>Statistik</strong><span>Verlauf und Aktivität</span></a>
|
||||
<?php if (!empty($authUser['is_admin'])): ?>
|
||||
<a class="options-menu-card" href="/options?panel=users"><strong>Neue Nutzer anlegen</strong><span>Accounts und Adminrechte</span></a>
|
||||
<?php endif; ?>
|
||||
<form method="post" action="/logout" class="options-logout-form">
|
||||
<?= csrf_field() ?>
|
||||
<button class="options-menu-card options-menu-card--danger" type="submit"><strong>Abmelden</strong><span>Sitzung sicher beenden</span></button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
+241
-433
@@ -1,449 +1,257 @@
|
||||
<section class="page-grid">
|
||||
<article class="glass-panel form-panel form-panel--wide">
|
||||
<section class="options-shell">
|
||||
<article class="glass-panel options-menu-panel">
|
||||
<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>
|
||||
<p class="eyebrow">Optionen</p>
|
||||
<h3>Einstellungen und Bereiche</h3>
|
||||
</div>
|
||||
<span class="chart-chip">Maximal <?= e(format_points((float) $maxScore)) ?> Punkte</span>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/options" class="stack-form stack-form--spacious">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="form_name" value="settings">
|
||||
|
||||
<div class="settings-section">
|
||||
<h4>Multiplikatoren</h4>
|
||||
<div class="field-grid field-grid--four">
|
||||
<label><span>Stimmung</span><input type="number" name="settings[scoring][mood_multiplier]" value="<?= e((string) $settings['scoring']['mood_multiplier']) ?>" min="0" max="10"></label>
|
||||
<label><span>Energie</span><input type="number" name="settings[scoring][energy_multiplier]" value="<?= e((string) $settings['scoring']['energy_multiplier']) ?>" min="0" max="10"></label>
|
||||
<label><span>Stress</span><input type="number" name="settings[scoring][stress_multiplier]" value="<?= e((string) $settings['scoring']['stress_multiplier']) ?>" min="0" max="10"></label>
|
||||
<label><span>Schlafgefühl</span><input type="number" name="settings[scoring][sleep_feeling_multiplier]" value="<?= e((string) $settings['scoring']['sleep_feeling_multiplier']) ?>" min="0" max="10"></label>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
<button class="ghost-button" type="button" data-add-sport-type>Sportart hinzufügen</button>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="settings[sport_types_present]" value="1">
|
||||
|
||||
<?php if (!empty($sportTypePresets)): ?>
|
||||
<div class="preset-list">
|
||||
<?php foreach ($sportTypePresets as $preset): ?>
|
||||
<button
|
||||
class="preset-pill"
|
||||
type="button"
|
||||
data-sport-preset
|
||||
data-id="<?= e($preset['id']) ?>"
|
||||
data-label="<?= e($preset['label']) ?>"
|
||||
data-icon="<?= e($preset['icon']) ?>"
|
||||
data-location="<?= e($preset['location'] ?? '') ?>"
|
||||
data-recovery-group="<?= e($preset['recovery_group']) ?>"
|
||||
data-bonus-points="<?= e((string) $preset['bonus_points']) ?>"
|
||||
data-allow-consecutive="<?= !empty($preset['allow_consecutive']) ? '1' : '0' ?>"
|
||||
>
|
||||
<img src="<?= e(sport_icon_path($preset['icon'])) ?>" alt="">
|
||||
<span><?= e($preset['label']) ?></span>
|
||||
</button>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="sport-type-list" data-sport-type-list>
|
||||
<?php foreach ($settings['sport_types'] as $index => $sportType): ?>
|
||||
<div class="sport-type-card band-card" data-sport-type-row>
|
||||
<input type="hidden" name="settings[sport_types][<?= e((string) $index) ?>][id]" value="<?= e($sportType['id']) ?>" data-name-template="settings[sport_types][__INDEX__][id]">
|
||||
|
||||
<div class="field-grid field-grid--four">
|
||||
<label>
|
||||
<span>Bezeichnung</span>
|
||||
<input type="text" name="settings[sport_types][<?= e((string) $index) ?>][label]" value="<?= e($sportType['label']) ?>" data-name-template="settings[sport_types][__INDEX__][label]">
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Icon</span>
|
||||
<select name="settings[sport_types][<?= e((string) $index) ?>][icon]" data-name-template="settings[sport_types][__INDEX__][icon]">
|
||||
<?php foreach (sport_icon_options() as $iconValue => $iconLabel): ?>
|
||||
<option value="<?= e($iconValue) ?>" <?= $sportType['icon'] === $iconValue ? 'selected' : '' ?>><?= e($iconLabel) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Ort</span>
|
||||
<select name="settings[sport_types][<?= e((string) $index) ?>][location]" data-name-template="settings[sport_types][__INDEX__][location]">
|
||||
<?php foreach ($sportLocationOptions as $locationValue => $locationLabel): ?>
|
||||
<option value="<?= e($locationValue) ?>" <?= ($sportType['location'] ?? '') === $locationValue ? 'selected' : '' ?>><?= e($locationLabel) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Erholungsgruppe optional</span>
|
||||
<input type="text" name="settings[sport_types][<?= e((string) $index) ?>][recovery_group]" value="<?= e($sportType['recovery_group']) ?>" data-name-template="settings[sport_types][__INDEX__][recovery_group]">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="field-grid field-grid--four">
|
||||
<label>
|
||||
<span>Bonuspunkte</span>
|
||||
<input type="number" name="settings[sport_types][<?= e((string) $index) ?>][bonus_points]" value="<?= e((string) $sportType['bonus_points']) ?>" min="0" max="20" data-name-template="settings[sport_types][__INDEX__][bonus_points]">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="checkbox-row">
|
||||
<input type="checkbox" name="settings[sport_types][<?= e((string) $index) ?>][allow_consecutive]" value="1" <?= !empty($sportType['allow_consecutive']) ? 'checked' : '' ?> data-name-template="settings[sport_types][__INDEX__][allow_consecutive]">
|
||||
<span>Darf an aufeinanderfolgenden Tagen Bonus geben</span>
|
||||
</label>
|
||||
|
||||
<p class="helper-text">Die Erholungsgruppe brauchst du nur, wenn mehrere Varianten dieselbe Belastung teilen sollen, zum Beispiel Krafttraining Zuhause und Krafttraining Auswärts. Wenn du nichts Besonderes einträgst, reicht die Sportart selbst.</p>
|
||||
|
||||
<div class="sport-type-card__actions">
|
||||
<span class="sport-pill sport-pill--soft">
|
||||
<img src="<?= e(sport_icon_path($sportType['icon'])) ?>" alt="">
|
||||
<span><?= e($sportType['label']) ?><?= !empty($sportType['location']) ? ' · ' . e(sport_location_label((string) $sportType['location'])) : '' ?></span>
|
||||
</span>
|
||||
<button class="ghost-button ghost-button--small" type="button" data-remove-sport-type>Entfernen</button>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<div class="section-head section-head--compact">
|
||||
<div>
|
||||
<h4>Erinnerungen</h4>
|
||||
<p class="helper-text">Diese Erinnerungen gelten nur für deinen Account. Auf dem iPhone funktioniert Push nach dem Hinzufügen zum Home-Bildschirm.</p>
|
||||
</div>
|
||||
<span class="chart-chip"><?= e((string) $pushSubscriptionCount) ?> Gerät<?= $pushSubscriptionCount === 1 ? '' : 'e' ?></span>
|
||||
</div>
|
||||
|
||||
<div class="field-grid field-grid--two">
|
||||
<label class="checkbox-row checkbox-row--panel">
|
||||
<input type="checkbox" name="settings[notifications][enabled]" value="1" <?= !empty($settings['notifications']['enabled']) ? 'checked' : '' ?>>
|
||||
<span>Tägliche Push-Erinnerung aktivieren</span>
|
||||
</label>
|
||||
|
||||
<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>
|
||||
|
||||
<div class="section-actions">
|
||||
<button class="primary-button" type="submit">Erinnerungen speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h4>Bewertungsskala</h4>
|
||||
<p class="helper-text">Diese Score-Grenzen und Schutzregeln gelten ebenfalls nur für deinen eigenen Account.</p>
|
||||
<div class="band-grid">
|
||||
<?php foreach ($settings['ratings'] as $index => $rating): ?>
|
||||
<div class="band-card">
|
||||
<label><span>Label</span><input type="text" name="settings[ratings][<?= e((string) $index) ?>][label]" value="<?= e($rating['label']) ?>"></label>
|
||||
<label><span>Min</span><input type="number" name="settings[ratings][<?= e((string) $index) ?>][min]" value="<?= e((string) $rating['min']) ?>"></label>
|
||||
<label><span>Max</span><input type="number" name="settings[ratings][<?= e((string) $index) ?>][max]" value="<?= e((string) $rating['max']) ?>"></label>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h4>Schutzregeln</h4>
|
||||
<div class="band-grid">
|
||||
<?php foreach ($settings['guardrails'] as $index => $guardrail): ?>
|
||||
<div class="band-card">
|
||||
<label><span>Stimmung max</span><input type="number" name="settings[guardrails][<?= e((string) $index) ?>][mood_max]" value="<?= e((string) $guardrail['mood_max']) ?>" min="1" max="10"></label>
|
||||
<label><span>Energie max</span><input type="number" name="settings[guardrails][<?= e((string) $index) ?>][energy_max]" value="<?= e((string) ($guardrail['energy_max'] ?? '')) ?>" min="1" max="10"></label>
|
||||
<label><span>Maximales Label</span><input type="text" name="settings[guardrails][<?= e((string) $index) ?>][cap_label]" value="<?= e($guardrail['cap_label']) ?>"></label>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
<span>Tagebuchpunkte bei nicht-leerer Notiz</span>
|
||||
<input type="number" name="settings[scoring][journal_points]" value="<?= e((string) $settings['scoring']['journal_points']) ?>" min="0" max="20">
|
||||
</label>
|
||||
|
||||
<button class="primary-button" type="submit">Bewertung speichern</button>
|
||||
</form>
|
||||
<div class="options-menu-grid">
|
||||
<button class="options-menu-card" type="button" data-options-open="score">
|
||||
<strong>Score anpassen</strong>
|
||||
<span>Multiplikatoren und Tageslogik</span>
|
||||
</button>
|
||||
<button class="options-menu-card" type="button" data-options-open="sports">
|
||||
<strong>Sportarten anpassen</strong>
|
||||
<span>Eigene Sportarten und Bonuspunkte</span>
|
||||
</button>
|
||||
<button class="options-menu-card" type="button" data-options-open="walk">
|
||||
<strong>Spaziergang anpassen</strong>
|
||||
<span>Zeit oder Schritte auswerten</span>
|
||||
</button>
|
||||
<button class="options-menu-card" type="button" data-options-open="reminders">
|
||||
<strong>Erinnerungen setzen</strong>
|
||||
<span>Push und tägliche Erinnerung</span>
|
||||
</button>
|
||||
<button class="options-menu-card" type="button" data-options-open="ratings">
|
||||
<strong>Bewertungsskala ändern</strong>
|
||||
<span>Labels und Schutzregeln</span>
|
||||
</button>
|
||||
<button class="options-menu-card" type="button" data-options-open="stats">
|
||||
<strong>Statistik</strong>
|
||||
<span>Verlauf und Aktivität</span>
|
||||
</button>
|
||||
<?php if (!empty($authUser['is_admin'])): ?>
|
||||
<button class="options-menu-card" type="button" data-options-open="users">
|
||||
<strong>Neue Nutzer anlegen</strong>
|
||||
<span>Accounts und Adminrechte</span>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
<button class="options-menu-card" type="button" data-options-open="security">
|
||||
<strong>Sicherheit</strong>
|
||||
<span>Passwort und Backup</span>
|
||||
</button>
|
||||
<?php if (!empty($authUser['is_admin'])): ?>
|
||||
<button class="options-menu-card" type="button" data-options-open="ai">
|
||||
<strong>KI</strong>
|
||||
<span>OpenAI und Zusammenfassungen</span>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
<form method="post" action="/logout" class="options-logout-form">
|
||||
<?= csrf_field() ?>
|
||||
<button class="options-menu-card options-menu-card--danger" type="submit">
|
||||
<strong>Abmelden</strong>
|
||||
<span>Sitzung sicher beenden</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<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>
|
||||
<div class="options-overlay" data-options-overlay<?= !empty($optionsOpenPanel) ? '' : ' hidden' ?> data-open-panel="<?= e((string) ($optionsOpenPanel ?? '')) ?>">
|
||||
<div class="options-overlay__backdrop" data-options-close></div>
|
||||
<section class="options-modal glass-panel" role="dialog" aria-modal="true">
|
||||
<div class="options-modal__controls">
|
||||
<button class="dashboard-modal__round" type="button" data-options-back>‹</button>
|
||||
<button class="dashboard-modal__round" type="button" data-options-close>×</button>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/options" class="stack-form">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="form_name" value="export_backup">
|
||||
<button class="primary-button" type="submit" <?= empty($backupAvailable) ? 'disabled' : '' ?>>Backup herunterladen</button>
|
||||
<?php if (empty($backupAvailable)): ?>
|
||||
<p class="helper-text">Auf diesem Server fehlt gerade die ZIP-Erweiterung für den Download.</p>
|
||||
<?php endif; ?>
|
||||
</form>
|
||||
|
||||
<form method="post" action="/options" enctype="multipart/form-data" class="stack-form">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="form_name" value="import_backup">
|
||||
<label>
|
||||
<span>Backup importieren</span>
|
||||
<input type="file" name="backup_files[]" accept=".zip,.txt" multiple>
|
||||
</label>
|
||||
<p class="helper-text">Du kannst ein komplettes ZIP-Backup oder einzelne Tagesdateien wie `2026-04-13.txt` importieren. Vorhandene Tage mit demselben Datum werden dabei aktualisiert.</p>
|
||||
<button class="ghost-button" type="submit">Backup importieren</button>
|
||||
</form>
|
||||
</article>
|
||||
|
||||
<article class="glass-panel detail-card">
|
||||
<p class="eyebrow">Sicherheit</p>
|
||||
<h3>Passwort ändern</h3>
|
||||
<form method="post" action="/options" class="stack-form">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="form_name" value="password">
|
||||
<label><span>Aktuelles Passwort</span><input type="password" name="current_password" required></label>
|
||||
<label><span>Neues Passwort</span><input type="password" name="new_password" minlength="10" required></label>
|
||||
<label><span>Neues Passwort wiederholen</span><input type="password" name="new_password_confirm" minlength="10" required></label>
|
||||
<button class="primary-button" type="submit">Passwort aktualisieren</button>
|
||||
</form>
|
||||
</article>
|
||||
|
||||
<?php if (!empty($authUser['is_admin'])): ?>
|
||||
<article class="glass-panel detail-card">
|
||||
<p class="eyebrow">KI</p>
|
||||
<h3>OpenAI für Zusammenfassungen</h3>
|
||||
<p class="helper-text">Diese Einstellung gilt zentral für alle Nutzer. Der API-Key bleibt in der Umgebung des Servers und wird hier bewusst nicht gespeichert.</p>
|
||||
|
||||
<?php if (!empty($aiStatus)): ?>
|
||||
<div class="user-list">
|
||||
<div class="user-row">
|
||||
<strong>API-Key</strong>
|
||||
<span><?= !empty($aiStatus['has_api_key']) ? 'vorhanden' : 'fehlt' ?></span>
|
||||
</div>
|
||||
<div class="user-row">
|
||||
<strong>Aktuelles Modell</strong>
|
||||
<span><?= e((string) ($aiStatus['model'] ?? '')) ?></span>
|
||||
</div>
|
||||
<div class="user-row">
|
||||
<strong>Timeout</strong>
|
||||
<span><?= e((string) ($aiStatus['timeout'] ?? '')) ?> s</span>
|
||||
<div class="options-panel" data-options-panel="score" hidden>
|
||||
<h2>Score 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>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" action="/options" class="stack-form">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="form_name" value="ai_config">
|
||||
<label><span>OpenAI-Modell</span><input type="text" name="ai[model]" value="<?= e((string) ($aiConfig['model'] ?? 'gpt-4o-mini')) ?>" required></label>
|
||||
<label><span>Timeout in Sekunden</span><input type="number" name="ai[timeout]" value="<?= e((string) ($aiConfig['timeout'] ?? 25)) ?>" min="5" max="120" required></label>
|
||||
<button class="primary-button" type="submit">KI-Konfiguration speichern</button>
|
||||
<div class="settings-section">
|
||||
<h4>Tracking-Felder</h4>
|
||||
<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>Schmerzen werden weiter in den Score einbezogen.</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>
|
||||
<button class="primary-button" type="submit">Score speichern</button>
|
||||
</form>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<article class="glass-panel detail-card">
|
||||
<p class="eyebrow">Mehrere Accounts</p>
|
||||
<h3>Neuen Nutzer anlegen</h3>
|
||||
<form method="post" action="/options" class="stack-form">
|
||||
<div class="options-panel" data-options-panel="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="create_user">
|
||||
<label><span>Benutzername</span><input type="text" name="username" required></label>
|
||||
<label><span>Startpasswort</span><input type="password" name="password" minlength="10" required></label>
|
||||
<label class="checkbox-row"><input type="checkbox" name="is_admin" value="1"><span>Als Admin anlegen</span></label>
|
||||
<button class="primary-button" type="submit">Account erstellen</button>
|
||||
</form>
|
||||
|
||||
<?php if ($users !== []): ?>
|
||||
<div class="user-list">
|
||||
<?php foreach ($users as $account): ?>
|
||||
<div class="user-row">
|
||||
<strong><?= e($account['username']) ?></strong>
|
||||
<span><?= !empty($account['is_admin']) ? 'Admin' : 'Nutzer' ?></span>
|
||||
<input type="hidden" name="form_name" value="settings">
|
||||
<div class="settings-section">
|
||||
<div class="section-head section-head--compact">
|
||||
<div>
|
||||
<h4>Sportarten und Bonuspunkte</h4>
|
||||
<p class="helper-text">Diese Sportarten stehen in deinen Momenten zur Auswahl.</p>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<button class="ghost-button" type="button" data-add-sport-type>Sportart hinzufügen</button>
|
||||
</div>
|
||||
<input type="hidden" name="settings[sport_types_present]" value="1">
|
||||
<?php if (!empty($sportTypePresets)): ?>
|
||||
<div class="preset-list">
|
||||
<?php foreach ($sportTypePresets as $preset): ?>
|
||||
<button class="preset-pill" type="button" data-sport-preset data-id="<?= e($preset['id']) ?>" data-label="<?= e($preset['label']) ?>" data-icon="<?= e($preset['icon']) ?>" data-location="<?= e($preset['location'] ?? '') ?>" data-recovery-group="<?= e($preset['recovery_group']) ?>" data-bonus-points="<?= e((string) $preset['bonus_points']) ?>" data-allow-consecutive="<?= !empty($preset['allow_consecutive']) ? '1' : '0' ?>">
|
||||
<img src="<?= e(sport_icon_path($preset['icon'])) ?>" alt=""><span><?= e($preset['label']) ?></span>
|
||||
</button>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="sport-type-list" data-sport-type-list>
|
||||
<?php foreach ($settings['sport_types'] as $index => $sportType): ?>
|
||||
<div class="sport-type-card band-card" data-sport-type-row>
|
||||
<input type="hidden" name="settings[sport_types][<?= e((string) $index) ?>][id]" value="<?= e($sportType['id']) ?>" data-name-template="settings[sport_types][__INDEX__][id]">
|
||||
<div class="field-grid field-grid--four">
|
||||
<label><span>Bezeichnung</span><input type="text" name="settings[sport_types][<?= e((string) $index) ?>][label]" value="<?= e($sportType['label']) ?>" data-name-template="settings[sport_types][__INDEX__][label]"></label>
|
||||
<label><span>Icon</span><select name="settings[sport_types][<?= e((string) $index) ?>][icon]" data-name-template="settings[sport_types][__INDEX__][icon]"><?php foreach (sport_icon_options() as $iconValue => $iconLabel): ?><option value="<?= e($iconValue) ?>" <?= $sportType['icon'] === $iconValue ? 'selected' : '' ?>><?= e($iconLabel) ?></option><?php endforeach; ?></select></label>
|
||||
<label><span>Ort</span><select name="settings[sport_types][<?= e((string) $index) ?>][location]" data-name-template="settings[sport_types][__INDEX__][location]"><?php foreach ($sportLocationOptions as $locationValue => $locationLabel): ?><option value="<?= e($locationValue) ?>" <?= ($sportType['location'] ?? '') === $locationValue ? 'selected' : '' ?>><?= e($locationLabel) ?></option><?php endforeach; ?></select></label>
|
||||
<label><span>Erholungsgruppe</span><input type="text" name="settings[sport_types][<?= e((string) $index) ?>][recovery_group]" value="<?= e($sportType['recovery_group']) ?>" data-name-template="settings[sport_types][__INDEX__][recovery_group]"></label>
|
||||
</div>
|
||||
<div class="field-grid field-grid--four"><label><span>Bonuspunkte</span><input type="number" name="settings[sport_types][<?= e((string) $index) ?>][bonus_points]" value="<?= e((string) $sportType['bonus_points']) ?>" min="0" max="20" data-name-template="settings[sport_types][__INDEX__][bonus_points]"></label></div>
|
||||
<label class="checkbox-row"><input type="checkbox" name="settings[sport_types][<?= e((string) $index) ?>][allow_consecutive]" value="1" <?= !empty($sportType['allow_consecutive']) ? 'checked' : '' ?> data-name-template="settings[sport_types][__INDEX__][allow_consecutive]"><span>Darf an Folgetagen Bonus geben</span></label>
|
||||
<div class="sport-type-card__actions"><button class="ghost-button ghost-button--small" type="button" data-remove-sport-type>Entfernen</button></div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<template id="sport-type-row-template">
|
||||
<div class="sport-type-card band-card" data-sport-type-row>
|
||||
<input type="hidden" value="" data-name-template="settings[sport_types][__INDEX__][id]">
|
||||
<div class="field-grid field-grid--four">
|
||||
<label><span>Bezeichnung</span><input type="text" value="" data-name-template="settings[sport_types][__INDEX__][label]"></label>
|
||||
<label><span>Icon</span><select data-name-template="settings[sport_types][__INDEX__][icon]"><?php foreach (sport_icon_options() as $iconValue => $iconLabel): ?><option value="<?= e($iconValue) ?>"><?= e($iconLabel) ?></option><?php endforeach; ?></select></label>
|
||||
<label><span>Ort</span><select data-name-template="settings[sport_types][__INDEX__][location]"><?php foreach ($sportLocationOptions as $locationValue => $locationLabel): ?><option value="<?= e($locationValue) ?>"><?= e($locationLabel) ?></option><?php endforeach; ?></select></label>
|
||||
<label><span>Erholungsgruppe</span><input type="text" value="" data-name-template="settings[sport_types][__INDEX__][recovery_group]"></label>
|
||||
</div>
|
||||
<div class="field-grid field-grid--four"><label><span>Bonuspunkte</span><input type="number" value="2" min="0" max="20" data-name-template="settings[sport_types][__INDEX__][bonus_points]"></label></div>
|
||||
<label class="checkbox-row"><input type="checkbox" value="1" data-name-template="settings[sport_types][__INDEX__][allow_consecutive]"><span>Darf an Folgetagen Bonus geben</span></label>
|
||||
<div class="sport-type-card__actions"><button class="ghost-button ghost-button--small" type="button" data-remove-sport-type>Entfernen</button></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</article>
|
||||
<?php endif; ?>
|
||||
</aside>
|
||||
<button class="primary-button" type="submit">Sportarten speichern</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="options-panel" data-options-panel="walk" hidden>
|
||||
<h2>Spaziergang anpassen</h2>
|
||||
<form method="post" action="/options" class="stack-form">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="form_name" value="settings">
|
||||
<label><span>Spaziergang auswerten nach</span><select name="settings[walk][mode]"><?php foreach ($walkModeOptions as $modeValue => $modeLabel): ?><option value="<?= e($modeValue) ?>" <?= ($settings['walk']['mode'] ?? 'time') === $modeValue ? 'selected' : '' ?>><?= e($modeLabel) ?></option><?php endforeach; ?></select></label>
|
||||
<div class="band-grid"><?php foreach ($settings['scoring']['walk_bands'] as $index => $band): ?><div class="band-card"><label><span>Min</span><input type="number" name="settings[scoring][walk_bands][<?= e((string) $index) ?>][min]" value="<?= e((string) $band['min']) ?>"></label><label><span>Max</span><input type="number" name="settings[scoring][walk_bands][<?= e((string) $index) ?>][max]" value="<?= e((string) $band['max']) ?>"></label><label><span>Punkte</span><input type="number" name="settings[scoring][walk_bands][<?= e((string) $index) ?>][points]" value="<?= e((string) $band['points']) ?>"></label></div><?php endforeach; ?></div>
|
||||
<button class="primary-button" type="submit">Spaziergang speichern</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="options-panel" data-options-panel="reminders" hidden>
|
||||
<h2>Erinnerungen setzen</h2>
|
||||
<form method="post" action="/options" class="stack-form">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="form_name" value="settings">
|
||||
<div class="field-grid field-grid--two">
|
||||
<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="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><div class="band-grid"><?php foreach ($settings['ratings'] as $index => $rating): ?><div class="band-card"><label><span>Label</span><input type="text" name="settings[ratings][<?= e((string) $index) ?>][label]" value="<?= e($rating['label']) ?>"></label><label><span>Min</span><input type="number" name="settings[ratings][<?= e((string) $index) ?>][min]" value="<?= e((string) $rating['min']) ?>"></label><label><span>Max</span><input type="number" name="settings[ratings][<?= e((string) $index) ?>][max]" value="<?= e((string) $rating['max']) ?>"></label></div><?php endforeach; ?></div></div>
|
||||
<div class="settings-section"><h4>Schutzregeln</h4><div class="band-grid"><?php foreach ($settings['guardrails'] as $index => $guardrail): ?><div class="band-card"><label><span>Stimmung max</span><input type="number" name="settings[guardrails][<?= e((string) $index) ?>][mood_max]" value="<?= e((string) $guardrail['mood_max']) ?>" min="1" max="10"></label><label><span>Energie max</span><input type="number" name="settings[guardrails][<?= e((string) $index) ?>][energy_max]" value="<?= e((string) ($guardrail['energy_max'] ?? '')) ?>" min="1" max="10"></label><label><span>Maximales Label</span><input type="text" name="settings[guardrails][<?= e((string) $index) ?>][cap_label]" value="<?= e($guardrail['cap_label']) ?>"></label></div><?php endforeach; ?></div></div>
|
||||
<label><span>Tagebuchpunkte bei nicht-leerer Notiz</span><input type="number" name="settings[scoring][journal_points]" value="<?= e((string) $settings['scoring']['journal_points']) ?>" min="0" max="20"></label>
|
||||
<button class="primary-button" type="submit">Bewertung speichern</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="options-panel" data-options-panel="stats" hidden>
|
||||
<h2>Statistik</h2>
|
||||
<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>Ø 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>Tagesstimmung</h3></div></div><div class="line-chart" data-chart-type="line" data-series="mood" data-payload="<?= e($statsChartPayload) ?>"></div></article>
|
||||
<article class="glass-panel chart-card"><div class="section-head"><div><p class="eyebrow">Belastung</p><h3>Stressverlauf</h3></div></div><div class="line-chart" data-chart-type="line" data-series="stress" data-payload="<?= e($statsChartPayload) ?>"></div></article>
|
||||
<article class="glass-panel chart-card chart-card--wide"><div class="section-head"><div><p class="eyebrow">Aktivität</p><h3>Sport und Spaziergang</h3></div></div><div class="bar-chart" data-chart-type="bars" data-series="sport" data-payload="<?= e($statsChartPayload) ?>"></div></article>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($authUser['is_admin'])): ?>
|
||||
<div class="options-panel" data-options-panel="users" hidden>
|
||||
<h2>Neue Nutzer anlegen</h2>
|
||||
<form method="post" action="/options" class="stack-form">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="form_name" value="create_user">
|
||||
<label><span>Benutzername</span><input type="text" name="username" required></label>
|
||||
<label><span>Startpasswort</span><input type="password" name="password" minlength="10" required></label>
|
||||
<label class="checkbox-row"><input type="checkbox" name="is_admin" value="1"><span>Als Admin anlegen</span></label>
|
||||
<button class="primary-button" type="submit">Account erstellen</button>
|
||||
</form>
|
||||
<?php if ($users !== []): ?><div class="user-list"><?php foreach ($users as $account): ?><div class="user-row"><strong><?= e($account['username']) ?></strong><span><?= !empty($account['is_admin']) ? 'Admin' : 'Nutzer' ?></span></div><?php endforeach; ?></div><?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="options-panel" data-options-panel="security" hidden>
|
||||
<h2>Sicherheit</h2>
|
||||
<article class="detail-card detail-card--overlay">
|
||||
<p class="eyebrow">Backup</p>
|
||||
<form method="post" action="/options" class="stack-form"><?= csrf_field() ?><input type="hidden" name="form_name" value="export_backup"><button class="primary-button" type="submit" <?= empty($backupAvailable) ? 'disabled' : '' ?>>Backup herunterladen</button></form>
|
||||
<form method="post" action="/options" enctype="multipart/form-data" class="stack-form"><?= csrf_field() ?><input type="hidden" name="form_name" value="import_backup"><label><span>Backup importieren</span><input type="file" name="backup_files[]" accept=".zip,.txt" multiple></label><button class="ghost-button" type="submit">Backup importieren</button></form>
|
||||
</article>
|
||||
<article class="detail-card detail-card--overlay">
|
||||
<p class="eyebrow">Passwort</p>
|
||||
<form method="post" action="/options" class="stack-form"><?= csrf_field() ?><input type="hidden" name="form_name" value="password"><label><span>Aktuelles Passwort</span><input type="password" name="current_password" required></label><label><span>Neues Passwort</span><input type="password" name="new_password" minlength="10" required></label><label><span>Neues Passwort wiederholen</span><input type="password" name="new_password_confirm" minlength="10" required></label><button class="primary-button" type="submit">Passwort aktualisieren</button></form>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($authUser['is_admin'])): ?>
|
||||
<div class="options-panel" data-options-panel="ai" hidden>
|
||||
<h2>KI</h2>
|
||||
<?php if (!empty($aiStatus)): ?><div class="user-list"><div class="user-row"><strong>API-Key</strong><span><?= !empty($aiStatus['has_api_key']) ? 'vorhanden' : 'fehlt' ?></span></div><div class="user-row"><strong>Aktuelles Modell</strong><span><?= e((string) ($aiStatus['model'] ?? '')) ?></span></div><div class="user-row"><strong>Timeout</strong><span><?= e((string) ($aiStatus['timeout'] ?? '')) ?> s</span></div></div><?php endif; ?>
|
||||
<form method="post" action="/options" class="stack-form"><?= csrf_field() ?><input type="hidden" name="form_name" value="ai_config"><label><span>OpenAI-Modell</span><input type="text" name="ai[model]" value="<?= e((string) ($aiConfig['model'] ?? 'gpt-4o-mini')) ?>" required></label><label><span>Timeout in Sekunden</span><input type="number" name="ai[timeout]" value="<?= e((string) ($aiConfig['timeout'] ?? 25)) ?>" min="5" max="120" required></label><button class="primary-button" type="submit">KI-Konfiguration speichern</button></form>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user