Add PWA reminders and flexible walk scoring

This commit is contained in:
2026-04-12 19:40:40 +02:00
parent 2cd00b1bf6
commit cd7526bd80
19 changed files with 1561 additions and 33 deletions
+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

+109
View File
@@ -144,6 +144,12 @@ button {
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
opacity: 0.55;
transform: none !important;
}
.aurora {
position: fixed;
inset: auto;
@@ -314,6 +320,10 @@ button {
margin-top: 2rem;
}
.mobile-nav {
display: none;
}
.main-nav a {
display: flex;
align-items: center;
@@ -1014,6 +1024,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 +1273,14 @@ input[type="range"] {
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 {
width: auto;
}
@@ -1340,6 +1382,10 @@ input[type="range"] {
}
@media (max-width: 820px) {
.shell {
grid-template-columns: 1fr;
}
.topbar,
.section-head,
.form-actions {
@@ -1362,6 +1408,53 @@ 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));
}
.bar-chart {
overflow-x: auto;
padding-bottom: 0.4rem;
@@ -1426,4 +1519,20 @@ 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;
}
}
+237 -3
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,7 @@
function evaluateEntry(entry, settings, previousEntry = null) {
const ratings = sortedRatings(settings.ratings || []);
const scoring = settings.scoring || {};
const walkMode = entry.walk_mode === "steps" ? "steps" : "time";
const components = {
mood: Number(entry.mood) * Number(scoring.mood_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),
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 || []),
note: String(entry.note || "").trim() === "" ? 0 : Number(scoring.journal_points || 0),
};
@@ -297,7 +332,9 @@
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),
note: form.elements.note.value || "",
});
@@ -452,6 +489,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 +507,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 +936,200 @@
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 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 +1146,6 @@
initTrackPreview();
initDashboardCharts();
initSportTypeManager();
initPwaShell();
initPushControls();
})();