aboutsummaryrefslogtreecommitdiff
path: root/src/frontend/public
diff options
context:
space:
mode:
Diffstat (limited to 'src/frontend/public')
-rw-r--r--src/frontend/public/pwa-worker.js247
1 files changed, 247 insertions, 0 deletions
diff --git a/src/frontend/public/pwa-worker.js b/src/frontend/public/pwa-worker.js
index 9427bd3..09755d3 100644
--- a/src/frontend/public/pwa-worker.js
+++ b/src/frontend/public/pwa-worker.js
@@ -84,3 +84,250 @@ async function handleStaticRequest(request) {
return null;
}
}
+
+// ---------------------------------------------------------------------------
+// IndexedDB helpers (inline โ€” classic SW scripts cannot use ES module imports)
+// Schema must match app/utils/idb.ts
+// ---------------------------------------------------------------------------
+
+const IDB_NAME = "enmarcha-sw";
+const IDB_VERSION = 1;
+
+function idbOpen() {
+ return new Promise((resolve, reject) => {
+ const req = indexedDB.open(IDB_NAME, IDB_VERSION);
+ req.onupgradeneeded = () => {
+ const db = req.result;
+ if (!db.objectStoreNames.contains("favorites")) {
+ db.createObjectStore("favorites", { keyPath: "key" });
+ }
+ if (!db.objectStoreNames.contains("alertState")) {
+ db.createObjectStore("alertState", { keyPath: "alertId" });
+ }
+ };
+ req.onsuccess = () => resolve(req.result);
+ req.onerror = () => reject(req.error);
+ });
+}
+
+function idbGet(db, storeName, key) {
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction(storeName, "readonly");
+ const req = tx.objectStore(storeName).get(key);
+ req.onsuccess = () => resolve(req.result);
+ req.onerror = () => reject(req.error);
+ });
+}
+
+function idbPut(db, storeName, value) {
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction(storeName, "readwrite");
+ tx.objectStore(storeName).put(value);
+ tx.oncomplete = () => resolve();
+ tx.onerror = () => reject(tx.error);
+ });
+}
+
+// ---------------------------------------------------------------------------
+// Push notification handler
+// ---------------------------------------------------------------------------
+
+self.addEventListener("push", (event) => {
+ event.waitUntil(handlePush(event));
+});
+
+async function handlePush(event) {
+ let payload;
+ try {
+ payload = event.data.json();
+ } catch {
+ return;
+ }
+
+ const {
+ alertId,
+ version,
+ header,
+ description,
+ selectors = [],
+ effect,
+ } = payload;
+
+ const db = await idbOpen();
+
+ // Check per-alert state โ€” skip if already shown at this version or silenced
+ const alertState = await idbGet(db, "alertState", alertId);
+ if (alertState) {
+ if (alertState.silenced) {
+ db.close();
+ return;
+ }
+ if (alertState.lastVersion >= version) {
+ db.close();
+ return;
+ }
+ }
+
+ // Read favourites from IDB
+ const stopRec = await idbGet(db, "favorites", "favouriteStops");
+ const routeRec = await idbGet(db, "favorites", "favouriteRoutes");
+ const agencyRec = await idbGet(db, "favorites", "favouriteAgencies");
+ db.close();
+
+ const favStops = stopRec?.ids ?? [];
+ const favRoutes = routeRec?.ids ?? [];
+ const favAgencies = agencyRec?.ids ?? [];
+
+ const hasAnyFavourites =
+ favStops.length > 0 || favRoutes.length > 0 || favAgencies.length > 0;
+
+ // If user has favourites, only show if a selector matches; otherwise show all (fail-open)
+ if (hasAnyFavourites) {
+ const matches = selectors.some((raw) => {
+ const hashIdx = raw.indexOf("#");
+ if (hashIdx === -1) return false;
+ const type = raw.slice(0, hashIdx);
+ const id = raw.slice(hashIdx + 1);
+ if (type === "stop") return favStops.includes(id);
+ if (type === "route") return favRoutes.includes(id);
+ if (type === "agency") return favAgencies.includes(id);
+ return false;
+ });
+ if (!matches) return;
+ }
+
+ // Determine notification title and body (prefer user's browser language, fallback to "es")
+ const lang = (self.navigator?.language ?? "es").slice(0, 2);
+ const title =
+ header[lang] ??
+ header["es"] ??
+ Object.values(header)[0] ??
+ "Alerta de servicio";
+ const body =
+ description[lang] ??
+ description["es"] ??
+ Object.values(description)[0] ??
+ "";
+
+ // Map effect to an emoji hint for better at-a-glance reading
+ const iconHint =
+ {
+ NoService: "๐Ÿšซ",
+ ReducedService: "โš ๏ธ",
+ SignificantDelays: "๐Ÿ•",
+ Detour: "โ†ฉ๏ธ",
+ AdditionalService: "โž•",
+ StopMoved: "๐Ÿ“",
+ }[effect] ?? "โ„น๏ธ";
+
+ // Save the new version so we don't re-show the same notification
+ const db2 = await idbOpen();
+ await idbPut(db2, "alertState", {
+ alertId,
+ silenced: false,
+ lastVersion: version,
+ });
+ db2.close();
+
+ // Build a deep-link from the first selector
+ let firstLink = "/";
+ if (selectors.length > 0) {
+ const first = selectors[0];
+ const hashIdx = first.indexOf("#");
+ if (hashIdx !== -1) {
+ const type = first.slice(0, hashIdx);
+ const id = first.slice(hashIdx + 1);
+ if (type === "stop") firstLink = `/stops/${encodeURIComponent(id)}`;
+ else if (type === "route")
+ firstLink = `/routes/${encodeURIComponent(id)}`;
+ }
+ }
+
+ await self.registration.showNotification(`${iconHint} ${title}`, {
+ body,
+ icon: "/icon-192.png",
+ badge: "/icon-monochrome-256.png",
+ tag: alertId,
+ data: { alertId, version, link: firstLink },
+ actions: [
+ { action: "open", title: "Ver detalles" },
+ { action: "silence", title: "No mostrar mรกs" },
+ ],
+ });
+}
+
+// ---------------------------------------------------------------------------
+// Notification click handler
+// ---------------------------------------------------------------------------
+
+self.addEventListener("notificationclick", (event) => {
+ event.notification.close();
+
+ if (event.action === "silence") {
+ event.waitUntil(
+ (async () => {
+ const { alertId, version } = event.notification.data ?? {};
+ if (!alertId) return;
+ const db = await idbOpen();
+ await idbPut(db, "alertState", {
+ alertId,
+ silenced: true,
+ lastVersion: version ?? 0,
+ });
+ db.close();
+ })()
+ );
+ return;
+ }
+
+ // Default / "open" action โ€” focus or open the app at the alert's deep link
+ const link = event.notification.data?.link ?? "/";
+ event.waitUntil(
+ self.clients
+ .matchAll({ type: "window", includeUncontrolled: true })
+ .then((clients) => {
+ for (const client of clients) {
+ if (client.url.includes(self.location.origin) && "focus" in client) {
+ client.navigate(link);
+ return client.focus();
+ }
+ }
+ return self.clients.openWindow(link);
+ })
+ );
+});
+
+// ---------------------------------------------------------------------------
+// Re-subscribe handler (fires when the push subscription is invalidated)
+// ---------------------------------------------------------------------------
+
+self.addEventListener("pushsubscriptionchange", (event) => {
+ event.waitUntil(
+ (async () => {
+ const newSubscription =
+ event.newSubscription ??
+ (await self.registration.pushManager.subscribe(
+ event.oldSubscription
+ ? {
+ userVisibleOnly: true,
+ applicationServerKey:
+ event.oldSubscription.options.applicationServerKey,
+ }
+ : { userVisibleOnly: true }
+ ));
+
+ if (!newSubscription) return;
+
+ const { endpoint, keys } = newSubscription.toJSON();
+ await fetch("/api/push/subscribe", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ endpoint,
+ p256Dh: keys?.p256dh,
+ auth: keys?.auth,
+ }),
+ });
+ })()
+ );
+});