feat: PWA update strategy with hashed assets and cache control

Move frontend sources to frontend/, build content-hashed assets at deploy,
network-first SW for shell/JS, immutable cache for /assets/, version.json
update banner, and Cache-Control middleware. Docs in docs/PWA-STRATEGY.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Frank Schwenk
2026-07-08 08:51:12 +02:00
parent bff14167c9
commit 7cec3030c1
29 changed files with 779 additions and 61 deletions
+191
View File
@@ -0,0 +1,191 @@
import { api } from "./lib/api.js";
import { getToken, setToken, clearToken, isLoggedIn } from "./lib/auth.js";
import { setStreakBadge } from "./lib/badge.js";
import { enqueue, flushQueue } from "./lib/offlineQueue.js";
import { registerPwa, wirePwaInstall, subscribePush } from "./lib/pwa.js";
import { wireAppUpdates } from "./lib/updates.js";
import { renderSlots, renderHeatmap, updateStats, showToast, showConfetti, showEasterEgg } from "./lib/ui.js";
const STATUS = { upcoming: "Noch nicht", pending: "Jetzt!", overdue: "Überfällig", snoozed: "Snoozed", taken: "Genommen", missed: "Verpasst" };
function showScreen(id) {
document.querySelectorAll(".screen").forEach((s) => { s.hidden = s.id !== id; });
}
function wirePinForm() {
const form = document.getElementById("pinForm");
const input = document.getElementById("pinInput");
const errEl = document.getElementById("loginError");
input.addEventListener("input", () => {
const digits = input.value.replace(/\D/g, "").slice(0, 6);
if (input.value !== digits) input.value = digits;
errEl.hidden = true;
});
form.addEventListener("submit", async (e) => {
e.preventDefault();
const pin = input.value.trim();
if (pin.length < 4) return;
errEl.hidden = true;
try {
const { token } = await api.login(pin);
setToken(token);
input.value = "";
await initApp();
} catch {
errEl.textContent = "Falscher PIN. Dein Gehirn auch.";
errEl.hidden = false;
input.value = "";
input.focus();
}
});
}
function wireTabs() {
document.querySelectorAll(".tab").forEach((tab) => {
tab.addEventListener("click", () => {
document.querySelectorAll(".tab").forEach((t) => t.classList.remove("active"));
document.querySelectorAll(".tabPanel").forEach((p) => p.hidden = true);
tab.classList.add("active");
const panel = document.getElementById(`tab-${tab.dataset.tab}`);
panel.hidden = false;
if (tab.dataset.tab === "history") loadHistory();
});
});
}
async function handleTake(slotId) {
const card = document.querySelector(`[data-slot="${slotId}"]`);
try {
if (!navigator.onLine) {
await enqueue({ slot_id: slotId, status: "taken" });
showToast("Offline gespeichert — wird synchronisiert.");
return;
}
const result = await api.log({ slot_id: slotId, status: "taken" });
card?.classList.add("dopamin");
showConfetti();
showToast(result.message);
if (result.new_milestones?.length) {
for (const m of result.new_milestones) {
showEasterEgg(m.title, "Achievement freigeschaltet!");
}
}
await refreshDashboard(result.stats);
} catch (e) {
await enqueue({ slot_id: slotId, status: "taken" });
showToast("Gespeichert — Sync folgt.");
}
}
async function handleSnooze(slotId, minutes) {
try {
const result = await api.snooze(slotId, minutes);
showToast(result.message || `Snooze +${minutes} Min`);
await refreshDashboard();
} catch (e) {
showToast("Snooze fehlgeschlagen.");
}
}
const MOTIVATION_CACHE_KEY = "medis-motivation-day";
function showMotivationFromCache(day) {
const el = document.getElementById("motivationText");
const cached = JSON.parse(sessionStorage.getItem(MOTIVATION_CACHE_KEY) || "null");
if (cached?.day === day && cached?.text) {
el.textContent = cached.text;
return true;
}
return false;
}
async function loadMotivation(day) {
try {
const motivation = await api.roast();
sessionStorage.setItem(MOTIVATION_CACHE_KEY, JSON.stringify({ day: motivation.day, text: motivation.text }));
document.getElementById("motivationText").textContent = motivation.text;
} catch {
document.getElementById("motivationText").textContent = "Dein Gehirn ist nicht kaputt. Es wartet nur auf den richtigen Treiber.";
}
}
async function refreshDashboard(stats) {
const today = await api.today();
renderSlots(today.slots, handleTake, handleSnooze);
if (!showMotivationFromCache(today.day)) {
document.getElementById("motivationText").textContent = "…";
loadMotivation(today.day);
}
const s = stats || await api.stats();
updateStats(s);
await setStreakBadge(s.streak);
}
async function loadHistory() {
const data = await api.history(90);
renderHeatmap(data.days);
updateStats(data.stats);
}
async function handleDeepLink() {
const params = new URLSearchParams(location.search);
const slot = params.get("slot");
if (params.get("action") === "take" && slot) {
await handleTake(slot);
history.replaceState({}, "", "/");
} else if (params.get("action") === "snooze" && slot) {
const minutes = parseInt(params.get("minutes") || "15", 10);
await handleSnooze(slot, minutes);
history.replaceState({}, "", "/");
}
}
async function initApp() {
showScreen("mainScreen");
wireTabs();
wirePwaInstall();
document.getElementById("logoutBtn").addEventListener("click", () => {
clearToken();
location.reload();
});
document.getElementById("enablePush").addEventListener("click", async () => {
const statusEl = document.getElementById("pushStatus");
try {
const perm = await Notification.requestPermission();
if (perm !== "granted") { statusEl.textContent = "Permission verweigert."; return; }
const { key } = await api.vapidKey();
if (!key) { statusEl.textContent = "VAPID keys nicht konfiguriert."; return; }
const sub = await subscribePush(key);
await api.pushSubscribe(sub);
statusEl.textContent = "Push aktiv! Du wirst dezent erinnert.";
} catch (e) {
statusEl.textContent = e.message || "Push-Setup fehlgeschlagen.";
}
});
await flushQueue((data) => api.log(data));
await refreshDashboard();
await handleDeepLink();
}
navigator.serviceWorker?.addEventListener("message", (event) => {
if (event.data?.type === "SYNC_QUEUE") flushQueue((data) => api.log(data));
});
async function boot() {
registerPwa();
wireAppUpdates();
if (isLoggedIn()) {
try { await initApp(); return; } catch { clearToken(); }
}
showScreen("loginScreen");
wirePinForm();
}
boot();