Add PWA reminders and flexible walk scoring
This commit is contained in:
+237
-3
@@ -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();
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user