3 Commits

27 changed files with 1736 additions and 35 deletions
+1 -1
View File
@@ -1,5 +1,6 @@
Options -Indexes Options -Indexes
DirectoryIndex index.php DirectoryIndex index.php
AddType application/manifest+json .webmanifest
<IfModule mod_rewrite.c> <IfModule mod_rewrite.c>
RewriteEngine On RewriteEngine On
@@ -11,4 +12,3 @@ DirectoryIndex index.php
RewriteCond %{REQUEST_FILENAME} !-d RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [QSA,L] RewriteRule ^ index.php [QSA,L]
</IfModule> </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. - 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. - 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. - 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

+137
View File
@@ -128,6 +128,34 @@ body {
color: var(--text); 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 { a {
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
@@ -144,6 +172,12 @@ button {
cursor: pointer; cursor: pointer;
} }
button:disabled {
cursor: not-allowed;
opacity: 0.55;
transform: none !important;
}
.aurora { .aurora {
position: fixed; position: fixed;
inset: auto; inset: auto;
@@ -314,6 +348,10 @@ button {
margin-top: 2rem; margin-top: 2rem;
} }
.mobile-nav {
display: none;
}
.main-nav a { .main-nav a {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1014,6 +1052,30 @@ input[type="range"] {
margin-top: 0.95rem; 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, .primary-button,
.ghost-button, .ghost-button,
.button-link { .button-link {
@@ -1239,6 +1301,14 @@ input[type="range"] {
gap: 0.7rem; gap: 0.7rem;
} }
.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 input { .checkbox-row input {
width: auto; width: auto;
} }
@@ -1340,6 +1410,10 @@ input[type="range"] {
} }
@media (max-width: 820px) { @media (max-width: 820px) {
.shell {
grid-template-columns: 1fr;
}
.topbar, .topbar,
.section-head, .section-head,
.form-actions { .form-actions {
@@ -1362,6 +1436,53 @@ input[type="range"] {
grid-template-columns: repeat(2, minmax(0, 1fr)); 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));
}
.bar-chart { .bar-chart {
overflow-x: auto; overflow-x: auto;
padding-bottom: 0.4rem; padding-bottom: 0.4rem;
@@ -1426,4 +1547,20 @@ input[type="range"] {
.bar-chart { .bar-chart {
min-height: 9.5rem; 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;
}
} }
+357 -3
View File
@@ -113,6 +113,38 @@
return last ? Number(last.points || 0) : 0; 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) { function sortedRatings(ratings) {
return [...(ratings || [])].sort((a, b) => Number(a.min || 0) - Number(b.min || 0)); return [...(ratings || [])].sort((a, b) => Number(a.min || 0) - Number(b.min || 0));
} }
@@ -231,6 +263,7 @@
function evaluateEntry(entry, settings, previousEntry = null) { function evaluateEntry(entry, settings, previousEntry = null) {
const ratings = sortedRatings(settings.ratings || []); const ratings = sortedRatings(settings.ratings || []);
const scoring = settings.scoring || {}; const scoring = settings.scoring || {};
const walkMode = entry.walk_mode === "steps" ? "steps" : "time";
const components = { const components = {
mood: Number(entry.mood) * Number(scoring.mood_multiplier || 0), mood: Number(entry.mood) * Number(scoring.mood_multiplier || 0),
energy: Number(entry.energy) * Number(scoring.energy_multiplier || 0), energy: Number(entry.energy) * Number(scoring.energy_multiplier || 0),
@@ -239,7 +272,9 @@
sleep_feeling: Number(entry.sleep_feeling) * Number(scoring.sleep_feeling_multiplier || 0), sleep_feeling: Number(entry.sleep_feeling) * Number(scoring.sleep_feeling_multiplier || 0),
sport_minutes: bandPoints(Number(entry.sport_minutes), scoring.sport_bands || []), sport_minutes: bandPoints(Number(entry.sport_minutes), scoring.sport_bands || []),
sport_bonus: sportBonusPoints(entry, settings, previousEntry), 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 || []),
note: String(entry.note || "").trim() === "" ? 0 : Number(scoring.journal_points || 0), note: String(entry.note || "").trim() === "" ? 0 : Number(scoring.journal_points || 0),
}; };
@@ -297,7 +332,9 @@
sleep_feeling: Number(form.elements.sleep_feeling.value), sleep_feeling: Number(form.elements.sleep_feeling.value),
sport_minutes: Number(form.elements.sport_minutes.value || 0), sport_minutes: Number(form.elements.sport_minutes.value || 0),
sport_types: [...form.querySelectorAll('input[name="sport_types[]"]:checked')].map(input => input.value), 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),
note: form.elements.note.value || "", note: form.elements.note.value || "",
}); });
@@ -452,6 +489,7 @@
const sportY = walkY - sportHeight; const sportY = walkY - sportHeight;
const badgeY = total > 0 ? Math.max(backgroundY + 6, sportY - 24) : (baseY - 20); 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 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 label = labels.length ? ` · ${labels.join(", ")}` : "";
const bonus = Number(item.sport_bonus || 0); const bonus = Number(item.sport_bonus || 0);
const icons = Array.isArray(item.sport_icons) ? item.sport_icons.slice(0, 3) : []; const icons = Array.isArray(item.sport_icons) ? item.sport_icons.slice(0, 3) : [];
@@ -469,7 +507,7 @@
return ` return `
<rect class="bar-grid" x="${x}" y="${backgroundY}" width="18" height="${chartHeight}" rx="9"></rect> <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"> <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>
<rect class="bar-segment--sport" x="${x}" y="${sportY}" width="18" height="${sportHeight}" rx="9"> <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> <title>${formatDateLabel(item.date)} · Sport ${sport} min${label}${bonus > 0 ? ` · Bonus ${formatNumber(bonus)}` : ""}</title>
@@ -898,6 +936,319 @@
syncPresets(); 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", () => { window.addEventListener("resize", () => {
if (!document.querySelector("#calendar-heatmap")) { if (!document.querySelector("#calendar-heatmap")) {
return; return;
@@ -914,4 +1265,7 @@
initTrackPreview(); initTrackPreview();
initDashboardCharts(); initDashboardCharts();
initSportTypeManager(); 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);
})());
});
+312 -10
View File
@@ -10,6 +10,8 @@ final class App
private LoginThrottle $throttle; private LoginThrottle $throttle;
private ScoringService $scoring; private ScoringService $scoring;
private Auth $auth; private Auth $auth;
private NotificationRepository $notifications;
private WebPushService $webPush;
public function __construct() public function __construct()
{ {
@@ -19,6 +21,8 @@ final class App
$this->throttle = new LoginThrottle(); $this->throttle = new LoginThrottle();
$this->scoring = new ScoringService(); $this->scoring = new ScoringService();
$this->auth = new Auth($this->users); $this->auth = new Auth($this->users);
$this->notifications = new NotificationRepository();
$this->webPush = new WebPushService($this->notifications);
} }
public function run(): void public function run(): void
@@ -29,6 +33,7 @@ final class App
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; $method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$hasUsers = $this->users->hasAnyUsers(); $hasUsers = $this->users->hasAnyUsers();
$isAuthenticated = $this->auth->check(); $isAuthenticated = $this->auth->check();
$systemPaths = ['/reminders/run'];
// A failed setup must never leave the app in a half-authenticated redirect loop. // A failed setup must never leave the app in a half-authenticated redirect loop.
if (!$hasUsers && $isAuthenticated) { if (!$hasUsers && $isAuthenticated) {
@@ -39,13 +44,13 @@ final class App
if (!$hasUsers) { if (!$hasUsers) {
if ($path === '/login') { if ($path === '/login') {
$path = '/setup'; $path = '/setup';
} elseif ($path !== '/setup') { } elseif ($path !== '/setup' && !in_array($path, $systemPaths, true)) {
redirect('/setup'); redirect('/setup');
} }
} elseif (!$isAuthenticated) { } elseif (!$isAuthenticated) {
if ($path === '/setup') { if ($path === '/setup') {
$path = '/login'; $path = '/login';
} elseif ($path !== '/login') { } elseif ($path !== '/login' && !in_array($path, $systemPaths, true)) {
redirect('/login'); redirect('/login');
} }
} }
@@ -90,6 +95,37 @@ final class App
$method === 'POST' ? $this->handleOptions() : $this->showOptions(); $method === 'POST' ? $this->handleOptions() : $this->showOptions();
return; 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: default:
http_response_code(404); http_response_code(404);
View::render('not-found', [ View::render('not-found', [
@@ -221,7 +257,9 @@ final class App
'sport_minutes' => 0, 'sport_minutes' => 0,
'sport_type' => '', 'sport_type' => '',
'sport_types' => [], 'sport_types' => [],
'walk_mode' => (string) ($settings['walk']['mode'] ?? 'time'),
'walk_minutes' => 0, 'walk_minutes' => 0,
'walk_steps' => 0,
'note' => '', 'note' => '',
]; ];
@@ -263,7 +301,9 @@ final class App
'sleep_feeling' => $_POST['sleep_feeling'] ?? 3, 'sleep_feeling' => $_POST['sleep_feeling'] ?? 3,
'sport_minutes' => $_POST['sport_minutes'] ?? 0, 'sport_minutes' => $_POST['sport_minutes'] ?? 0,
'sport_types' => $_POST['sport_types'] ?? [], 'sport_types' => $_POST['sport_types'] ?? [],
'walk_mode' => $_POST['walk_mode'] ?? ($settings['walk']['mode'] ?? 'time'),
'walk_minutes' => $_POST['walk_minutes'] ?? 0, 'walk_minutes' => $_POST['walk_minutes'] ?? 0,
'walk_steps' => $_POST['walk_steps'] ?? 0,
'note' => $_POST['note'] ?? '', 'note' => $_POST['note'] ?? '',
]); ]);
@@ -323,6 +363,16 @@ final class App
return true; return true;
} }
)); ));
$pushAvailable = $this->webPush->isAvailable();
$pushPublicKey = null;
if ($pushAvailable) {
try {
$pushPublicKey = $this->webPush->publicKey();
} catch (RuntimeException) {
$pushAvailable = false;
}
}
View::render('options', [ View::render('options', [
'pageTitle' => 'Optionen', 'pageTitle' => 'Optionen',
@@ -331,6 +381,10 @@ final class App
'settings' => $settings, 'settings' => $settings,
'sportTypePresets' => $sportTypePresets, 'sportTypePresets' => $sportTypePresets,
'sportLocationOptions' => sport_location_options(), 'sportLocationOptions' => sport_location_options(),
'walkModeOptions' => walk_mode_options(),
'pushAvailable' => $pushAvailable,
'pushPublicKey' => $pushPublicKey,
'pushSubscriptionCount' => $this->notifications->subscriptionCount($user['username']),
'users' => $user['is_admin'] ? $this->users->all() : [], 'users' => $user['is_admin'] ? $this->users->all() : [],
'maxScore' => $this->scoring->evaluate([ 'maxScore' => $this->scoring->evaluate([
'mood' => 10, 'mood' => 10,
@@ -343,7 +397,9 @@ final class App
static fn (array $type): string => (string) ($type['id'] ?? ''), static fn (array $type): string => (string) ($type['id'] ?? ''),
normalized_sport_types($settings) normalized_sport_types($settings)
), ),
'walk_mode' => (string) ($settings['walk']['mode'] ?? 'time'),
'walk_minutes' => 999, 'walk_minutes' => 999,
'walk_steps' => 10000,
'note' => 'x', 'note' => 'x',
], $settings)['max_total'], ], $settings)['max_total'],
]); ]);
@@ -357,7 +413,8 @@ final class App
$form = (string) ($_POST['form_name'] ?? ''); $form = (string) ($_POST['form_name'] ?? '');
if ($form === 'settings') { 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); $this->settings->saveForUser($user['username'], $settings);
flash('success', 'Deine persönlichen Optionen wurden aktualisiert.'); flash('success', 'Deine persönlichen Optionen wurden aktualisiert.');
redirect('/options'); redirect('/options');
@@ -478,9 +535,10 @@ final class App
'sport' => array_map(static function (array $entry): array { 'sport' => array_map(static function (array $entry): array {
return [ return [
'date' => $entry['date'], 'date' => $entry['date'],
'value' => $entry['sport_minutes'] + $entry['walk_minutes'], 'value' => $entry['sport_minutes'] + walk_chart_value($entry),
'sport' => $entry['sport_minutes'], '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( 'sport_labels' => array_values(array_filter(array_map(
static function (array $type): string { static function (array $type): string {
$label = (string) ($type['label'] ?? ''); $label = (string) ($type['label'] ?? '');
@@ -528,10 +586,14 @@ final class App
return $streak; return $streak;
} }
private function sanitizeSettings(array $input): array private function sanitizeSettings(array $input, ?array $existingSettings = null): array
{ {
$defaults = Defaults::settings(); $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']['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']['energy_multiplier'] = max(0, min(10, (int) ($input['scoring']['energy_multiplier'] ?? 2)));
@@ -545,10 +607,12 @@ final class App
foreach (['sport_bands', 'walk_bands'] as $bandKey) { foreach (['sport_bands', 'walk_bands'] as $bandKey) {
foreach ($defaults['scoring'][$bandKey] as $index => $defaultBand) { foreach ($defaults['scoring'][$bandKey] as $index => $defaultBand) {
$currentBand = $settings['scoring'][$bandKey][$index] ?? $defaultBand;
$settings['scoring'][$bandKey][$index] = [ $settings['scoring'][$bandKey][$index] = [
'min' => max(0, min(2000, (int) ($input['scoring'][$bandKey][$index]['min'] ?? $defaultBand['min']))), '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'] ?? $defaultBand['max']))), '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'] ?? $defaultBand['points']))), 'points' => max(0, min(20, (int) ($input['scoring'][$bandKey][$index]['points'] ?? $currentBand['points'] ?? $defaultBand['points']))),
]; ];
} }
} }
@@ -579,12 +643,30 @@ final class App
: ($sportTypesProvided ? [] : $defaults['sport_types']), : ($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; return $settings;
} }
private function hydrateSettings(array $settings): array private function hydrateSettings(array $settings): array
{ {
$settings['sport_types'] = normalized_sport_types($settings); $settings['sport_types'] = normalized_sport_types($settings);
$settings['walk'] = array_replace(
Defaults::settings()['walk'],
is_array($settings['walk'] ?? null) ? $settings['walk'] : []
);
$settings['notifications'] = array_replace(
Defaults::settings()['notifications'],
is_array($settings['notifications'] ?? null) ? $settings['notifications'] : []
);
return $settings; return $settings;
} }
@@ -632,6 +714,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 private function requireUser(): array
{ {
$user = $this->auth->user(); $user = $this->auth->user();
@@ -660,10 +752,220 @@ final class App
return strlen($password) >= 10; 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 private function throttleKey(string $username): string
{ {
$remoteAddress = (string) ($_SERVER['REMOTE_ADDR'] ?? 'unknown'); $remoteAddress = (string) ($_SERVER['REMOTE_ADDR'] ?? 'unknown');
return sha1($remoteAddress . '|' . normalize_username($username)); 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);
}
$now = new DateTimeImmutable('now');
$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' => date(DATE_ATOM),
]);
}
}
json_response([
'ok' => true,
'processed' => $processed,
'sent_users' => $sentUsers,
'already_tracked' => $alreadyTracked,
'skipped' => $skipped,
'removed_subscriptions' => $removed,
]);
}
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;
}
} }
+9 -2
View File
@@ -77,6 +77,10 @@ final class EntryRepository
$sportTypes = normalize_sport_type_selection($sportType); $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);
$entry = [ $entry = [
'date' => $this->extract('/^Datum:\s*(\d{4}-\d{2}-\d{2})$/m', $content) ?? $fallbackDate, 'date' => $this->extract('/^Datum:\s*(\d{4}-\d{2}-\d{2})$/m', $content) ?? $fallbackDate,
'mood' => (int) ($this->extract('/^- Stimmung:\s*(.+)$/m', $content) ?? 5), 'mood' => (int) ($this->extract('/^- Stimmung:\s*(.+)$/m', $content) ?? 5),
@@ -87,7 +91,9 @@ final class EntryRepository
'sport_minutes' => (int) ($this->extract('/^- Sport:\s*(.+)$/m', $content) ?? 0), 'sport_minutes' => (int) ($this->extract('/^- Sport:\s*(.+)$/m', $content) ?? 0),
'sport_type' => $sportTypes[0] ?? '', 'sport_type' => $sportTypes[0] ?? '',
'sport_types' => $sportTypes, '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,
'note' => $this->extractNote($content), 'note' => $this->extractNote($content),
]; ];
@@ -138,7 +144,8 @@ final class EntryRepository
'- Schlafgefühl: ' . $entry['sleep_feeling'], '- Schlafgefühl: ' . $entry['sleep_feeling'],
'- Sport: ' . $entry['sport_minutes'], '- Sport: ' . $entry['sport_minutes'],
'- Sportarten: ' . implode(', ', $sportTypeValues), '- 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)),
'', '',
'## Bewertung', '## Bewertung',
'- Punkte: ' . format_points((float) $evaluation['total']) . ' / ' . format_points((float) $evaluation['max_total']), '- Punkte: ' . format_points((float) $evaluation['total']) . ' / ' . format_points((float) $evaluation['max_total']),
+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.');
}
}
}
+75 -2
View File
@@ -18,7 +18,9 @@ final class ScoringService
'sport_minutes' => max(0, min(1440, (int) ($input['sport_minutes'] ?? 0))), 'sport_minutes' => max(0, min(1440, (int) ($input['sport_minutes'] ?? 0))),
'sport_type' => $sportTypes[0] ?? '', 'sport_type' => $sportTypes[0] ?? '',
'sport_types' => $sportTypes, 'sport_types' => $sportTypes,
'walk_mode' => $this->normalizeWalkMode((string) ($input['walk_mode'] ?? ((isset($input['walk_steps']) && !isset($input['walk_minutes'])) ? 'steps' : 'time'))),
'walk_minutes' => max(0, min(1440, (int) ($input['walk_minutes'] ?? 0))), 'walk_minutes' => max(0, min(1440, (int) ($input['walk_minutes'] ?? 0))),
'walk_steps' => max(0, min(50000, (int) ($input['walk_steps'] ?? 0))),
'note' => trim((string) ($input['note'] ?? '')), 'note' => trim((string) ($input['note'] ?? '')),
]; ];
} }
@@ -40,7 +42,7 @@ final class ScoringService
'sleep_feeling' => $entry['sleep_feeling'] * (float) $scoring['sleep_feeling_multiplier'], 'sleep_feeling' => $entry['sleep_feeling'] * (float) $scoring['sleep_feeling_multiplier'],
'sport_minutes' => $this->bandPoints((int) $entry['sport_minutes'], $scoring['sport_bands']), 'sport_minutes' => $this->bandPoints((int) $entry['sport_minutes'], $scoring['sport_bands']),
'sport_bonus' => $sportBonus, 'sport_bonus' => $sportBonus,
'walk_minutes' => $this->bandPoints((int) $entry['walk_minutes'], $scoring['walk_bands']), 'walk_minutes' => $this->walkPoints($entry, $settings),
'note' => $entry['note'] === '' ? 0 : (float) $scoring['journal_points'], 'note' => $entry['note'] === '' ? 0 : (float) $scoring['journal_points'],
]; ];
@@ -53,7 +55,7 @@ final class ScoringService
(5 * (float) $scoring['sleep_feeling_multiplier']) + (5 * (float) $scoring['sleep_feeling_multiplier']) +
$this->maxBandPoints($scoring['sport_bands']) + $this->maxBandPoints($scoring['sport_bands']) +
$this->maxSportBonusPoints($settings) + $this->maxSportBonusPoints($settings) +
$this->maxBandPoints($scoring['walk_bands']) + $this->maxWalkPoints($entry, $settings) +
(float) $scoring['journal_points'], (float) $scoring['journal_points'],
1 1
); );
@@ -146,6 +148,72 @@ final class ScoringService
return $max; 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 private function maxSportBonusPoints(array $settings): float
{ {
$max = 0.0; $max = 0.0;
@@ -280,4 +348,9 @@ final class ScoringService
default => 'radiant', default => 'radiant',
}; };
} }
private function normalizeWalkMode(string $mode): string
{
return $mode === 'steps' ? 'steps' : 'time';
}
} }
+17
View File
@@ -16,6 +16,9 @@ final class Defaults
5 => 'sehr ausgeschlafen', 5 => 'sehr ausgeschlafen',
], ],
], ],
'walk' => [
'mode' => 'time',
],
'sport_types' => [ 'sport_types' => [
[ [
'id' => 'running', 'id' => 'running',
@@ -135,6 +138,16 @@ final class Defaults
['min' => 16, 'max' => 40, 'points' => 5], ['min' => 16, 'max' => 40, 'points' => 5],
['min' => 41, 'max' => 10000, 'points' => 7], ['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, 'journal_points' => 2,
], ],
'ratings' => [ 'ratings' => [
@@ -156,6 +169,10 @@ final class Defaults
'cap_label' => 'schwerer Tag', 'cap_label' => 'schwerer Tag',
], ],
], ],
'notifications' => [
'enabled' => false,
'time' => '20:30',
],
]; ];
} }
} }
+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;
}
}
+2
View File
@@ -6,10 +6,12 @@ require __DIR__ . '/helpers.php';
require __DIR__ . '/Support/Defaults.php'; require __DIR__ . '/Support/Defaults.php';
require __DIR__ . '/Support/Auth.php'; require __DIR__ . '/Support/Auth.php';
require __DIR__ . '/Support/View.php'; require __DIR__ . '/Support/View.php';
require __DIR__ . '/Support/WebPushService.php';
require __DIR__ . '/Domain/UserRepository.php'; require __DIR__ . '/Domain/UserRepository.php';
require __DIR__ . '/Domain/SettingsRepository.php'; require __DIR__ . '/Domain/SettingsRepository.php';
require __DIR__ . '/Domain/EntryRepository.php'; require __DIR__ . '/Domain/EntryRepository.php';
require __DIR__ . '/Domain/LoginThrottle.php'; require __DIR__ . '/Domain/LoginThrottle.php';
require __DIR__ . '/Domain/NotificationRepository.php';
require __DIR__ . '/Domain/ScoringService.php'; require __DIR__ . '/Domain/ScoringService.php';
require __DIR__ . '/App.php'; require __DIR__ . '/App.php';
+95
View File
@@ -29,6 +29,14 @@ function redirect(string $path): never
exit; 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 function request_path(): string
{ {
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH); $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); 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 function is_active_path(string $path): bool
{ {
return request_path() === $path; return request_path() === $path;
@@ -117,6 +136,18 @@ function decode_json_file(string $path, array $fallback = []): array
return is_array($decoded) ? $decoded : $fallback; 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 function encode_payload(array $payload): string
{ {
return base64_encode((string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); 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 function remember_me_lifetime(): int
{ {
return 60 * 60 * 24 * 30; return 60 * 60 * 24 * 30;
@@ -257,6 +296,62 @@ function sport_location_label(?string $value): string
return $options[$value] ?? ''; 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 normalize_sport_type_id(string $value): string function normalize_sport_type_id(string $value): string
{ {
$value = trim(strtr($value, [ $value = trim(strtr($value, [
+37 -4
View File
@@ -6,7 +6,7 @@ $brandSubtitle = match ($page) {
'dashboard' => 'Statistiken und Verlauf', 'dashboard' => 'Statistiken und Verlauf',
'track' => 'Tag erfassen und bewerten', 'track' => 'Tag erfassen und bewerten',
'archive' => 'Rückblick auf vergangene Tage', 'archive' => 'Rückblick auf vergangene Tage',
'options' => 'Logik, Sicherheit und Accounts', 'options' => 'Logik, Erinnerungen, Sicherheit und Accounts',
'login' => 'Geschützter Zugang', 'login' => 'Geschützter Zugang',
'setup' => 'Erstkonfiguration', 'setup' => 'Erstkonfiguration',
default => 'Stimmungstracker', default => 'Stimmungstracker',
@@ -19,15 +19,27 @@ $brandSubtitle = match ($page) {
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#0b1e2e"> <meta name="theme-color" content="#0b1e2e">
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet"> <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> <title><?= e($pageTitle) ?> · Mood</title>
<link rel="icon" type="image/svg+xml" href="/assets/branding/favicon.svg"> <link rel="icon" type="image/svg+xml" href="/assets/branding/favicon.svg?v=20260412">
<link rel="shortcut icon" href="/assets/branding/favicon.svg"> <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"> <link rel="stylesheet" href="/assets/css/app.css">
<script defer src="/assets/js/app.js"></script> <script defer src="/assets/js/app.js"></script>
</head> </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-one"></div>
<div class="aurora aurora-two"></div> <div class="aurora aurora-two"></div>
<div class="pull-refresh-indicator glass-panel" data-pull-refresh-indicator aria-hidden="true">Zum Aktualisieren ziehen</div>
<div class="shell"> <div class="shell">
<?php if ($authUser !== null): ?> <?php if ($authUser !== null): ?>
<aside class="sidebar glass-panel"> <aside class="sidebar glass-panel">
@@ -101,6 +113,27 @@ $brandSubtitle = match ($page) {
<?= $content ?> <?= $content ?>
</main> </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> </div>
</body> </body>
</html> </html>
+1 -1
View File
@@ -75,7 +75,7 @@
</dd> </dd>
</div> </div>
<div><dt>Sportbonus</dt><dd><?= e(format_points((float) ($selectedEntry['evaluation']['components']['sport_bonus'] ?? 0))) ?></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>
</dl> </dl>
<div class="note-box"> <div class="note-box">
+1 -1
View File
@@ -79,7 +79,7 @@
<p class="eyebrow">Aktivität</p> <p class="eyebrow">Aktivität</p>
<h3>Sport und Spaziergang</h3> <h3>Sport und Spaziergang</h3>
</div> </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>
<div class="bar-chart" data-chart-type="bars" data-series="sport" data-payload="<?= e($chartPayload) ?>"></div> <div class="bar-chart" data-chart-type="bars" data-series="sport" data-payload="<?= e($chartPayload) ?>"></div>
</article> </article>
+73 -1
View File
@@ -49,7 +49,29 @@
</div> </div>
<div class="settings-section"> <div class="settings-section">
<h4>Spaziergang-Bänder</h4> <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"> <div class="band-grid">
<?php foreach ($settings['scoring']['walk_bands'] as $index => $band): ?> <?php foreach ($settings['scoring']['walk_bands'] as $index => $band): ?>
<div class="band-card"> <div class="band-card">
@@ -59,6 +81,7 @@
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<?php endif; ?>
</div> </div>
<div class="settings-section"> <div class="settings-section">
@@ -217,6 +240,55 @@
</template> </template>
</div> </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"> <div class="settings-section">
<h4>Bewertungsskala</h4> <h4>Bewertungsskala</h4>
<p class="helper-text">Diese Score-Grenzen und Schutzregeln gelten ebenfalls nur für deinen eigenen Account.</p> <p class="helper-text">Diese Score-Grenzen und Schutzregeln gelten ebenfalls nur für deinen eigenen Account.</p>
+12 -2
View File
@@ -14,6 +14,8 @@
<form method="post" action="/track" class="tracker-form" id="tracker-form" autocomplete="off"> <form method="post" action="/track" class="tracker-form" id="tracker-form" autocomplete="off">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="date" value="<?= e($entry['date']) ?>"> <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"> <div class="field-grid field-grid--three">
<label class="range-card"> <label class="range-card">
@@ -60,8 +62,16 @@
</label> </label>
<label> <label>
<span>Spaziergang in Minuten</span> <span><?= $walkMode === 'steps' ? 'Spaziergang in Schritten' : 'Spaziergang in Minuten' ?></span>
<input type="number" min="0" max="1440" step="1" name="walk_minutes" value="<?= e((string) $entry['walk_minutes']) ?>" required> <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> </label>
</div> </div>