6 Commits

28 changed files with 2456 additions and 44 deletions
+1 -1
View File
@@ -1,5 +1,6 @@
Options -Indexes
DirectoryIndex index.php
AddType application/manifest+json .webmanifest
<IfModule mod_rewrite.c>
RewriteEngine On
@@ -11,4 +12,3 @@ DirectoryIndex index.php
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [QSA,L]
</IfModule>
+15
View File
@@ -0,0 +1,15 @@
Mood-Board
Copyright (c) 2026 HNZIO
Licensed under the PolyForm Noncommercial License 1.0.0.
You may use, copy, modify, and distribute this software only for permitted
noncommercial purposes under the terms of that license.
Commercial use is not allowed without a separate written agreement from the
copyright holder.
Required Notice: Copyright (c) 2026 HNZIO
Full license text:
https://polyformproject.org/licenses/noncommercial/1.0.0/
+7
View File
@@ -32,3 +32,10 @@ Dateibasierter Stimmungstracker für LAMP/Cloudron ohne Datenbank.
- Die Inhalte liegen absichtlich nicht in einer Datenbank, sondern in menschenlesbaren TXT-Dateien.
- Mehrere Accounts sind möglich und verursachen hier wenig Overhead, weil jeder Nutzer nur einen eigenen Unterordner mit Tagen und Einstellungen bekommt.
- Wenn du später Reverse Proxy oder HTTPS über Cloudron nutzt, bleiben die Daten weiterhin nur über die App erreichbar.
## Lizenz
- Dieses Projekt steht unter der `PolyForm Noncommercial License 1.0.0`.
- Nicht-kommerzielle Nutzung ist erlaubt.
- Kommerzielle Nutzung ist ohne separate schriftliche Freigabe nicht erlaubt.
- Details siehe [LICENSE](/home/hnzio/Projekte/mood/LICENSE).
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+18
View File
@@ -0,0 +1,18 @@
<svg width="180" height="180" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="6" y="6" width="84" height="84" rx="28" fill="url(#bg)"/>
<rect x="6.75" y="6.75" width="82.5" height="82.5" rx="27.25" stroke="rgba(255,255,255,0.28)" stroke-width="1.5"/>
<path d="M48 21C35.2975 21 25 31.2975 25 44C25 61.25 48 76 48 76C48 76 71 61.25 71 44C71 31.2975 60.7025 21 48 21Z" fill="url(#drop)"/>
<circle cx="39.5" cy="35.5" r="7" fill="rgba(255,255,255,0.5)"/>
<defs>
<linearGradient id="bg" x1="14" y1="10" x2="84" y2="84" gradientUnits="userSpaceOnUse">
<stop stop-color="#95E8FF"/>
<stop offset="0.46" stop-color="#5CB5FF"/>
<stop offset="1" stop-color="#173859"/>
</linearGradient>
<linearGradient id="drop" x1="33" y1="24" x2="64" y2="67" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFFFFF"/>
<stop offset="0.56" stop-color="#8CFFE0"/>
<stop offset="1" stop-color="#49CBFF"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1005 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 655 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

+184
View File
@@ -128,6 +128,34 @@ body {
color: var(--text);
}
.pull-refresh-indicator {
position: fixed;
top: max(0.85rem, env(safe-area-inset-top));
left: 50%;
z-index: 30;
padding: 0.72rem 1rem;
border-radius: 999px;
opacity: 0;
pointer-events: none;
transform: translate(-50%, -0.9rem) scale(0.96);
transition: opacity 160ms ease, transform 160ms ease;
color: var(--muted);
font-size: 0.92rem;
letter-spacing: 0.01em;
}
body.is-pull-refreshing .pull-refresh-indicator,
body.is-pull-refresh-ready .pull-refresh-indicator,
body.is-pull-refresh-reloading .pull-refresh-indicator {
opacity: 1;
transform: translate(-50%, 0) scale(1);
}
body.is-pull-refresh-ready .pull-refresh-indicator,
body.is-pull-refresh-reloading .pull-refresh-indicator {
color: var(--text);
}
a {
color: inherit;
text-decoration: none;
@@ -144,6 +172,12 @@ button {
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
opacity: 0.55;
transform: none !important;
}
.aurora {
position: fixed;
inset: auto;
@@ -210,6 +244,26 @@ button {
gap: 1rem;
}
.site-footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.9rem;
margin-top: auto;
padding: 0.9rem 1.1rem;
border-radius: var(--radius-lg);
}
.site-footer__link {
color: var(--muted);
font-size: 0.92rem;
transition: color 180ms ease, opacity 180ms ease;
}
.site-footer__link:hover {
color: var(--text);
}
.topbar {
display: flex;
justify-content: space-between;
@@ -314,6 +368,10 @@ button {
margin-top: 2rem;
}
.mobile-nav {
display: none;
}
.main-nav a {
display: flex;
align-items: center;
@@ -1014,6 +1072,30 @@ input[type="range"] {
margin-top: 0.95rem;
}
.push-panel {
display: grid;
gap: 0.9rem;
}
.push-panel h5 {
margin: 0 0 0.35rem;
font-size: 1rem;
}
.push-panel [data-push-status][data-tone="success"] {
color: var(--good);
}
.push-panel [data-push-status][data-tone="error"] {
color: var(--danger);
}
.push-actions {
display: flex;
flex-wrap: wrap;
gap: 0.7rem;
}
.primary-button,
.ghost-button,
.button-link {
@@ -1239,6 +1321,31 @@ input[type="range"] {
gap: 0.7rem;
}
.checkbox-row span {
display: grid;
gap: 0.15rem;
}
.checkbox-row small {
color: var(--muted);
font-size: 0.86rem;
line-height: 1.45;
}
.checkbox-row--panel {
padding: 0.95rem 1rem;
border-radius: 18px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.1);
min-height: 100%;
}
.checkbox-row--tall {
align-items: flex-start;
padding-top: 1.05rem;
padding-bottom: 1.05rem;
}
.checkbox-row input {
width: auto;
}
@@ -1340,6 +1447,10 @@ input[type="range"] {
}
@media (max-width: 820px) {
.shell {
grid-template-columns: 1fr;
}
.topbar,
.section-head,
.form-actions {
@@ -1362,6 +1473,57 @@ input[type="range"] {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.sidebar {
display: none;
}
.mobile-nav {
position: fixed;
left: 0.8rem;
right: 0.8rem;
bottom: max(0.8rem, env(safe-area-inset-bottom));
z-index: 30;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.45rem;
padding: 0.6rem;
border-radius: 28px;
}
.mobile-nav a {
display: grid;
justify-items: center;
gap: 0.35rem;
padding: 0.7rem 0.5rem;
border-radius: 22px;
color: var(--muted);
transition: transform 180ms ease, background 180ms ease, color 180ms ease;
}
.mobile-nav a.active {
background: var(--nav-hover-bg);
color: var(--text);
transform: translateY(-1px);
}
.mobile-nav a span {
font-size: 0.76rem;
line-height: 1.1;
}
.mobile-nav .nav-icon {
width: 1.25rem;
height: 1.25rem;
}
body.is-authenticated .content {
padding-bottom: calc(6.8rem + env(safe-area-inset-bottom));
}
.site-footer {
margin-bottom: 0.5rem;
}
.bar-chart {
overflow-x: auto;
padding-bottom: 0.4rem;
@@ -1426,4 +1588,26 @@ input[type="range"] {
.bar-chart {
min-height: 9.5rem;
}
.mobile-nav {
left: 0.7rem;
right: 0.7rem;
bottom: max(0.7rem, env(safe-area-inset-bottom));
padding: 0.5rem;
gap: 0.3rem;
}
.mobile-nav a {
padding: 0.62rem 0.35rem;
}
.mobile-nav a span {
font-size: 0.72rem;
}
.site-footer {
flex-direction: column;
align-items: flex-start;
gap: 0.4rem;
}
}
+370 -6
View File
@@ -113,6 +113,38 @@
return last ? Number(last.points || 0) : 0;
}
function stepTargetPoints(value, targets) {
const list = [...(targets || [])].sort((a, b) => Number(a.steps || 0) - Number(b.steps || 0));
if (!list.length) {
return 0;
}
if (value <= Number(list[0].steps || 0)) {
return Number(list[0].points || 0);
}
const last = list[list.length - 1];
if (value >= Number(last.steps || 0)) {
return Number(last.points || 0);
}
for (let index = 1; index < list.length; index += 1) {
const previous = list[index - 1];
const current = list[index];
const previousSteps = Number(previous.steps || 0);
const currentSteps = Number(current.steps || 0);
if (value > currentSteps) {
continue;
}
const ratio = (value - previousSteps) / Math.max(currentSteps - previousSteps, 1);
return Math.round((Number(previous.points || 0) + ((Number(current.points || 0) - Number(previous.points || 0)) * ratio)) * 10) / 10;
}
return 0;
}
function sortedRatings(ratings) {
return [...(ratings || [])].sort((a, b) => Number(a.min || 0) - Number(b.min || 0));
}
@@ -231,6 +263,8 @@
function evaluateEntry(entry, settings, previousEntry = null) {
const ratings = sortedRatings(settings.ratings || []);
const scoring = settings.scoring || {};
const walkMode = entry.walk_mode === "steps" ? "steps" : "time";
const painEnabled = Boolean(settings.tracking?.pain_enabled);
const components = {
mood: Number(entry.mood) * Number(scoring.mood_multiplier || 0),
energy: Number(entry.energy) * Number(scoring.energy_multiplier || 0),
@@ -239,10 +273,17 @@
sleep_feeling: Number(entry.sleep_feeling) * Number(scoring.sleep_feeling_multiplier || 0),
sport_minutes: bandPoints(Number(entry.sport_minutes), scoring.sport_bands || []),
sport_bonus: sportBonusPoints(entry, settings, previousEntry),
walk_minutes: bandPoints(Number(entry.walk_minutes), scoring.walk_bands || []),
walk_minutes: walkMode === "steps"
? stepTargetPoints(Number(entry.walk_steps || 0), scoring.walk_step_targets || [])
: bandPoints(Number(entry.walk_minutes), scoring.walk_bands || []),
alcohol: entry.alcohol ? (Number(scoring.alcohol_penalty || 5) * -1) : 0,
note: String(entry.note || "").trim() === "" ? 0 : Number(scoring.journal_points || 0),
};
if (painEnabled) {
components.pain = (11 - Number(entry.pain || 1)) * Number(scoring.pain_multiplier || 0);
}
const total = Math.round(Object.values(components).reduce((sum, value) => sum + Number(value), 0) * 10) / 10;
let label = labelForScore(total, ratings);
@@ -281,11 +322,13 @@
mood: "Stimmung",
energy: "Energie",
stress: "Stress",
pain: "Schmerzen",
sleep_hours: "Schlafdauer",
sleep_feeling: "Schlafgefühl",
sport_minutes: "Sport",
sport_bonus: "Sportbonus",
walk_minutes: "Spaziergang",
alcohol: "Alkohol",
note: "Notiz",
};
@@ -293,11 +336,15 @@
mood: Number(form.elements.mood.value),
energy: Number(form.elements.energy.value),
stress: Number(form.elements.stress.value),
pain: Number(form.elements.pain?.value || 1),
sleep_hours: Number(form.elements.sleep_hours.value || 0),
sleep_feeling: Number(form.elements.sleep_feeling.value),
sport_minutes: Number(form.elements.sport_minutes.value || 0),
sport_types: [...form.querySelectorAll('input[name="sport_types[]"]:checked')].map(input => input.value),
walk_minutes: Number(form.elements.walk_minutes.value || 0),
walk_mode: form.elements.walk_mode?.value || "time",
walk_minutes: Number(form.elements.walk_minutes?.value || 0),
walk_steps: Number(form.elements.walk_steps?.value || 0),
alcohol: Boolean(form.elements.alcohol?.checked),
note: form.elements.note.value || "",
});
@@ -347,7 +394,7 @@
}
const seriesName = container.dataset.series || "";
const invertScale = seriesName === "stress";
const invertScale = seriesName === "stress" || seriesName === "pain";
const values = items.map(item => Number(item.value));
const width = 760;
const height = 196;
@@ -355,7 +402,7 @@
let minValue = Math.min(...values);
let maxValue = Math.max(...values);
if (seriesName === "mood" || seriesName === "stress") {
if (seriesName === "mood" || seriesName === "stress" || seriesName === "pain") {
minValue = Math.max(1, minValue - 1.5);
maxValue = Math.min(10, maxValue + 1.5);
} else {
@@ -365,7 +412,7 @@
if ((maxValue - minValue) < 3) {
const center = (maxValue + minValue) / 2;
if (seriesName === "mood" || seriesName === "stress") {
if (seriesName === "mood" || seriesName === "stress" || seriesName === "pain") {
minValue = Math.max(1, center - 1.5);
maxValue = Math.min(10, center + 1.5);
} else {
@@ -452,6 +499,7 @@
const sportY = walkY - sportHeight;
const badgeY = total > 0 ? Math.max(backgroundY + 6, sportY - 24) : (baseY - 20);
const labels = Array.isArray(item.sport_labels) ? item.sport_labels.filter(Boolean) : [];
const walkLabel = item.walk_label || `${walk} Aktivität`;
const label = labels.length ? ` · ${labels.join(", ")}` : "";
const bonus = Number(item.sport_bonus || 0);
const icons = Array.isArray(item.sport_icons) ? item.sport_icons.slice(0, 3) : [];
@@ -469,7 +517,7 @@
return `
<rect class="bar-grid" x="${x}" y="${backgroundY}" width="18" height="${chartHeight}" rx="9"></rect>
<rect class="bar-segment--walk" x="${x}" y="${walkY}" width="18" height="${walkHeight}" rx="9">
<title>${formatDateLabel(item.date)} · Spaziergang ${walk} min</title>
<title>${formatDateLabel(item.date)} · Spaziergang ${walkLabel}</title>
</rect>
<rect class="bar-segment--sport" x="${x}" y="${sportY}" width="18" height="${sportHeight}" rx="9">
<title>${formatDateLabel(item.date)} · Sport ${sport} min${label}${bonus > 0 ? ` · Bonus ${formatNumber(bonus)}` : ""}</title>
@@ -898,6 +946,319 @@
syncPresets();
}
function csrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") || "";
}
function pushPublicKey() {
return document.querySelector('meta[name="mood-push-public-key"]')?.getAttribute("content") || "";
}
function base64UrlToUint8Array(value) {
const padded = value + "=".repeat((4 - (value.length % 4)) % 4);
const base64 = padded.replace(/-/g, "+").replace(/_/g, "/");
const raw = atob(base64);
return Uint8Array.from(raw, char => char.charCodeAt(0));
}
async function postJson(url, payload) {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": csrfToken(),
},
body: JSON.stringify(payload || {}),
});
let data = {};
try {
data = await response.json();
} catch (error) {
data = {};
}
if (!response.ok) {
throw new Error(data.message || "Die Anfrage konnte nicht verarbeitet werden.");
}
return data;
}
async function initPwaShell() {
if (document.body.dataset.authenticated !== "1" || !("serviceWorker" in navigator)) {
return;
}
try {
await navigator.serviceWorker.register("/service-worker.js");
} catch (error) {
console.warn("Service Worker registration failed", error);
}
}
function isStandaloneMode() {
return window.matchMedia("(display-mode: standalone)").matches || window.navigator.standalone === true;
}
function isAppleTouchDevice() {
return /iPhone|iPad|iPod/i.test(window.navigator.userAgent)
|| (window.navigator.platform === "MacIntel" && window.navigator.maxTouchPoints > 1);
}
function initPullToRefresh() {
if (!isStandaloneMode() || !isAppleTouchDevice()) {
return;
}
const indicator = document.querySelector("[data-pull-refresh-indicator]");
const body = document.body;
const threshold = 96;
let isTracking = false;
let isReady = false;
let startY = 0;
const setIndicator = message => {
if (indicator) {
indicator.textContent = message;
}
};
const resetState = () => {
body.classList.remove("is-pull-refreshing", "is-pull-refresh-ready");
isTracking = false;
isReady = false;
startY = 0;
setIndicator("Zum Aktualisieren ziehen");
};
const scrollTop = () => Math.max(
window.scrollY || 0,
document.documentElement.scrollTop || 0,
document.body.scrollTop || 0
);
const canStart = target => {
if (scrollTop() > 0) {
return false;
}
if (!(target instanceof Element)) {
return true;
}
return !target.closest("input, textarea, select, button");
};
window.addEventListener("touchstart", event => {
if (event.touches.length !== 1 || !canStart(event.target)) {
resetState();
return;
}
isTracking = true;
startY = event.touches[0].clientY;
}, { passive: true });
window.addEventListener("touchmove", event => {
if (!isTracking || event.touches.length !== 1) {
return;
}
if (scrollTop() > 0) {
resetState();
return;
}
const delta = event.touches[0].clientY - startY;
if (delta <= 0) {
body.classList.remove("is-pull-refreshing", "is-pull-refresh-ready");
isReady = false;
setIndicator("Zum Aktualisieren ziehen");
return;
}
if (delta > 18) {
body.classList.add("is-pull-refreshing");
event.preventDefault();
}
if (delta >= threshold) {
if (!isReady) {
body.classList.add("is-pull-refresh-ready");
setIndicator("Loslassen zum Aktualisieren");
isReady = true;
}
return;
}
if (isReady) {
body.classList.remove("is-pull-refresh-ready");
isReady = false;
}
setIndicator("Zum Aktualisieren ziehen");
}, { passive: false });
window.addEventListener("touchend", () => {
if (!isTracking) {
return;
}
if (isReady) {
body.classList.remove("is-pull-refreshing", "is-pull-refresh-ready");
body.classList.add("is-pull-refresh-reloading");
setIndicator("Wird aktualisiert ...");
window.location.reload();
return;
}
resetState();
}, { passive: true });
window.addEventListener("touchcancel", resetState, { passive: true });
}
function initPushControls() {
const panel = document.querySelector("[data-push-panel]");
if (!panel) {
return;
}
const statusNode = panel.querySelector("[data-push-status]");
const enableButton = panel.querySelector("[data-push-enable]");
const disableButton = panel.querySelector("[data-push-disable]");
const testButton = panel.querySelector("[data-push-test]");
const ready = panel.dataset.pushReady === "1";
const vapidKey = pushPublicKey();
const setStatus = (message, tone = "neutral") => {
if (!statusNode) {
return;
}
statusNode.textContent = message;
statusNode.dataset.tone = tone;
};
if (!ready || !vapidKey) {
setStatus("Push ist auf diesem Server gerade noch nicht bereit.", "error");
return;
}
if (!("Notification" in window) || !("serviceWorker" in navigator) || !("PushManager" in window)) {
setStatus("Dieses Gerät unterstützt Web Push in diesem Browser leider nicht.", "error");
if (enableButton) enableButton.disabled = true;
if (disableButton) disableButton.disabled = true;
if (testButton) testButton.disabled = true;
return;
}
const updateUi = subscription => {
if (subscription) {
setStatus("Push ist auf diesem Gerät aktiv. Test und tägliche Erinnerungen können gesendet werden.", "success");
if (enableButton) enableButton.disabled = true;
if (disableButton) disableButton.disabled = false;
if (testButton) testButton.disabled = false;
return;
}
if (!isStandaloneMode() && /iPhone|iPad|iPod/i.test(navigator.userAgent)) {
setStatus("Für iPhone zuerst in Safari öffnen, zum Home-Bildschirm hinzufügen und danach Push aktivieren.", "neutral");
} else {
setStatus("Push ist auf diesem Gerät noch nicht aktiv. Du kannst ihn hier direkt einschalten.", "neutral");
}
if (enableButton) enableButton.disabled = false;
if (disableButton) disableButton.disabled = true;
if (testButton) testButton.disabled = true;
};
const getRegistration = async () => {
await initPwaShell();
return navigator.serviceWorker.ready;
};
const getSubscription = async () => {
const registration = await getRegistration();
return registration.pushManager.getSubscription();
};
const refreshStatus = async () => {
try {
updateUi(await getSubscription());
} catch (error) {
setStatus("Der Push-Status konnte gerade nicht gelesen werden.", "error");
}
};
if (enableButton) {
enableButton.addEventListener("click", async () => {
try {
const permission = await Notification.requestPermission();
if (permission !== "granted") {
setStatus("Die Push-Berechtigung wurde nicht erteilt.", "error");
return;
}
const registration = await getRegistration();
let subscription = await registration.pushManager.getSubscription();
if (!subscription) {
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: base64UrlToUint8Array(vapidKey),
});
}
await postJson("/push/subscribe", {
subscription: subscription.toJSON(),
contentEncoding: subscription.options?.applicationServerKey ? "aes128gcm" : "aes128gcm",
});
updateUi(subscription);
} catch (error) {
setStatus(error.message || "Push konnte nicht aktiviert werden.", "error");
}
});
}
if (disableButton) {
disableButton.addEventListener("click", async () => {
try {
const subscription = await getSubscription();
if (!subscription) {
updateUi(null);
return;
}
await postJson("/push/unsubscribe", {
endpoint: subscription.endpoint,
});
await subscription.unsubscribe();
updateUi(null);
} catch (error) {
setStatus(error.message || "Push konnte nicht entfernt werden.", "error");
}
});
}
if (testButton) {
testButton.addEventListener("click", async () => {
try {
const data = await postJson("/push/test", {});
setStatus(data.message || "Die Test-Benachrichtigung wurde verschickt.", "success");
} catch (error) {
setStatus(error.message || "Die Test-Benachrichtigung konnte nicht gesendet werden.", "error");
}
});
}
refreshStatus();
}
window.addEventListener("resize", () => {
if (!document.querySelector("#calendar-heatmap")) {
return;
@@ -914,4 +1275,7 @@
initTrackPreview();
initDashboardCharts();
initSportTypeManager();
initPwaShell();
initPullToRefresh();
initPushControls();
})();
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+27
View File
@@ -0,0 +1,27 @@
{
"id": "/",
"name": "Mood-Board",
"short_name": "Mood",
"description": "Persönlicher Stimmungstracker mit Archiv, Dashboard und Erinnerungen.",
"lang": "de-DE",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#0b1e2e",
"theme_color": "#0b1e2e",
"icons": [
{
"src": "/assets/branding/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/assets/branding/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
}
]
}
+55
View File
@@ -0,0 +1,55 @@
self.addEventListener("install", event => {
event.waitUntil(self.skipWaiting());
});
self.addEventListener("activate", event => {
event.waitUntil(self.clients.claim());
});
self.addEventListener("push", event => {
let payload = {};
try {
payload = event.data ? event.data.json() : {};
} catch (error) {
payload = {};
}
const title = payload.title || "Mood-Board";
const options = {
body: payload.body || "Zeit für deinen heutigen Eintrag.",
icon: payload.icon || "/assets/branding/logo-mark.svg",
badge: payload.badge || "/assets/branding/favicon.svg",
tag: payload.tag || "mood-reminder",
data: {
url: payload.url || "/track",
},
};
event.waitUntil(self.registration.showNotification(title, options));
});
self.addEventListener("notificationclick", event => {
event.notification.close();
const targetUrl = event.notification.data?.url || "/track";
event.waitUntil((async () => {
const clients = await self.clients.matchAll({
type: "window",
includeUncontrolled: true,
});
for (const client of clients) {
const clientUrl = new URL(client.url);
const target = new URL(targetUrl, self.location.origin);
if (clientUrl.pathname === target.pathname) {
await client.focus();
return;
}
}
await self.clients.openWindow(targetUrl);
})());
});
+620 -13
View File
@@ -10,6 +10,8 @@ final class App
private LoginThrottle $throttle;
private ScoringService $scoring;
private Auth $auth;
private NotificationRepository $notifications;
private WebPushService $webPush;
public function __construct()
{
@@ -19,6 +21,8 @@ final class App
$this->throttle = new LoginThrottle();
$this->scoring = new ScoringService();
$this->auth = new Auth($this->users);
$this->notifications = new NotificationRepository();
$this->webPush = new WebPushService($this->notifications);
}
public function run(): void
@@ -27,8 +31,10 @@ final class App
$path = request_path();
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$this->triggerReminderCheckFromTraffic($method, $path);
$hasUsers = $this->users->hasAnyUsers();
$isAuthenticated = $this->auth->check();
$systemPaths = ['/reminders/run'];
// A failed setup must never leave the app in a half-authenticated redirect loop.
if (!$hasUsers && $isAuthenticated) {
@@ -39,13 +45,13 @@ final class App
if (!$hasUsers) {
if ($path === '/login') {
$path = '/setup';
} elseif ($path !== '/setup') {
} elseif ($path !== '/setup' && !in_array($path, $systemPaths, true)) {
redirect('/setup');
}
} elseif (!$isAuthenticated) {
if ($path === '/setup') {
$path = '/login';
} elseif ($path !== '/login') {
} elseif ($path !== '/login' && !in_array($path, $systemPaths, true)) {
redirect('/login');
}
}
@@ -90,6 +96,37 @@ final class App
$method === 'POST' ? $this->handleOptions() : $this->showOptions();
return;
case '/push/subscribe':
if ($method !== 'POST') {
http_response_code(405);
exit('Method Not Allowed');
}
$this->handlePushSubscribe();
return;
case '/push/unsubscribe':
if ($method !== 'POST') {
http_response_code(405);
exit('Method Not Allowed');
}
$this->handlePushUnsubscribe();
return;
case '/push/test':
if ($method !== 'POST') {
http_response_code(405);
exit('Method Not Allowed');
}
$this->handlePushTest();
return;
case '/reminders/run':
$this->handleReminderRun();
return;
default:
http_response_code(404);
View::render('not-found', [
@@ -197,6 +234,7 @@ final class App
'pageTitle' => 'Dashboard',
'page' => 'dashboard',
'authUser' => $user,
'settings' => $settings,
'summary' => $summary,
'entries' => array_reverse($evaluatedEntries),
'chartPayload' => encode_payload($chartData),
@@ -216,15 +254,21 @@ final class App
'mood' => 6,
'energy' => 6,
'stress' => 4,
'pain' => 1,
'pain_enabled' => !empty($settings['tracking']['pain_enabled']),
'sleep_hours' => 7,
'sleep_feeling' => 3,
'sport_minutes' => 0,
'sport_type' => '',
'sport_types' => [],
'walk_mode' => (string) ($settings['walk']['mode'] ?? 'time'),
'walk_minutes' => 0,
'walk_steps' => 0,
'alcohol' => false,
'note' => '',
];
$entry['pain_enabled'] = !empty($settings['tracking']['pain_enabled']);
$entry = $this->scoring->normalize($entry);
$previousEntry = $this->entries->find($user['username'], shift_date($date, -1));
$evaluation = $this->scoring->evaluate($entry, $settings, $previousEntry);
@@ -259,11 +303,16 @@ final class App
'mood' => $_POST['mood'] ?? 5,
'energy' => $_POST['energy'] ?? 5,
'stress' => $_POST['stress'] ?? 5,
'pain' => $_POST['pain'] ?? 1,
'pain_enabled' => !empty($settings['tracking']['pain_enabled']),
'sleep_hours' => $_POST['sleep_hours'] ?? 0,
'sleep_feeling' => $_POST['sleep_feeling'] ?? 3,
'sport_minutes' => $_POST['sport_minutes'] ?? 0,
'sport_types' => $_POST['sport_types'] ?? [],
'walk_mode' => $_POST['walk_mode'] ?? ($settings['walk']['mode'] ?? 'time'),
'walk_minutes' => $_POST['walk_minutes'] ?? 0,
'walk_steps' => $_POST['walk_steps'] ?? 0,
'alcohol' => $_POST['alcohol'] ?? false,
'note' => $_POST['note'] ?? '',
]);
@@ -272,9 +321,15 @@ final class App
redirect('/track');
}
$previousEntry = $this->entries->find($user['username'], shift_date($entry['date'], -1));
$evaluation = $this->scoring->evaluate($entry, $settings, $previousEntry);
$this->entries->save($user['username'], $entry['date'], $entry, $evaluation);
$entries = $this->entries->all($user['username']);
$entryMap = [];
foreach ($entries as $existingEntry) {
$entryMap[(string) ($existingEntry['date'] ?? '')] = $existingEntry;
}
$entryMap[$entry['date']] = $entry;
$this->persistUserEntries($user['username'], $settings, array_values($entryMap));
flash('success', 'Der Tag wurde gespeichert.');
redirect('/track?date=' . rawurlencode($entry['date']));
@@ -304,6 +359,7 @@ final class App
'authUser' => $user,
'entries' => $archive,
'selectedEntry' => $selectedEntry,
'settings' => $settings,
]);
}
@@ -323,6 +379,16 @@ final class App
return true;
}
));
$pushAvailable = $this->webPush->isAvailable();
$pushPublicKey = null;
if ($pushAvailable) {
try {
$pushPublicKey = $this->webPush->publicKey();
} catch (RuntimeException) {
$pushAvailable = false;
}
}
View::render('options', [
'pageTitle' => 'Optionen',
@@ -331,11 +397,18 @@ final class App
'settings' => $settings,
'sportTypePresets' => $sportTypePresets,
'sportLocationOptions' => sport_location_options(),
'walkModeOptions' => walk_mode_options(),
'pushAvailable' => $pushAvailable,
'pushPublicKey' => $pushPublicKey,
'pushSubscriptionCount' => $this->notifications->subscriptionCount($user['username']),
'backupAvailable' => class_exists('ZipArchive'),
'users' => $user['is_admin'] ? $this->users->all() : [],
'maxScore' => $this->scoring->evaluate([
'mood' => 10,
'energy' => 10,
'stress' => 1,
'pain' => 1,
'pain_enabled' => !empty($settings['tracking']['pain_enabled']),
'sleep_hours' => 7,
'sleep_feeling' => 5,
'sport_minutes' => 999,
@@ -343,7 +416,10 @@ final class App
static fn (array $type): string => (string) ($type['id'] ?? ''),
normalized_sport_types($settings)
),
'walk_mode' => (string) ($settings['walk']['mode'] ?? 'time'),
'walk_minutes' => 999,
'walk_steps' => 10000,
'alcohol' => false,
'note' => 'x',
], $settings)['max_total'],
]);
@@ -357,12 +433,31 @@ final class App
$form = (string) ($_POST['form_name'] ?? '');
if ($form === 'settings') {
$settings = $this->sanitizeSettings($_POST['settings'] ?? []);
$currentSettings = $this->hydrateSettings($this->settings->forUser($user['username']));
$settings = $this->sanitizeSettings($_POST['settings'] ?? [], $currentSettings);
$this->settings->saveForUser($user['username'], $settings);
flash('success', 'Deine persönlichen Optionen wurden aktualisiert.');
redirect('/options');
}
if ($form === 'export_backup') {
$settings = $this->hydrateSettings($this->settings->forUser($user['username']));
$this->downloadUserBackup($user, $settings);
}
if ($form === 'import_backup') {
$settings = $this->hydrateSettings($this->settings->forUser($user['username']));
try {
$imported = $this->importUserBackup($user, $settings);
flash('success', $imported . ' Tag' . ($imported === 1 ? '' : 'e') . ' aus dem Backup importiert.');
} catch (RuntimeException $exception) {
flash('error', $exception->getMessage());
}
redirect('/options');
}
if ($form === 'password') {
$current = (string) ($_POST['current_password'] ?? '');
$new = (string) ($_POST['new_password'] ?? '');
@@ -449,6 +544,220 @@ final class App
];
}
private function persistUserEntries(string $username, array $settings, array $entries): void
{
$normalized = [];
foreach ($entries as $entry) {
if (!is_array($entry)) {
continue;
}
$normalizedEntry = $this->scoring->normalize($entry);
if (!$this->isValidDate((string) ($normalizedEntry['date'] ?? ''))) {
continue;
}
$normalized[$normalizedEntry['date']] = $normalizedEntry;
}
ksort($normalized, SORT_STRING);
$previousEntry = null;
foreach ($normalized as $date => $entry) {
$evaluation = $this->scoring->evaluate($entry, $settings, $previousEntry);
$this->entries->save($username, $date, $entry, $evaluation);
$previousEntry = $entry;
}
}
private function downloadUserBackup(array $user, array $settings): never
{
if (!class_exists('ZipArchive')) {
flash('error', 'Für den Backup-Download fehlt auf diesem Server die ZIP-Erweiterung.');
redirect('/options');
}
$entries = $this->evaluateEntriesWithContext($this->entries->all($user['username']), $settings);
$tempPath = tempnam(sys_get_temp_dir(), 'mood-backup-');
if ($tempPath === false) {
throw new RuntimeException('Das Backup konnte gerade nicht vorbereitet werden.');
}
$zip = new ZipArchive();
$opened = $zip->open($tempPath, ZipArchive::OVERWRITE);
if ($opened !== true) {
@unlink($tempPath);
throw new RuntimeException('Das Backup konnte nicht als ZIP erstellt werden.');
}
foreach ($entries as $entry) {
$date = (string) ($entry['date'] ?? '');
if (!$this->isValidDate($date)) {
continue;
}
$markdown = $this->entries->exportMarkdown(
(string) ($user['username'] ?? ''),
$date,
$entry,
$entry['evaluation'] ?? $this->scoring->evaluate($entry, $settings)
);
$zip->addFromString($date . '.txt', $markdown);
}
$zip->close();
$fileName = 'mood-board-backup-' . normalize_username((string) ($user['username'] ?? 'user')) . '-' . today() . '.zip';
header('Content-Type: application/zip');
header('Content-Disposition: attachment; filename="' . $fileName . '"');
header('Content-Length: ' . (string) filesize($tempPath));
header('Cache-Control: no-store, no-cache, must-revalidate');
header('Pragma: no-cache');
readfile($tempPath);
@unlink($tempPath);
exit;
}
private function importUserBackup(array $user, array $settings): int
{
$files = uploaded_files('backup_files');
if ($files === []) {
throw new RuntimeException('Bitte wähle mindestens eine Backup-Datei aus.');
}
$importedEntries = [];
foreach ($files as $file) {
$error = (int) ($file['error'] ?? UPLOAD_ERR_NO_FILE);
if ($error === UPLOAD_ERR_NO_FILE) {
continue;
}
if ($error !== UPLOAD_ERR_OK) {
throw new RuntimeException('Eine Backup-Datei konnte nicht hochgeladen werden.');
}
$tmpName = (string) ($file['tmp_name'] ?? '');
$name = trim((string) ($file['name'] ?? ''));
if ($tmpName === '' || !is_uploaded_file($tmpName)) {
throw new RuntimeException('Eine Backup-Datei ist ungültig.');
}
$extension = strtolower(pathinfo($name, PATHINFO_EXTENSION));
if ($extension === 'zip') {
foreach ($this->entriesFromZip($tmpName) as $date => $entry) {
$importedEntries[$date] = $entry;
}
continue;
}
if ($extension !== 'txt') {
throw new RuntimeException('Es werden nur .txt-Dateien oder ZIP-Backups unterstützt.');
}
$date = $this->dateFromBackupFileName($name);
$content = (string) file_get_contents($tmpName);
$entry = $this->entries->parseMarkdown($content, $date);
if ($entry === null) {
throw new RuntimeException('Mindestens eine Tagesdatei konnte nicht gelesen werden.');
}
$importedEntries[$date] = $entry;
}
if ($importedEntries === []) {
throw new RuntimeException('Im Backup wurden keine gültigen Tagesdateien gefunden.');
}
$existingEntries = $this->entries->all((string) ($user['username'] ?? ''));
$entryMap = [];
foreach ($existingEntries as $entry) {
if (!is_array($entry) || !$this->isValidDate((string) ($entry['date'] ?? ''))) {
continue;
}
$entryMap[$entry['date']] = $entry;
}
foreach ($importedEntries as $date => $entry) {
$entryMap[$date] = $entry;
}
$this->persistUserEntries((string) ($user['username'] ?? ''), $settings, array_values($entryMap));
return count($importedEntries);
}
private function entriesFromZip(string $path): array
{
if (!class_exists('ZipArchive')) {
throw new RuntimeException('ZIP-Import ist auf diesem Server nicht verfügbar.');
}
$zip = new ZipArchive();
$opened = $zip->open($path);
if ($opened !== true) {
throw new RuntimeException('Das ZIP-Backup konnte nicht geöffnet werden.');
}
$entries = [];
for ($index = 0; $index < $zip->numFiles; $index++) {
$name = (string) $zip->getNameIndex($index);
if ($name === '' || str_ends_with($name, '/')) {
continue;
}
$baseName = basename($name);
if (!preg_match('/^\d{4}-\d{2}-\d{2}\.txt$/', $baseName)) {
continue;
}
$date = $this->dateFromBackupFileName($baseName);
$content = $zip->getFromIndex($index);
if (!is_string($content)) {
continue;
}
$entry = $this->entries->parseMarkdown($content, $date);
if ($entry !== null) {
$entries[$date] = $entry;
}
}
$zip->close();
return $entries;
}
private function dateFromBackupFileName(string $fileName): string
{
$baseName = basename($fileName);
if (!preg_match('/^(\d{4}-\d{2}-\d{2})\.txt$/', $baseName, $matches)) {
throw new RuntimeException('Backup-Dateien müssen als YYYY-MM-DD.txt benannt sein.');
}
$date = (string) ($matches[1] ?? '');
if (!$this->isValidDate($date)) {
throw new RuntimeException('Eine Backup-Datei enthält ein ungültiges Datum.');
}
return $date;
}
private function buildDashboardCharts(array $entries): array
{
$recent = array_slice($entries, -30);
@@ -475,12 +784,19 @@ final class App
'value' => $entry['stress'],
];
}, $recent),
'pain' => array_map(static function (array $entry): array {
return [
'date' => $entry['date'],
'value' => $entry['pain'],
];
}, $recent),
'sport' => array_map(static function (array $entry): array {
return [
'date' => $entry['date'],
'value' => $entry['sport_minutes'] + $entry['walk_minutes'],
'value' => $entry['sport_minutes'] + walk_chart_value($entry),
'sport' => $entry['sport_minutes'],
'walk' => $entry['walk_minutes'],
'walk' => walk_chart_value($entry),
'walk_label' => format_walk_value($entry),
'sport_labels' => array_values(array_filter(array_map(
static function (array $type): string {
$label = (string) ($type['label'] ?? '');
@@ -528,16 +844,24 @@ final class App
return $streak;
}
private function sanitizeSettings(array $input): array
private function sanitizeSettings(array $input, ?array $existingSettings = null): array
{
$defaults = Defaults::settings();
$settings = $defaults;
$settings = array_replace_recursive($defaults, $existingSettings ?? []);
$settings['walk'] = [
'mode' => ($input['walk']['mode'] ?? ($settings['walk']['mode'] ?? 'time')) === 'steps' ? 'steps' : 'time',
];
$settings['scoring']['mood_multiplier'] = max(0, min(10, (int) ($input['scoring']['mood_multiplier'] ?? 3)));
$settings['scoring']['energy_multiplier'] = max(0, min(10, (int) ($input['scoring']['energy_multiplier'] ?? 2)));
$settings['scoring']['stress_multiplier'] = max(0, min(10, (int) ($input['scoring']['stress_multiplier'] ?? 2)));
$settings['scoring']['pain_multiplier'] = max(0, min(10, (int) ($input['scoring']['pain_multiplier'] ?? ($settings['scoring']['pain_multiplier'] ?? 3))));
$settings['scoring']['sleep_feeling_multiplier'] = max(0, min(10, (int) ($input['scoring']['sleep_feeling_multiplier'] ?? 2)));
$settings['scoring']['journal_points'] = max(0, min(20, (int) ($input['scoring']['journal_points'] ?? 2)));
$settings['tracking'] = [
'pain_enabled' => isset($input['tracking']['pain_enabled']) && $input['tracking']['pain_enabled'] === '1',
];
foreach ($defaults['scoring']['sleep_duration_points'] as $key => $default) {
$settings['scoring']['sleep_duration_points'][$key] = max(0, min(20, (int) ($input['scoring']['sleep_duration_points'][$key] ?? $default)));
@@ -545,10 +869,12 @@ final class App
foreach (['sport_bands', 'walk_bands'] as $bandKey) {
foreach ($defaults['scoring'][$bandKey] as $index => $defaultBand) {
$currentBand = $settings['scoring'][$bandKey][$index] ?? $defaultBand;
$settings['scoring'][$bandKey][$index] = [
'min' => max(0, min(2000, (int) ($input['scoring'][$bandKey][$index]['min'] ?? $defaultBand['min']))),
'max' => max(0, min(2000, (int) ($input['scoring'][$bandKey][$index]['max'] ?? $defaultBand['max']))),
'points' => max(0, min(20, (int) ($input['scoring'][$bandKey][$index]['points'] ?? $defaultBand['points']))),
'min' => max(0, min(2000, (int) ($input['scoring'][$bandKey][$index]['min'] ?? $currentBand['min'] ?? $defaultBand['min']))),
'max' => max(0, min(2000, (int) ($input['scoring'][$bandKey][$index]['max'] ?? $currentBand['max'] ?? $defaultBand['max']))),
'points' => max(0, min(20, (int) ($input['scoring'][$bandKey][$index]['points'] ?? $currentBand['points'] ?? $defaultBand['points']))),
];
}
}
@@ -579,12 +905,34 @@ final class App
: ($sportTypesProvided ? [] : $defaults['sport_types']),
]);
$time = trim((string) ($input['notifications']['time'] ?? ($settings['notifications']['time'] ?? $defaults['notifications']['time'])));
if (!$this->isValidTime($time)) {
$time = $defaults['notifications']['time'];
}
$settings['notifications'] = [
'enabled' => isset($input['notifications']['enabled']) && $input['notifications']['enabled'] === '1',
'time' => $time,
];
return $settings;
}
private function hydrateSettings(array $settings): array
{
$settings['sport_types'] = normalized_sport_types($settings);
$settings['walk'] = array_replace(
Defaults::settings()['walk'],
is_array($settings['walk'] ?? null) ? $settings['walk'] : []
);
$settings['tracking'] = array_replace(
Defaults::settings()['tracking'],
is_array($settings['tracking'] ?? null) ? $settings['tracking'] : []
);
$settings['notifications'] = array_replace(
Defaults::settings()['notifications'],
is_array($settings['notifications'] ?? null) ? $settings['notifications'] : []
);
return $settings;
}
@@ -632,6 +980,16 @@ final class App
}
}
private function enforceRequestCsrf(): void
{
if (!verify_request_csrf()) {
json_response([
'ok' => false,
'message' => 'Ungültiges Formular-Token.',
], 419);
}
}
private function requireUser(): array
{
$user = $this->auth->user();
@@ -660,10 +1018,259 @@ final class App
return strlen($password) >= 10;
}
private function isValidTime(string $time): bool
{
return preg_match('/^(?:[01]\d|2[0-3]):[0-5]\d$/', $time) === 1;
}
private function throttleKey(string $username): string
{
$remoteAddress = (string) ($_SERVER['REMOTE_ADDR'] ?? 'unknown');
return sha1($remoteAddress . '|' . normalize_username($username));
}
private function handlePushSubscribe(): void
{
$this->enforceRequestCsrf();
$user = $this->requireUser();
$payload = request_json_body();
$subscription = $payload['subscription'] ?? null;
if (!is_array($subscription)) {
json_response(['ok' => false, 'message' => 'Die Push-Daten fehlen.'], 422);
}
try {
$this->notifications->saveSubscription($user['username'], [
'endpoint' => trim((string) ($subscription['endpoint'] ?? '')),
'keys' => [
'p256dh' => trim((string) ($subscription['keys']['p256dh'] ?? '')),
'auth' => trim((string) ($subscription['keys']['auth'] ?? '')),
],
'content_encoding' => trim((string) ($payload['contentEncoding'] ?? 'aes128gcm')),
'user_agent' => (string) ($_SERVER['HTTP_USER_AGENT'] ?? ''),
]);
} catch (RuntimeException $exception) {
json_response(['ok' => false, 'message' => $exception->getMessage()], 422);
}
json_response([
'ok' => true,
'message' => 'Push ist auf diesem Gerät aktiviert.',
'count' => $this->notifications->subscriptionCount($user['username']),
]);
}
private function handlePushUnsubscribe(): void
{
$this->enforceRequestCsrf();
$user = $this->requireUser();
$payload = request_json_body();
$endpoint = trim((string) ($payload['endpoint'] ?? ''));
if ($endpoint === '') {
json_response(['ok' => false, 'message' => 'Kein Push-Endpunkt übergeben.'], 422);
}
$this->notifications->removeSubscription($user['username'], $endpoint);
json_response([
'ok' => true,
'message' => 'Push wurde für dieses Gerät entfernt.',
'count' => $this->notifications->subscriptionCount($user['username']),
]);
}
private function handlePushTest(): void
{
$this->enforceRequestCsrf();
$user = $this->requireUser();
$result = $this->sendNotificationsForUser($user['username'], [
'title' => 'Mood-Board',
'body' => 'Die Push-Erinnerung ist auf diesem Gerät bereit.',
'url' => '/options',
'tag' => 'mood-push-test',
]);
if ($result['sent'] <= 0) {
json_response([
'ok' => false,
'message' => 'Es konnte noch keine Test-Benachrichtigung gesendet werden. Bitte aktiviere Push zuerst auf diesem Gerät.',
'removed' => $result['removed'],
], 422);
}
json_response([
'ok' => true,
'message' => 'Die Test-Benachrichtigung wurde verschickt.',
'sent' => $result['sent'],
'removed' => $result['removed'],
]);
}
private function handleReminderRun(): void
{
$providedToken = trim((string) ($_GET['token'] ?? ($_SERVER['HTTP_X_REMINDER_TOKEN'] ?? '')));
$expectedToken = $this->webPush->cronToken();
if ($providedToken === '' || !hash_equals($expectedToken, $providedToken)) {
json_response(['ok' => false, 'message' => 'Ungültiger Reminder-Token.'], 403);
}
$stats = $this->runDueReminders(new DateTimeImmutable('now'));
json_response([
'ok' => true,
'processed' => $stats['processed'],
'sent_users' => $stats['sent_users'],
'already_tracked' => $stats['already_tracked'],
'skipped' => $stats['skipped'],
'removed_subscriptions' => $stats['removed_subscriptions'],
]);
}
private function sendNotificationsForUser(string $username, array $message): array
{
$subscriptions = $this->notifications->subscriptionsForUser($username);
$sent = 0;
$removedEndpoints = [];
foreach ($subscriptions as $subscription) {
try {
$result = $this->webPush->send($subscription, $message);
} catch (RuntimeException) {
$result = [
'ok' => false,
'remove' => false,
];
}
if (!empty($result['ok'])) {
$sent++;
continue;
}
if (!empty($result['remove'])) {
$endpoint = (string) ($subscription['endpoint'] ?? '');
if ($endpoint !== '') {
$removedEndpoints[] = $endpoint;
}
}
}
if ($removedEndpoints !== []) {
$this->notifications->removeInvalidSubscriptions($username, $removedEndpoints);
}
return [
'sent' => $sent,
'removed' => count($removedEndpoints),
];
}
private function isReminderDue(array $settings, array $state, string $today, string $currentTime): bool
{
if (empty($settings['notifications']['enabled'])) {
return false;
}
$reminderTime = (string) ($settings['notifications']['time'] ?? '');
if (!$this->isValidTime($reminderTime)) {
return false;
}
if ($currentTime < $reminderTime) {
return false;
}
return (string) ($state['last_sent_date'] ?? '') !== $today;
}
private function triggerReminderCheckFromTraffic(string $method, string $path): void
{
if ($method !== 'GET' || $path === '/reminders/run') {
return;
}
$lockPath = storage_path('system/reminder-traffic.lock');
$handle = fopen($lockPath, 'c+');
if ($handle === false) {
return;
}
if (!flock($handle, LOCK_EX | LOCK_NB)) {
fclose($handle);
return;
}
try {
$this->runDueReminders(new DateTimeImmutable('now'));
} catch (Throwable) {
// Reminder checks should never break normal page delivery.
}
flock($handle, LOCK_UN);
fclose($handle);
}
private function runDueReminders(DateTimeImmutable $now): array
{
$today = $now->format('Y-m-d');
$currentTime = $now->format('H:i');
$processed = 0;
$sentUsers = 0;
$alreadyTracked = 0;
$skipped = 0;
$removed = 0;
foreach ($this->users->all() as $account) {
$username = (string) ($account['username'] ?? '');
if ($username === '') {
continue;
}
$processed++;
$settings = $this->hydrateSettings($this->settings->forUser($username));
$state = $this->notifications->reminderState($username);
if (!$this->isReminderDue($settings, $state, $today, $currentTime)) {
$skipped++;
continue;
}
if ($this->entries->find($username, $today) !== null) {
$alreadyTracked++;
continue;
}
$result = $this->sendNotificationsForUser($username, [
'title' => 'Mood-Board',
'body' => 'Nimm dir kurz Zeit für deinen heutigen Eintrag.',
'url' => '/track?date=' . rawurlencode($today),
'tag' => 'mood-reminder-' . $today,
]);
$removed += $result['removed'];
if ($result['sent'] > 0) {
$sentUsers++;
$this->notifications->saveReminderState($username, [
'last_sent_date' => $today,
'last_sent_at' => $now->format(DATE_ATOM),
]);
}
}
return [
'processed' => $processed,
'sent_users' => $sentUsers,
'already_tracked' => $alreadyTracked,
'skipped' => $skipped,
'removed_subscriptions' => $removed,
];
}
}
+55 -5
View File
@@ -4,6 +4,13 @@ declare(strict_types=1);
final class EntryRepository
{
private EntryCrypto $crypto;
public function __construct()
{
$this->crypto = new EntryCrypto();
}
public function save(string $username, string $date, array $entry, array $evaluation): void
{
$path = $this->pathFor($username, $date);
@@ -13,7 +20,8 @@ final class EntryRepository
mkdir($directory, 0775, true);
}
file_put_contents($path, $this->toMarkdown($username, $date, $entry, $evaluation));
$markdown = $this->toMarkdown($username, $date, $entry, $evaluation);
file_put_contents($path, $this->crypto->encrypt($markdown), LOCK_EX);
}
public function find(string $username, string $date): ?array
@@ -24,7 +32,14 @@ final class EntryRepository
return null;
}
return $this->parse((string) file_get_contents($path), $date);
$content = (string) file_get_contents($path);
$plaintext = $this->crypto->decrypt($content);
if ($this->crypto->shouldEncrypt() && !$this->crypto->isEncrypted($content)) {
file_put_contents($path, $this->crypto->encrypt($plaintext), LOCK_EX);
}
return $this->parse($plaintext, $date);
}
public function all(string $username): array
@@ -41,7 +56,14 @@ final class EntryRepository
$entries = [];
foreach ($files as $file) {
$date = basename($file, '.txt');
$parsed = $this->parse((string) file_get_contents($file), $date);
$content = (string) file_get_contents($file);
$plaintext = $this->crypto->decrypt($content);
if ($this->crypto->shouldEncrypt() && !$this->crypto->isEncrypted($content)) {
file_put_contents($file, $this->crypto->encrypt($plaintext), LOCK_EX);
}
$parsed = $this->parse($plaintext, $date);
if ($parsed !== null) {
$entries[] = $parsed;
}
@@ -50,6 +72,18 @@ final class EntryRepository
return $entries;
}
public function parseMarkdown(string $content, string $fallbackDate): ?array
{
$plaintext = $this->crypto->decrypt($content);
return $this->parse($plaintext, $fallbackDate);
}
public function exportMarkdown(string $username, string $date, array $entry, array $evaluation): string
{
return $this->toMarkdown($username, $date, $entry, $evaluation);
}
private function directoryFor(string $username): string
{
return storage_path('users/' . normalize_username($username) . '/days');
@@ -77,17 +111,28 @@ final class EntryRepository
$sportTypes = normalize_sport_type_selection($sportType);
}
$walkModeRaw = strtolower((string) ($this->extract('/^- Spaziergang-Modus:\s*(.+)$/mu', $content) ?? 'zeit'));
$walkMode = $walkModeRaw === 'schritte' ? 'steps' : 'time';
$walkValue = (int) ($this->extract('/^- Spaziergang:\s*(.+)$/m', $content) ?? 0);
$painRaw = $this->extract('/^- Schmerzen:\s*(.+)$/mu', $content);
$alcoholRaw = strtolower((string) ($this->extract('/^- Alkohol:\s*(.+)$/mu', $content) ?? 'nein'));
$entry = [
'date' => $this->extract('/^Datum:\s*(\d{4}-\d{2}-\d{2})$/m', $content) ?? $fallbackDate,
'mood' => (int) ($this->extract('/^- Stimmung:\s*(.+)$/m', $content) ?? 5),
'energy' => (int) ($this->extract('/^- Energie:\s*(.+)$/m', $content) ?? 5),
'stress' => (int) ($this->extract('/^- Stress:\s*(.+)$/m', $content) ?? 5),
'pain' => $painRaw !== null ? (int) $painRaw : 1,
'pain_enabled' => $painRaw !== null,
'sleep_hours' => (float) ($this->extract('/^- Schlafdauer:\s*(.+)$/m', $content) ?? 0),
'sleep_feeling' => (int) ($this->extract('/^- Schlaf(?:gefühl|gefuehl):\s*(.+)$/mu', $content) ?? 3),
'sport_minutes' => (int) ($this->extract('/^- Sport:\s*(.+)$/m', $content) ?? 0),
'sport_type' => $sportTypes[0] ?? '',
'sport_types' => $sportTypes,
'walk_minutes' => (int) ($this->extract('/^- Spaziergang:\s*(.+)$/m', $content) ?? 0),
'walk_mode' => $walkMode,
'walk_minutes' => $walkMode === 'time' ? $walkValue : 0,
'walk_steps' => $walkMode === 'steps' ? $walkValue : 0,
'alcohol' => in_array($alcoholRaw, ['ja', 'yes', 'true', '1'], true),
'note' => $this->extractNote($content),
];
@@ -134,11 +179,14 @@ final class EntryRepository
'- Stimmung: ' . $entry['mood'],
'- Energie: ' . $entry['energy'],
'- Stress: ' . $entry['stress'],
...(!empty($entry['pain_enabled']) ? ['- Schmerzen: ' . $entry['pain']] : []),
'- Schlafdauer: ' . $entry['sleep_hours'],
'- Schlafgefühl: ' . $entry['sleep_feeling'],
'- Sport: ' . $entry['sport_minutes'],
'- Sportarten: ' . implode(', ', $sportTypeValues),
'- Spaziergang: ' . $entry['walk_minutes'],
'- Spaziergang-Modus: ' . (($entry['walk_mode'] ?? 'time') === 'steps' ? 'schritte' : 'zeit'),
'- Spaziergang: ' . (($entry['walk_mode'] ?? 'time') === 'steps' ? (int) ($entry['walk_steps'] ?? 0) : (int) ($entry['walk_minutes'] ?? 0)),
'- Alkohol: ' . (!empty($entry['alcohol']) ? 'ja' : 'nein'),
'',
'## Bewertung',
'- Punkte: ' . format_points((float) $evaluation['total']) . ' / ' . format_points((float) $evaluation['max_total']),
@@ -148,11 +196,13 @@ final class EntryRepository
'- Stimmung: ' . format_points((float) $evaluation['components']['mood']),
'- Energie: ' . format_points((float) $evaluation['components']['energy']),
'- Stress: ' . format_points((float) $evaluation['components']['stress']),
...(array_key_exists('pain', $evaluation['components']) ? ['- Schmerzen: ' . format_points((float) $evaluation['components']['pain'])] : []),
'- Schlafdauer: ' . format_points((float) $evaluation['components']['sleep_hours']),
'- Schlafgefühl: ' . format_points((float) $evaluation['components']['sleep_feeling']),
'- 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']),
'- Alkohol: ' . format_points((float) ($evaluation['components']['alcohol'] ?? 0)),
'- Notiz: ' . format_points((float) $evaluation['components']['note']),
'',
'## Notiz',
+149
View File
@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
final class NotificationRepository
{
private string $systemPath;
public function __construct()
{
$this->systemPath = storage_path('system/notifications.json');
}
public function systemConfig(): array
{
$config = decode_json_file($this->systemPath, []);
$changed = false;
if (!isset($config['cron_token']) || !is_string($config['cron_token']) || $config['cron_token'] === '') {
$config['cron_token'] = bin2hex(random_bytes(24));
$changed = true;
}
if (!isset($config['subject']) || !is_string($config['subject']) || $config['subject'] === '') {
$host = parse_url(app_origin(), PHP_URL_HOST);
$host = is_string($host) && $host !== '' ? $host : 'localhost';
$config['subject'] = 'mailto:hello@' . $host;
$changed = true;
}
if ($changed) {
$this->writeJson($this->systemPath, $config);
}
return $config;
}
public function saveVapidKeys(string $publicKey, string $privateKey): void
{
$config = $this->systemConfig();
$config['vapid_public_key'] = $publicKey;
$config['vapid_private_key'] = $privateKey;
$this->writeJson($this->systemPath, $config);
}
public function subscriptionsForUser(string $username): array
{
$payload = decode_json_file($this->subscriptionsPath($username), ['subscriptions' => []]);
return array_values(array_filter($payload['subscriptions'] ?? [], 'is_array'));
}
public function saveSubscription(string $username, array $subscription): void
{
$endpoint = trim((string) ($subscription['endpoint'] ?? ''));
if ($endpoint === '') {
throw new RuntimeException('Die Subscription ist unvollständig.');
}
$subscriptions = $this->subscriptionsForUser($username);
$saved = false;
foreach ($subscriptions as &$entry) {
if (($entry['endpoint'] ?? '') === $endpoint) {
$entry = array_merge($entry, $subscription, [
'updated_at' => date(DATE_ATOM),
]);
$saved = true;
break;
}
}
unset($entry);
if (!$saved) {
$subscription['created_at'] = date(DATE_ATOM);
$subscription['updated_at'] = date(DATE_ATOM);
$subscriptions[] = $subscription;
}
$this->writeJson($this->subscriptionsPath($username), ['subscriptions' => array_values($subscriptions)]);
}
public function removeSubscription(string $username, string $endpoint): void
{
$endpoint = trim($endpoint);
if ($endpoint === '') {
return;
}
$subscriptions = array_values(array_filter(
$this->subscriptionsForUser($username),
static fn (array $entry): bool => ($entry['endpoint'] ?? '') !== $endpoint
));
$this->writeJson($this->subscriptionsPath($username), ['subscriptions' => $subscriptions]);
}
public function removeInvalidSubscriptions(string $username, array $endpoints): void
{
foreach ($endpoints as $endpoint) {
if (is_string($endpoint) && $endpoint !== '') {
$this->removeSubscription($username, $endpoint);
}
}
}
public function reminderState(string $username): array
{
return decode_json_file($this->reminderStatePath($username), []);
}
public function saveReminderState(string $username, array $state): void
{
$this->writeJson($this->reminderStatePath($username), $state);
}
public function subscriptionCount(string $username): int
{
return count($this->subscriptionsForUser($username));
}
private function subscriptionsPath(string $username): string
{
return storage_path('users/' . normalize_username($username) . '/push-subscriptions.json');
}
private function reminderStatePath(string $username): string
{
return storage_path('users/' . normalize_username($username) . '/notification-state.json');
}
private function writeJson(string $path, array $payload): void
{
$directory = dirname($path);
if (!is_dir($directory)) {
mkdir($directory, 0775, true);
}
$bytes = file_put_contents(
$path,
json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
LOCK_EX
);
if ($bytes === false) {
throw new RuntimeException('Die Benachrichtigungsdaten konnten nicht gespeichert werden.');
}
}
}
+102 -2
View File
@@ -13,12 +13,17 @@ final class ScoringService
'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))),
'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))),
'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'] ?? '')),
];
}
@@ -31,6 +36,7 @@ final class ScoringService
$ratings = $this->sortedRatings($settings['ratings'] ?? []);
$sportTypes = find_sport_types($settings, $entry['sport_types']);
$sportBonus = $this->sportBonusPoints($entry, $previousEntry, $settings, $sportTypes);
$painEnabled = !empty($settings['tracking']['pain_enabled']);
$components = [
'mood' => $entry['mood'] * (float) $scoring['mood_multiplier'],
@@ -40,20 +46,26 @@ final class ScoringService
'sleep_feeling' => $entry['sleep_feeling'] * (float) $scoring['sleep_feeling_multiplier'],
'sport_minutes' => $this->bandPoints((int) $entry['sport_minutes'], $scoring['sport_bands']),
'sport_bonus' => $sportBonus,
'walk_minutes' => $this->bandPoints((int) $entry['walk_minutes'], $scoring['walk_bands']),
'walk_minutes' => $this->walkPoints($entry, $settings),
'alcohol' => !empty($entry['alcohol']) ? ((float) abs((float) ($scoring['alcohol_penalty'] ?? 5)) * -1) : 0.0,
'note' => $entry['note'] === '' ? 0 : (float) $scoring['journal_points'],
];
if ($painEnabled) {
$components['pain'] = (11 - $entry['pain']) * (float) $scoring['pain_multiplier'];
}
$total = round(array_sum($components), 1);
$maxTotal = round(
(10 * (float) $scoring['mood_multiplier']) +
(10 * (float) $scoring['energy_multiplier']) +
(10 * (float) $scoring['stress_multiplier']) +
($painEnabled ? (10 * (float) $scoring['pain_multiplier']) : 0.0) +
max(array_map('floatval', $scoring['sleep_duration_points'])) +
(5 * (float) $scoring['sleep_feeling_multiplier']) +
$this->maxBandPoints($scoring['sport_bands']) +
$this->maxSportBonusPoints($settings) +
$this->maxBandPoints($scoring['walk_bands']) +
$this->maxWalkPoints($entry, $settings) +
(float) $scoring['journal_points'],
1
);
@@ -146,6 +158,72 @@ final class ScoringService
return $max;
}
private function walkPoints(array $entry, array $settings): float
{
$entry = $this->normalize($entry);
$scoring = $settings['scoring'] ?? [];
if (($entry['walk_mode'] ?? 'time') === 'steps') {
return $this->stepTargetPoints((int) $entry['walk_steps'], $scoring['walk_step_targets'] ?? []);
}
return $this->bandPoints((int) $entry['walk_minutes'], $scoring['walk_bands'] ?? []);
}
private function maxWalkPoints(array $entry, array $settings): float
{
$scoring = $settings['scoring'] ?? [];
if (($entry['walk_mode'] ?? 'time') === 'steps') {
$max = 0.0;
foreach ($scoring['walk_step_targets'] ?? [] as $target) {
$max = max($max, (float) ($target['points'] ?? 0));
}
return $max;
}
return $this->maxBandPoints($scoring['walk_bands'] ?? []);
}
private function stepTargetPoints(int $steps, array $targets): float
{
if ($targets === []) {
return 0.0;
}
usort($targets, static fn (array $a, array $b): int => ((int) ($a['steps'] ?? 0)) <=> ((int) ($b['steps'] ?? 0)));
if ($steps <= (int) ($targets[0]['steps'] ?? 0)) {
return (float) ($targets[0]['points'] ?? 0);
}
$lastIndex = count($targets) - 1;
if ($steps >= (int) ($targets[$lastIndex]['steps'] ?? 0)) {
return (float) ($targets[$lastIndex]['points'] ?? 0);
}
for ($index = 1; $index < count($targets); $index++) {
$previous = $targets[$index - 1];
$current = $targets[$index];
$previousSteps = (int) ($previous['steps'] ?? 0);
$currentSteps = (int) ($current['steps'] ?? 0);
if ($steps > $currentSteps) {
continue;
}
$range = max(1, $currentSteps - $previousSteps);
$ratio = ($steps - $previousSteps) / $range;
$previousPoints = (float) ($previous['points'] ?? 0);
$currentPoints = (float) ($current['points'] ?? 0);
return round($previousPoints + (($currentPoints - $previousPoints) * $ratio), 1);
}
return 0.0;
}
private function maxSportBonusPoints(array $settings): float
{
$max = 0.0;
@@ -280,4 +358,26 @@ final class ScoringService
default => 'radiant',
};
}
private function normalizeWalkMode(string $mode): string
{
return $mode === 'steps' ? 'steps' : 'time';
}
private function normalizeBoolean(mixed $value): bool
{
if (is_bool($value)) {
return $value;
}
if (is_int($value) || is_float($value)) {
return (int) $value === 1;
}
if (!is_string($value)) {
return false;
}
return in_array(strtolower(trim($value)), ['1', 'true', 'yes', 'ja', 'on'], true);
}
}
+22
View File
@@ -16,6 +16,12 @@ final class Defaults
5 => 'sehr ausgeschlafen',
],
],
'walk' => [
'mode' => 'time',
],
'tracking' => [
'pain_enabled' => false,
],
'sport_types' => [
[
'id' => 'running',
@@ -112,6 +118,7 @@ final class Defaults
'mood_multiplier' => 3,
'energy_multiplier' => 2,
'stress_multiplier' => 2,
'pain_multiplier' => 3,
'sleep_feeling_multiplier' => 2,
'sleep_duration_points' => [
'lt4' => 0,
@@ -135,7 +142,18 @@ final class Defaults
['min' => 16, 'max' => 40, 'points' => 5],
['min' => 41, 'max' => 10000, 'points' => 7],
],
'walk_step_targets' => [
['steps' => 0, 'points' => 0],
['steps' => 3000, 'points' => 0],
['steps' => 5000, 'points' => 2],
['steps' => 7500, 'points' => 5],
['steps' => 10000, 'points' => 7],
['steps' => 12500, 'points' => 6],
['steps' => 15000, 'points' => 4],
['steps' => 20000, 'points' => 0],
],
'journal_points' => 2,
'alcohol_penalty' => 5,
],
'ratings' => [
['label' => 'Scheißtag', 'min' => 0, 'max' => 39],
@@ -156,6 +174,10 @@ final class Defaults
'cap_label' => 'schwerer Tag',
],
],
'notifications' => [
'enabled' => false,
'time' => '20:30',
],
];
}
}
+140
View File
@@ -0,0 +1,140 @@
<?php
declare(strict_types=1);
final class EntryCrypto
{
private const HEADER = "MOODENC1\n";
private string $fallbackKeyPath;
public function __construct()
{
$this->fallbackKeyPath = storage_path('system/entry-encryption.key');
}
public function isAvailable(): bool
{
return function_exists('openssl_encrypt')
&& function_exists('openssl_decrypt')
&& function_exists('random_bytes');
}
public function shouldEncrypt(): bool
{
return $this->isAvailable();
}
public function isEncrypted(string $content): bool
{
return str_starts_with($content, self::HEADER);
}
public function encrypt(string $plaintext): string
{
if (!$this->shouldEncrypt()) {
return $plaintext;
}
$iv = random_bytes(12);
$tag = '';
$ciphertext = openssl_encrypt(
$plaintext,
'aes-256-gcm',
$this->key(),
OPENSSL_RAW_DATA,
$iv,
$tag
);
if (!is_string($ciphertext) || $tag === '') {
throw new RuntimeException('Die Tagesdatei konnte nicht verschlüsselt werden.');
}
$payload = json_encode([
'iv' => base64_encode($iv),
'tag' => base64_encode($tag),
'data' => base64_encode($ciphertext),
], JSON_UNESCAPED_SLASHES);
if (!is_string($payload)) {
throw new RuntimeException('Die verschlüsselte Tagesdatei konnte nicht kodiert werden.');
}
return self::HEADER . $payload;
}
public function decrypt(string $content): string
{
if (!$this->isEncrypted($content) || !$this->shouldEncrypt()) {
return $content;
}
$payload = substr($content, strlen(self::HEADER));
$decoded = json_decode($payload, true);
if (
!is_array($decoded)
|| !is_string($decoded['iv'] ?? null)
|| !is_string($decoded['tag'] ?? null)
|| !is_string($decoded['data'] ?? null)
) {
throw new RuntimeException('Die verschlüsselte Tagesdatei ist ungültig.');
}
$plaintext = openssl_decrypt(
(string) base64_decode($decoded['data'], true),
'aes-256-gcm',
$this->key(),
OPENSSL_RAW_DATA,
(string) base64_decode($decoded['iv'], true),
(string) base64_decode($decoded['tag'], true)
);
if (!is_string($plaintext)) {
throw new RuntimeException('Die Tagesdatei konnte nicht entschlüsselt werden.');
}
return $plaintext;
}
private function key(): string
{
$configured = trim((string) ($_ENV['MOOD_STORAGE_KEY'] ?? getenv('MOOD_STORAGE_KEY') ?: ''));
if ($configured !== '') {
return hash('sha256', $configured, true);
}
$stored = $this->readFallbackKey();
if ($stored !== null) {
return $stored;
}
$raw = random_bytes(32);
$directory = dirname($this->fallbackKeyPath);
if (!is_dir($directory)) {
mkdir($directory, 0775, true);
}
file_put_contents($this->fallbackKeyPath, base64_encode($raw), LOCK_EX);
@chmod($this->fallbackKeyPath, 0600);
return $raw;
}
private function readFallbackKey(): ?string
{
if (!is_file($this->fallbackKeyPath)) {
return null;
}
$raw = trim((string) file_get_contents($this->fallbackKeyPath));
if ($raw === '') {
return null;
}
$decoded = base64_decode($raw, true);
return is_string($decoded) && strlen($decoded) === 32 ? $decoded : null;
}
}
+328
View File
@@ -0,0 +1,328 @@
<?php
declare(strict_types=1);
final class WebPushService
{
private NotificationRepository $notifications;
public function __construct(NotificationRepository $notifications)
{
$this->notifications = $notifications;
}
public function isAvailable(): bool
{
return function_exists('openssl_pkey_new')
&& function_exists('openssl_sign')
&& function_exists('openssl_encrypt')
&& function_exists('openssl_pkey_derive')
&& function_exists('curl_init');
}
public function publicKey(): ?string
{
$keys = $this->keys();
return $keys['public'] ?? null;
}
public function cronToken(): string
{
return (string) ($this->notifications->systemConfig()['cron_token'] ?? '');
}
public function send(array $subscription, array $message): array
{
if (!$this->isAvailable()) {
throw new RuntimeException('Web Push ist auf diesem Server aktuell nicht verfügbar.');
}
$endpoint = trim((string) ($subscription['endpoint'] ?? ''));
$p256dh = trim((string) ($subscription['keys']['p256dh'] ?? ''));
$auth = trim((string) ($subscription['keys']['auth'] ?? ''));
if ($endpoint === '' || $p256dh === '' || $auth === '') {
throw new RuntimeException('Die Push-Subscription ist unvollständig.');
}
$payload = json_encode([
'title' => (string) ($message['title'] ?? 'Mood-Board'),
'body' => (string) ($message['body'] ?? 'Zeit für deinen Eintrag.'),
'icon' => '/assets/branding/logo-mark.svg',
'badge' => '/assets/branding/favicon.svg',
'url' => (string) ($message['url'] ?? '/track'),
'tag' => (string) ($message['tag'] ?? 'mood-reminder'),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($payload)) {
throw new RuntimeException('Die Push-Nachricht konnte nicht kodiert werden.');
}
$encrypted = $this->encrypt($payload, $p256dh, $auth);
$audience = $this->audienceForEndpoint($endpoint);
$authorization = $this->authorizationHeader($audience);
$headers = [
'TTL: 3600',
'Urgency: normal',
'Content-Encoding: aes128gcm',
'Content-Type: application/octet-stream',
'Authorization: ' . $authorization['header'],
'Crypto-Key: p256ecdsa=' . $authorization['public'],
];
$handle = curl_init($endpoint);
if ($handle === false) {
throw new RuntimeException('Die Verbindung zum Push-Endpunkt konnte nicht vorbereitet werden.');
}
curl_setopt_array($handle, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $encrypted['body'],
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HEADER => false,
CURLOPT_TIMEOUT => 12,
]);
$responseBody = curl_exec($handle);
$status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
$error = curl_error($handle);
curl_close($handle);
return [
'ok' => $status >= 200 && $status < 300,
'status' => $status,
'remove' => in_array($status, [404, 410], true),
'error' => $error !== '' ? $error : null,
'response' => is_string($responseBody) ? $responseBody : null,
];
}
private function keys(): array
{
$config = $this->notifications->systemConfig();
$public = trim((string) ($config['vapid_public_key'] ?? ''));
$private = trim((string) ($config['vapid_private_key'] ?? ''));
if ($public !== '' && $private !== '') {
return ['public' => $public, 'private' => $private];
}
if (!$this->isAvailable()) {
return ['public' => null, 'private' => null];
}
$resource = openssl_pkey_new([
'private_key_type' => OPENSSL_KEYTYPE_EC,
'curve_name' => 'prime256v1',
]);
if ($resource === false) {
throw new RuntimeException('Die VAPID-Schlüssel konnten nicht erzeugt werden.');
}
$details = openssl_pkey_get_details($resource);
if (!is_array($details) || !isset($details['ec']['x'], $details['ec']['y'], $details['ec']['d'])) {
throw new RuntimeException('Die VAPID-Schlüsseldetails konnten nicht gelesen werden.');
}
$publicKey = "\x04" . $details['ec']['x'] . $details['ec']['y'];
$privateKey = $details['ec']['d'];
$encodedPublic = base64url_encode($publicKey);
$encodedPrivate = base64url_encode($privateKey);
$this->notifications->saveVapidKeys($encodedPublic, $encodedPrivate);
return ['public' => $encodedPublic, 'private' => $encodedPrivate];
}
private function encrypt(string $payload, string $userPublicKey, string $authSecret): array
{
$salt = random_bytes(16);
$userPublicRaw = base64url_decode($userPublicKey);
$authRaw = base64url_decode($authSecret);
$localKey = openssl_pkey_new([
'private_key_type' => OPENSSL_KEYTYPE_EC,
'curve_name' => 'prime256v1',
]);
if ($localKey === false) {
throw new RuntimeException('Der temporäre Push-Schlüssel konnte nicht erzeugt werden.');
}
$localDetails = openssl_pkey_get_details($localKey);
if (!is_array($localDetails) || !isset($localDetails['ec']['x'], $localDetails['ec']['y'])) {
throw new RuntimeException('Die temporären Push-Schlüsseldetails fehlen.');
}
$localPublicRaw = "\x04" . $localDetails['ec']['x'] . $localDetails['ec']['y'];
$userPem = $this->publicKeyPemFromRaw($userPublicRaw);
$sharedSecret = openssl_pkey_derive($userPem, $localKey, 32);
if (!is_string($sharedSecret) || $sharedSecret === '') {
throw new RuntimeException('Das gemeinsame Push-Geheimnis konnte nicht abgeleitet werden.');
}
$context = "WebPush: info\0" . $userPublicRaw . $localPublicRaw;
$keyMaterial = $this->hkdfExpand(
$this->hkdfExtract($authRaw, $sharedSecret),
$context,
32
);
$contentPrk = $this->hkdfExtract($salt, $keyMaterial);
$contentKey = $this->hkdfExpand($contentPrk, "Content-Encoding: aes128gcm\0", 16);
$nonce = $this->hkdfExpand($contentPrk, "Content-Encoding: nonce\0", 12);
$recordSize = 4096;
$plaintext = $payload . "\x02";
$tag = '';
$ciphertext = openssl_encrypt($plaintext, 'aes-128-gcm', $contentKey, OPENSSL_RAW_DATA, $nonce, $tag);
if (!is_string($ciphertext)) {
throw new RuntimeException('Die Push-Nachricht konnte nicht verschlüsselt werden.');
}
$body = $salt
. pack('N', $recordSize)
. chr(strlen($localPublicRaw))
. $localPublicRaw
. $ciphertext
. $tag;
return [
'body' => $body,
'local_public' => $localPublicRaw,
];
}
private function authorizationHeader(string $audience): array
{
$keys = $this->keys();
$header = base64url_encode((string) json_encode([
'typ' => 'JWT',
'alg' => 'ES256',
], JSON_UNESCAPED_SLASHES));
$payload = base64url_encode((string) json_encode([
'aud' => $audience,
'exp' => time() + 3600,
'sub' => (string) ($this->notifications->systemConfig()['subject'] ?? 'mailto:hello@localhost'),
], JSON_UNESCAPED_SLASHES));
$signingInput = $header . '.' . $payload;
$signatureDer = '';
$privatePem = $this->privateKeyPemFromRaw(base64url_decode((string) $keys['private']));
if (!openssl_sign($signingInput, $signatureDer, $privatePem, OPENSSL_ALGO_SHA256)) {
throw new RuntimeException('Die VAPID-Signatur konnte nicht erstellt werden.');
}
$jwt = $signingInput . '.' . base64url_encode($this->derSignatureToJose($signatureDer, 64));
return [
'header' => 'vapid t=' . $jwt . ', k=' . $keys['public'],
'public' => (string) $keys['public'],
];
}
private function audienceForEndpoint(string $endpoint): string
{
$parts = parse_url($endpoint);
if (!is_array($parts) || empty($parts['scheme']) || empty($parts['host'])) {
throw new RuntimeException('Der Push-Endpunkt ist ungültig.');
}
return $parts['scheme'] . '://' . $parts['host'];
}
private function hkdfExtract(string $salt, string $ikm): string
{
return hash_hmac('sha256', $ikm, $salt, true);
}
private function hkdfExpand(string $prk, string $info, int $length): string
{
$output = '';
$block = '';
$counter = 1;
while (strlen($output) < $length) {
$block = hash_hmac('sha256', $block . $info . chr($counter), $prk, true);
$output .= $block;
$counter++;
}
return substr($output, 0, $length);
}
private function publicKeyPemFromRaw(string $raw): string
{
$der = hex2bin('3059301306072A8648CE3D020106082A8648CE3D030107034200') . $raw;
return "-----BEGIN PUBLIC KEY-----\n"
. chunk_split(base64_encode((string) $der), 64, "\n")
. "-----END PUBLIC KEY-----\n";
}
private function privateKeyPemFromRaw(string $raw): string
{
$der = hex2bin('30770201010420')
. $raw
. hex2bin('A00A06082A8648CE3D030107A14403420004')
. substr(base64url_decode((string) $this->keys()['public']), 1);
return "-----BEGIN EC PRIVATE KEY-----\n"
. chunk_split(base64_encode((string) $der), 64, "\n")
. "-----END EC PRIVATE KEY-----\n";
}
private function derSignatureToJose(string $der, int $partLength): string
{
$offset = 0;
if (ord($der[$offset]) !== 0x30) {
throw new RuntimeException('Ungültige DER-Signatur.');
}
$offset++;
$this->readAsnLength($der, $offset);
if (ord($der[$offset]) !== 0x02) {
throw new RuntimeException('Ungültiger DER-R-Teil.');
}
$offset++;
$rLength = $this->readAsnLength($der, $offset);
$r = substr($der, $offset, $rLength);
$offset += $rLength;
if (ord($der[$offset]) !== 0x02) {
throw new RuntimeException('Ungültiger DER-S-Teil.');
}
$offset++;
$sLength = $this->readAsnLength($der, $offset);
$s = substr($der, $offset, $sLength);
return str_pad(ltrim($r, "\x00"), $partLength / 2, "\x00", STR_PAD_LEFT)
. str_pad(ltrim($s, "\x00"), $partLength / 2, "\x00", STR_PAD_LEFT);
}
private function readAsnLength(string $der, int &$offset): int
{
$length = ord($der[$offset]);
$offset++;
if (($length & 0x80) === 0) {
return $length;
}
$numberOfBytes = $length & 0x7F;
$length = 0;
for ($index = 0; $index < $numberOfBytes; $index++) {
$length = ($length << 8) | ord($der[$offset]);
$offset++;
}
return $length;
}
}
+3
View File
@@ -5,11 +5,14 @@ declare(strict_types=1);
require __DIR__ . '/helpers.php';
require __DIR__ . '/Support/Defaults.php';
require __DIR__ . '/Support/Auth.php';
require __DIR__ . '/Support/EntryCrypto.php';
require __DIR__ . '/Support/View.php';
require __DIR__ . '/Support/WebPushService.php';
require __DIR__ . '/Domain/UserRepository.php';
require __DIR__ . '/Domain/SettingsRepository.php';
require __DIR__ . '/Domain/EntryRepository.php';
require __DIR__ . '/Domain/LoginThrottle.php';
require __DIR__ . '/Domain/NotificationRepository.php';
require __DIR__ . '/Domain/ScoringService.php';
require __DIR__ . '/App.php';
+126
View File
@@ -29,6 +29,14 @@ function redirect(string $path): never
exit;
}
function json_response(array $payload, int $status = 200): never
{
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
function request_path(): string
{
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
@@ -80,6 +88,17 @@ function verify_csrf(?string $token): bool
return hash_equals(csrf_token(), $token);
}
function verify_request_csrf(): bool
{
$headerToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? null;
if (is_string($headerToken) && $headerToken !== '') {
return verify_csrf($headerToken);
}
return verify_csrf($_POST['_token'] ?? null);
}
function is_active_path(string $path): bool
{
return request_path() === $path;
@@ -117,6 +136,18 @@ function decode_json_file(string $path, array $fallback = []): array
return is_array($decoded) ? $decoded : $fallback;
}
function request_json_body(): array
{
$raw = file_get_contents('php://input');
if (!is_string($raw) || trim($raw) === '') {
return [];
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : [];
}
function encode_payload(array $payload): string
{
return base64_encode((string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
@@ -188,6 +219,14 @@ function request_is_secure(): bool
);
}
function app_origin(): string
{
$host = (string) ($_SERVER['HTTP_HOST'] ?? 'localhost');
$scheme = request_is_secure() ? 'https' : 'http';
return $scheme . '://' . $host;
}
function remember_me_lifetime(): int
{
return 60 * 60 * 24 * 30;
@@ -257,6 +296,93 @@ function sport_location_label(?string $value): string
return $options[$value] ?? '';
}
function walk_mode_options(): array
{
return [
'time' => 'Spaziergang nach Zeit',
'steps' => 'Spaziergang nach Schritten',
];
}
function walk_mode_label(string $mode): string
{
return walk_mode_options()[$mode] ?? walk_mode_options()['time'];
}
function format_walk_value(array $entry): string
{
$mode = (string) ($entry['walk_mode'] ?? 'time');
if ($mode === 'steps') {
return number_format((int) ($entry['walk_steps'] ?? 0), 0, ',', '.') . ' Schritte';
}
return (string) ((int) ($entry['walk_minutes'] ?? 0)) . ' min';
}
function walk_chart_value(array $entry): int
{
$mode = (string) ($entry['walk_mode'] ?? 'time');
if ($mode === 'steps') {
return max(0, (int) round(((int) ($entry['walk_steps'] ?? 0)) / 200));
}
return max(0, (int) ($entry['walk_minutes'] ?? 0));
}
function base64url_encode(string $data): string
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
function base64url_decode(string $data): string
{
$padding = strlen($data) % 4;
if ($padding > 0) {
$data .= str_repeat('=', 4 - $padding);
}
$decoded = base64_decode(strtr($data, '-_', '+/'), true);
if ($decoded === false) {
throw new RuntimeException('Ungültige Base64url-Daten.');
}
return $decoded;
}
function uploaded_files(string $field): array
{
$raw = $_FILES[$field] ?? null;
if (!is_array($raw) || !isset($raw['name'])) {
return [];
}
if (!is_array($raw['name'])) {
return [[
'name' => (string) ($raw['name'] ?? ''),
'type' => (string) ($raw['type'] ?? ''),
'tmp_name' => (string) ($raw['tmp_name'] ?? ''),
'error' => (int) ($raw['error'] ?? UPLOAD_ERR_NO_FILE),
'size' => (int) ($raw['size'] ?? 0),
]];
}
$files = [];
foreach ($raw['name'] as $index => $name) {
$files[] = [
'name' => (string) ($name ?? ''),
'type' => (string) ($raw['type'][$index] ?? ''),
'tmp_name' => (string) ($raw['tmp_name'][$index] ?? ''),
'error' => (int) ($raw['error'][$index] ?? UPLOAD_ERR_NO_FILE),
'size' => (int) ($raw['size'][$index] ?? 0),
];
}
return $files;
}
function normalize_sport_type_id(string $value): string
{
$value = trim(strtr($value, [
+42 -4
View File
@@ -6,7 +6,7 @@ $brandSubtitle = match ($page) {
'dashboard' => 'Statistiken und Verlauf',
'track' => 'Tag erfassen und bewerten',
'archive' => 'Rückblick auf vergangene Tage',
'options' => 'Logik, Sicherheit und Accounts',
'options' => 'Logik, Erinnerungen, Sicherheit und Accounts',
'login' => 'Geschützter Zugang',
'setup' => 'Erstkonfiguration',
default => 'Stimmungstracker',
@@ -19,15 +19,27 @@ $brandSubtitle = match ($page) {
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#0b1e2e">
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Mood-Board">
<meta name="csrf-token" content="<?= e(csrf_token()) ?>">
<?php if (!empty($pushPublicKey)): ?>
<meta name="mood-push-public-key" content="<?= e((string) $pushPublicKey) ?>">
<?php endif; ?>
<title><?= e($pageTitle) ?> · Mood</title>
<link rel="icon" type="image/svg+xml" href="/assets/branding/favicon.svg">
<link rel="shortcut icon" href="/assets/branding/favicon.svg">
<link rel="icon" type="image/svg+xml" href="/assets/branding/favicon.svg?v=20260412">
<link rel="icon" type="image/png" sizes="32x32" href="/assets/branding/favicon-32.png?v=20260412">
<link rel="icon" type="image/png" sizes="16x16" href="/assets/branding/favicon-16.png?v=20260412">
<link rel="shortcut icon" href="/favicon.ico?v=20260412">
<link rel="apple-touch-icon" sizes="180x180" href="/assets/branding/apple-touch-icon.png?v=20260412">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="stylesheet" href="/assets/css/app.css">
<script defer src="/assets/js/app.js"></script>
</head>
<body class="app-body page-<?= e($page) ?>"<?= isset($trackMood) ? ' data-track-mood="' . e($trackMood) . '"' : '' ?>>
<body class="app-body page-<?= e($page) ?><?= $authUser !== null ? ' is-authenticated' : '' ?>" data-authenticated="<?= $authUser !== null ? '1' : '0' ?>"<?= isset($trackMood) ? ' data-track-mood="' . e($trackMood) . '"' : '' ?>>
<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): ?>
<aside class="sidebar glass-panel">
@@ -100,7 +112,33 @@ $brandSubtitle = match ($page) {
<?php endforeach; ?>
<?= $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>
</main>
<?php if ($authUser !== null): ?>
<nav class="mobile-nav glass-panel" aria-label="Mobile Hauptnavigation">
<a class="<?= is_active_path('/') ? 'active' : '' ?>" href="/">
<img class="nav-icon" src="<?= e(icon_path('dashboard')) ?>" alt="">
<span>Dashboard</span>
</a>
<a class="<?= is_active_path('/track') ? 'active' : '' ?>" href="/track">
<img class="nav-icon" src="<?= e(icon_path('track')) ?>" alt="">
<span>Tracken</span>
</a>
<a class="<?= is_active_path('/archive') ? 'active' : '' ?>" href="/archive">
<img class="nav-icon" src="<?= e(icon_path('archive')) ?>" alt="">
<span>Archiv</span>
</a>
<a class="<?= is_active_path('/options') ? 'active' : '' ?>" href="/options">
<img class="nav-icon" src="<?= e(icon_path('options')) ?>" alt="">
<span>Optionen</span>
</a>
</nav>
<?php endif; ?>
</div>
</body>
</html>
+5 -1
View File
@@ -54,6 +54,9 @@
<div><dt>Stimmung</dt><dd><?= e((string) $selectedEntry['mood']) ?>/10</dd></div>
<div><dt>Energie</dt><dd><?= e((string) $selectedEntry['energy']) ?>/10</dd></div>
<div><dt>Stress</dt><dd><?= e((string) $selectedEntry['stress']) ?>/10</dd></div>
<?php if (!empty($settings['tracking']['pain_enabled'])): ?>
<div><dt>Schmerzen</dt><dd><?= e((string) $selectedEntry['pain']) ?>/10</dd></div>
<?php endif; ?>
<div><dt>Schlaf</dt><dd><?= e((string) $selectedEntry['sleep_hours']) ?> h</dd></div>
<div><dt>Schlafgefühl</dt><dd><?= e((string) $selectedEntry['sleep_feeling']) ?>/5</dd></div>
<div><dt>Sport</dt><dd><?= e((string) $selectedEntry['sport_minutes']) ?> min</dd></div>
@@ -75,7 +78,8 @@
</dd>
</div>
<div><dt>Sportbonus</dt><dd><?= e(format_points((float) ($selectedEntry['evaluation']['components']['sport_bonus'] ?? 0))) ?></dd></div>
<div><dt>Spaziergang</dt><dd><?= e((string) $selectedEntry['walk_minutes']) ?> min</dd></div>
<div><dt>Spaziergang</dt><dd><?= e(format_walk_value($selectedEntry)) ?></dd></div>
<div><dt>Alkohol</dt><dd><?= !empty($selectedEntry['alcohol']) ? 'ja' : 'nein' ?></dd></div>
</dl>
<div class="note-box">
+14 -1
View File
@@ -73,13 +73,26 @@
<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>
</div>
<span class="chart-chip chart-chip--cool">Minuten pro Tag</span>
<span class="chart-chip chart-chip--cool">Aktivität pro Tag</span>
</div>
<div class="bar-chart" data-chart-type="bars" data-series="sport" data-payload="<?= e($chartPayload) ?>"></div>
</article>
+131 -9
View File
@@ -23,6 +23,30 @@
</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">
@@ -49,16 +73,39 @@
</div>
<div class="settings-section">
<h4>Spaziergang-Bänder</h4>
<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 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">
@@ -217,6 +264,55 @@
</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>
@@ -254,6 +350,32 @@
</article>
<aside class="stack-column">
<article class="glass-panel detail-card">
<p class="eyebrow">Backup</p>
<h3>Eigene Einträge sichern</h3>
<p class="helper-text">Deine Tagesdateien liegen auf dem Server verschlüsselt. Beim Download bekommst du automatisch ein lesbares Backup mit allen Tagen als `.txt`-Dateien in einer ZIP-Datei. Beim Import werden diese Dateien wieder still im Hintergrund geschützt gespeichert.</p>
<form method="post" action="/options" class="stack-form">
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="export_backup">
<button class="primary-button" type="submit" <?= empty($backupAvailable) ? 'disabled' : '' ?>>Backup herunterladen</button>
<?php if (empty($backupAvailable)): ?>
<p class="helper-text">Auf diesem Server fehlt gerade die ZIP-Erweiterung für den Download.</p>
<?php endif; ?>
</form>
<form method="post" action="/options" enctype="multipart/form-data" class="stack-form">
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="import_backup">
<label>
<span>Backup importieren</span>
<input type="file" name="backup_files[]" accept=".zip,.txt" multiple>
</label>
<p class="helper-text">Du kannst ein komplettes ZIP-Backup oder einzelne Tagesdateien wie `2026-04-13.txt` importieren. Vorhandene Tage mit demselben Datum werden dabei aktualisiert.</p>
<button class="ghost-button" type="submit">Backup importieren</button>
</form>
</article>
<article class="glass-panel detail-card">
<p class="eyebrow">Sicherheit</p>
<h3>Passwort ändern</h3>
+42 -2
View File
@@ -14,6 +14,8 @@
<form method="post" action="/track" class="tracker-form" id="tracker-form" autocomplete="off">
<?= csrf_field() ?>
<input type="hidden" name="date" value="<?= e($entry['date']) ?>">
<?php $walkMode = ($entry['walk_mode'] ?? ($settings['walk']['mode'] ?? 'time')) === 'steps' ? 'steps' : 'time'; ?>
<input type="hidden" name="walk_mode" value="<?= e($walkMode) ?>">
<div class="field-grid field-grid--three">
<label class="range-card">
@@ -35,6 +37,34 @@
</label>
</div>
<?php if (!empty($settings['tracking']['pain_enabled'])): ?>
<div class="field-grid field-grid--two">
<label class="range-card">
<span>Schmerzen</span>
<output data-output-for="pain"><?= e((string) $entry['pain']) ?></output>
<input type="range" min="1" max="10" step="1" name="pain" value="<?= e((string) $entry['pain']) ?>">
</label>
<label class="checkbox-row checkbox-row--panel checkbox-row--tall">
<input type="checkbox" name="alcohol" value="1" <?= !empty($entry['alcohol']) ? 'checked' : '' ?>>
<span>
<strong>Alkohol</strong>
<small>Wenn du heute Alkohol getrunken hast, werden 5 Punkte abgezogen.</small>
</span>
</label>
</div>
<?php else: ?>
<div class="field-grid field-grid--single">
<label class="checkbox-row checkbox-row--panel">
<input type="checkbox" name="alcohol" value="1" <?= !empty($entry['alcohol']) ? 'checked' : '' ?>>
<span>
<strong>Alkohol</strong>
<small>Wenn du heute Alkohol getrunken hast, werden 5 Punkte abgezogen.</small>
</span>
</label>
</div>
<?php endif; ?>
<div class="field-grid field-grid--two">
<label>
<span>Schlafdauer in Stunden</span>
@@ -60,8 +90,16 @@
</label>
<label>
<span>Spaziergang in Minuten</span>
<input type="number" min="0" max="1440" step="1" name="walk_minutes" value="<?= e((string) $entry['walk_minutes']) ?>" required>
<span><?= $walkMode === 'steps' ? 'Spaziergang in Schritten' : 'Spaziergang in Minuten' ?></span>
<input
type="number"
min="0"
max="<?= $walkMode === 'steps' ? '50000' : '1440' ?>"
step="1"
name="<?= $walkMode === 'steps' ? 'walk_steps' : 'walk_minutes' ?>"
value="<?= e((string) ($walkMode === 'steps' ? ($entry['walk_steps'] ?? 0) : $entry['walk_minutes'])) ?>"
required
>
</label>
</div>
@@ -117,11 +155,13 @@
'mood' => 'Stimmung',
'energy' => 'Energie',
'stress' => 'Stress',
'pain' => 'Schmerzen',
'sleep_hours' => 'Schlafdauer',
'sleep_feeling' => 'Schlafgefühl',
'sport_minutes' => 'Sport',
'sport_bonus' => 'Sportbonus',
'walk_minutes' => 'Spaziergang',
'alcohol' => 'Alkohol',
'note' => 'Notiz',
];
?>