7cec3030c1
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>
153 lines
4.0 KiB
JavaScript
153 lines
4.0 KiB
JavaScript
const CACHE = "{{CACHE_VERSION}}";
|
|
const ASSETS = {{ASSETS_JSON}};
|
|
|
|
self.addEventListener("install", (event) => {
|
|
event.waitUntil(
|
|
caches.open(CACHE).then((c) => c.addAll(ASSETS)).then(() => self.skipWaiting())
|
|
);
|
|
});
|
|
|
|
self.addEventListener("activate", (event) => {
|
|
event.waitUntil(
|
|
caches.keys().then((names) =>
|
|
Promise.all(names.filter((n) => n !== CACHE).map((n) => caches.delete(n)))
|
|
).then(() => self.clients.claim())
|
|
);
|
|
});
|
|
|
|
self.addEventListener("message", (event) => {
|
|
if (event.data?.type === "SKIP_WAITING") self.skipWaiting();
|
|
});
|
|
|
|
function isHashedAsset(pathname) {
|
|
return pathname.startsWith("/assets/");
|
|
}
|
|
|
|
async function networkOnly(request) {
|
|
return fetch(request);
|
|
}
|
|
|
|
async function networkFirst(request, fallbackPath) {
|
|
try {
|
|
const response = await fetch(request);
|
|
if (response.ok) return response;
|
|
} catch {
|
|
/* offline */
|
|
}
|
|
const cached = await caches.match(request);
|
|
if (cached) return cached;
|
|
if (fallbackPath) {
|
|
const fallback = await caches.match(fallbackPath);
|
|
if (fallback) return fallback;
|
|
}
|
|
return new Response("Offline", { status: 503, statusText: "Offline" });
|
|
}
|
|
|
|
async function cacheFirst(request) {
|
|
const cached = await caches.match(request);
|
|
if (cached) return cached;
|
|
const response = await fetch(request);
|
|
if (response.ok) {
|
|
const cache = await caches.open(CACHE);
|
|
await cache.put(request, response.clone());
|
|
}
|
|
return response;
|
|
}
|
|
|
|
self.addEventListener("fetch", (event) => {
|
|
const { request } = event;
|
|
if (request.method !== "GET") return;
|
|
const url = new URL(request.url);
|
|
if (url.origin !== self.location.origin) return;
|
|
|
|
// sw.js: never intercept — browser must revalidate with server
|
|
if (url.pathname === "/sw.js") return;
|
|
|
|
if (url.pathname.startsWith("/api/")) {
|
|
event.respondWith(
|
|
fetch(request).catch(() => new Response(JSON.stringify({ offline: true }), {
|
|
status: 503,
|
|
headers: { "Content-Type": "application/json" },
|
|
}))
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/version.json") {
|
|
event.respondWith(networkOnly(request));
|
|
return;
|
|
}
|
|
|
|
if (request.mode === "navigate") {
|
|
event.respondWith(networkFirst(request, "/index.html"));
|
|
return;
|
|
}
|
|
|
|
if (isHashedAsset(url.pathname)) {
|
|
event.respondWith(cacheFirst(request));
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/index.html" || url.pathname === "/manifest.json") {
|
|
event.respondWith(networkFirst(request));
|
|
return;
|
|
}
|
|
|
|
// Icons and other static root files
|
|
event.respondWith(networkFirst(request));
|
|
});
|
|
|
|
self.addEventListener("push", (event) => {
|
|
if (!event.data) return;
|
|
let data = {};
|
|
try { data = event.data.json(); } catch { data = { body: event.data.text() }; }
|
|
|
|
const options = {
|
|
body: data.body || "Med-Time!",
|
|
icon: "/icon-192x192.png",
|
|
badge: "/icon-72x72.png",
|
|
silent: data.silent !== false,
|
|
tag: data.tag || "med-reminder",
|
|
renotify: true,
|
|
data: { slot_id: data.slot_id },
|
|
actions: [
|
|
{ action: "take", title: "Genommen ✓" },
|
|
{ action: "snooze15", title: "+15 Min" },
|
|
{ action: "snooze30", title: "+30 Min" },
|
|
],
|
|
};
|
|
|
|
event.waitUntil(
|
|
self.registration.showNotification(data.title || "TakeYourMeds", options)
|
|
);
|
|
});
|
|
|
|
self.addEventListener("notificationclick", (event) => {
|
|
event.notification.close();
|
|
const slotId = event.notification.data?.slot_id;
|
|
const action = event.action;
|
|
|
|
if (action === "take" && slotId) {
|
|
event.waitUntil(self.clients.openWindow(`/?action=take&slot=${slotId}`));
|
|
return;
|
|
}
|
|
|
|
if ((action === "snooze15" || action === "snooze30") && slotId) {
|
|
const minutes = action === "snooze15" ? 15 : 30;
|
|
event.waitUntil(self.clients.openWindow(`/?action=snooze&slot=${slotId}&minutes=${minutes}`));
|
|
return;
|
|
}
|
|
|
|
event.waitUntil(self.clients.openWindow("/"));
|
|
});
|
|
|
|
self.addEventListener("sync", (event) => {
|
|
if (event.tag === "sync-logs") {
|
|
event.waitUntil(
|
|
self.clients.matchAll().then((list) => {
|
|
list.forEach((c) => c.postMessage({ type: "SYNC_QUEUE" }));
|
|
})
|
|
);
|
|
}
|
|
});
|