freezmo/public/sw.js
Martin Dvorak 2103f9fc69 Smart Review: instant loading feedback, no more dead-feeling tap
Reported: tapping Review due could sit for many seconds with no loading
screen. The flow itself is local and fast (counts are hydrated on the
library; <1.5s even at 6x CPU throttle with 3,000 answers), so the
visible fault was missing feedback: /review is a dynamic route and
until its payload arrived nothing on screen changed.

- LinkLoadingScreen: the review card links render a full-screen
  loading screen from the link's own useLinkStatus pending state, the
  instant the tap happens (measured 58 ms with the route request held
  open). The links also prefetch their payload.
- Route loading boundaries (loading.tsx) for /review, /stats and
  /exam, sharing a RouteLoading screen (app icon + spinner +
  localized message); ReviewClient's bare skeleton replaced with it.
- Health probes: per-key 30s cache + in-flight sharing in use-health.ts
  (every page mounts its own gate; a session now fires one probe
  instead of one per navigation). Manual re-check still forces.
- Czech fix: exam heading 'Zkouškové režim' -> 'Zkouškový režim'.

Verified by the rebuilt suites (the /tmp harness was wiped overnight):
review-loading 15/15 (stalled-request loading screen at 58 ms, session
arrival after release, fast normal path, mistakes path, end-to-end
grading, probe cache) and navigation 78/78 (tab bar, labels 360/390
EN/CS, routing, deep links, wizard loading lock). Lint + build clean.
SW freezmo-v30.
2026-10-11 14:35:16 +00:00

134 lines
3.9 KiB
JavaScript

/**
* Freezmo service worker (production only).
*
* Strategy, deliberately conservative:
* - API routes are never cached and never intercepted.
* - Navigations: network first, falling back to the cached shell, then /offline.
* - /_next/static + icons: stale-while-revalidate (fast, self-healing).
* - Everything else: straight to the network.
*
* Bumping CACHE_VERSION is how a new deploy invalidates old assets.
*/
const CACHE_VERSION = "freezmo-v30";
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const ASSET_CACHE = `${CACHE_VERSION}-assets`;
const SHELL_ASSETS = [
"/",
"/offline",
"/manifest.webmanifest",
"/icons/icon-192.png",
"/icons/icon-512.png",
"/icons/apple-touch-icon.png",
];
self.addEventListener("install", (event) => {
event.waitUntil(
(async () => {
const cache = await caches.open(SHELL_CACHE);
await Promise.allSettled(SHELL_ASSETS.map((url) => cache.add(new Request(url, { cache: "reload" }))));
await self.skipWaiting();
})(),
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
const names = await caches.keys();
await Promise.all(
names.filter((name) => !name.startsWith(CACHE_VERSION)).map((name) => caches.delete(name)),
);
await self.clients.claim();
})(),
);
});
function isCacheableAsset(url) {
return (
url.pathname.startsWith("/_next/static/") ||
url.pathname.startsWith("/icons/") ||
url.pathname === "/manifest.webmanifest" ||
/\.(?:css|js|png|jpg|jpeg|svg|webp|woff2?)$/.test(url.pathname)
);
}
/* ------------------------------------------------------------------ */
/* Push reminders (opt-in; only active when VAPID is configured) */
/* ------------------------------------------------------------------ */
self.addEventListener("push", (event) => {
let data = {};
try {
data = event.data ? event.data.json() : {};
} catch {
data = {};
}
event.waitUntil(
self.registration.showNotification(data.title || "Freezmo", {
body: data.body || "",
icon: "/icons/icon-192.png",
badge: "/icons/icon-192.png",
data: { url: data.url || "/" },
}),
);
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const url = (event.notification.data && event.notification.data.url) || "/";
event.waitUntil(
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clients) => {
for (const client of clients) {
if ("focus" in client) {
client.navigate(url).catch(() => {});
return client.focus();
}
}
return self.clients.openWindow(url);
}),
);
});
self.addEventListener("fetch", (event) => {
const request = event.request;
if (request.method !== "GET") return;
const url = new URL(request.url);
if (url.origin !== self.location.origin) return;
// Never touch the API: keys, notes and generated decks must always be live.
if (url.pathname.startsWith("/api/")) return;
if (request.mode === "navigate") {
event.respondWith(
(async () => {
try {
const response = await fetch(request);
const cache = await caches.open(SHELL_CACHE);
cache.put("/", response.clone()).catch(() => {});
return response;
} catch {
const cache = await caches.open(SHELL_CACHE);
return (await cache.match("/")) ?? (await cache.match("/offline")) ?? Response.error();
}
})(),
);
return;
}
if (isCacheableAsset(url)) {
event.respondWith(
(async () => {
const cache = await caches.open(ASSET_CACHE);
const cached = await cache.match(request);
const network = fetch(request)
.then((response) => {
if (response.ok) cache.put(request, response.clone()).catch(() => {});
return response;
})
.catch(() => cached);
return cached ?? network;
})(),
);
}
});