diff options
| author | Ariel Costas Guerrero <ariel@costas.dev> | 2026-04-02 12:38:10 +0200 |
|---|---|---|
| committer | Ariel Costas Guerrero <ariel@costas.dev> | 2026-04-02 12:45:33 +0200 |
| commit | 1b4f4a674ac533c0b51260ba35ab91dd2cf9486d (patch) | |
| tree | 9fdaf418bef86c51737bcf203483089c9e2b908b /src/frontend | |
| parent | 749e04d6fc2304bb29920db297d1fa4d73b57648 (diff) | |
Basic push notification system for service alerts
Co-authored-by: Copilot <copilot@github.com>
Diffstat (limited to 'src/frontend')
| -rw-r--r-- | src/frontend/app/api/schema.ts | 1 | ||||
| -rw-r--r-- | src/frontend/app/components/PushNotificationSettings.tsx | 198 | ||||
| -rw-r--r-- | src/frontend/app/components/ServiceAlerts.tsx | 115 | ||||
| -rw-r--r-- | src/frontend/app/data/StopDataProvider.ts | 8 | ||||
| -rw-r--r-- | src/frontend/app/hooks/useFavorites.ts | 6 | ||||
| -rw-r--r-- | src/frontend/app/routes/favourites.tsx | 5 | ||||
| -rw-r--r-- | src/frontend/app/routes/routes.tsx | 35 | ||||
| -rw-r--r-- | src/frontend/app/routes/settings.tsx | 8 | ||||
| -rw-r--r-- | src/frontend/app/utils/idb.ts | 83 | ||||
| -rw-r--r-- | src/frontend/public/pwa-worker.js | 247 |
10 files changed, 686 insertions, 20 deletions
diff --git a/src/frontend/app/api/schema.ts b/src/frontend/app/api/schema.ts index 20daede..40358a6 100644 --- a/src/frontend/app/api/schema.ts +++ b/src/frontend/app/api/schema.ts @@ -110,6 +110,7 @@ export const RouteSchema = z.object({ textColor: z.string().nullable(), sortOrder: z.number().nullable(), agencyName: z.string().nullable().optional(), + agencyId: z.string().nullable().optional(), tripCount: z.number(), }); diff --git a/src/frontend/app/components/PushNotificationSettings.tsx b/src/frontend/app/components/PushNotificationSettings.tsx new file mode 100644 index 0000000..af3206a --- /dev/null +++ b/src/frontend/app/components/PushNotificationSettings.tsx @@ -0,0 +1,198 @@ +import { BellOff, BellRing, Loader } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { writeFavorites } from "~/utils/idb"; + +/** Convert a base64url string (as returned by the VAPID endpoint) to a Uint8Array. */ +function urlBase64ToUint8Array(base64String: string): Uint8Array { + const padding = "=".repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/"); + const rawData = atob(base64); + return Uint8Array.from([...rawData].map((c) => c.charCodeAt(0))); +} + +/** Sync all three favourites lists from localStorage to IndexedDB. */ +async function syncFavouritesToIdb() { + const keys = [ + "favouriteStops", + "favouriteRoutes", + "favouriteAgencies", + ] as const; + await Promise.all( + keys.map((key) => { + const raw = localStorage.getItem(key); + const ids: string[] = raw ? JSON.parse(raw) : []; + return writeFavorites(key, ids); + }) + ); +} + +type Status = + | "loading" // checking current state + | "unsupported" // browser does not support Push API + | "denied" // permission was explicitly blocked + | "subscribed" // user is actively subscribed + | "unsubscribed"; // user is not subscribed (or permission not yet granted) + +export function PushNotificationSettings() { + const { t } = useTranslation(); + const [status, setStatus] = useState<Status>("loading"); + const [working, setWorking] = useState(false); + + useEffect(() => { + checkStatus().then(setStatus); + }, []); + + async function checkStatus(): Promise<Status> { + if (!("serviceWorker" in navigator) || !("PushManager" in window)) { + return "unsupported"; + } + if (Notification.permission === "denied") return "denied"; + + try { + const reg = await navigator.serviceWorker.ready; + const sub = await reg.pushManager.getSubscription(); + return sub ? "subscribed" : "unsubscribed"; + } catch { + return "unsubscribed"; + } + } + + async function subscribe() { + setWorking(true); + try { + const permission = await Notification.requestPermission(); + if (permission !== "granted") { + setStatus("denied"); + return; + } + + // Fetch the VAPID public key + const res = await fetch("/api/push/vapid-public-key"); + if (!res.ok) { + console.error("Push notifications not configured on this server."); + return; + } + const { publicKey } = await res.json(); + + const reg = await navigator.serviceWorker.ready; + const subscription = await reg.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(publicKey), + }); + + // Mirror favourites to IDB before registering so the SW has them from day 1 + await syncFavouritesToIdb(); + + const json = subscription.toJSON(); + await fetch("/api/push/subscribe", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + endpoint: json.endpoint, + p256Dh: json.keys?.p256dh, + auth: json.keys?.auth, + }), + }); + + setStatus("subscribed"); + } finally { + setWorking(false); + } + } + + async function unsubscribe() { + setWorking(true); + try { + const reg = await navigator.serviceWorker.ready; + const sub = await reg.pushManager.getSubscription(); + if (sub) { + const endpoint = sub.endpoint; + await sub.unsubscribe(); + await fetch("/api/push/unsubscribe", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ endpoint }), + }); + } + setStatus("unsubscribed"); + } finally { + setWorking(false); + } + } + + return ( + <section className="mb-8"> + <h2 className="text-xl font-semibold mb-2 text-text"> + {t("settings.push_title", "Notificaciones")} + </h2> + <p className="text-sm text-muted mb-4"> + {t( + "settings.push_description", + "Recibe notificaciones cuando haya alertas de servicio relevantes para tus paradas, líneas o operadores favoritos." + )} + </p> + + {status === "loading" && ( + <div className="flex items-center gap-2 text-muted text-sm"> + <Loader className="w-4 h-4 animate-spin" /> + {t("common.loading", "Cargando...")} + </div> + )} + + {status === "unsupported" && ( + <p className="text-sm text-muted p-4 rounded-lg border border-border bg-surface"> + {t( + "settings.push_unsupported", + "Tu navegador no soporta notificaciones push. Prueba con Chrome, Edge o Firefox." + )} + </p> + )} + + {status === "denied" && ( + <p className="text-sm text-muted p-4 rounded-lg border border-amber-300 bg-amber-50 dark:bg-amber-950 dark:border-amber-700"> + {t( + "settings.push_permission_denied", + "Has bloqueado los permisos de notificación en este navegador. Para activarlos, ve a la configuración del sitio y permite las notificaciones." + )} + </p> + )} + + {(status === "subscribed" || status === "unsubscribed") && ( + <label className="flex items-center justify-between p-4 rounded-lg border border-border bg-surface cursor-pointer hover:bg-surface/50 transition-colors"> + <span className="flex items-center gap-2 text-text font-medium"> + {status === "subscribed" ? ( + <BellRing className="w-5 h-5 text-primary" /> + ) : ( + <BellOff className="w-5 h-5 text-muted" /> + )} + {status === "subscribed" + ? t("settings.push_subscribed", "Notificaciones activadas") + : t( + "settings.push_subscribe", + "Activar notificaciones de alertas" + )} + </span> + <button + onClick={status === "subscribed" ? unsubscribe : subscribe} + disabled={working} + aria-pressed={status === "subscribed"} + className={` + relative inline-flex h-6 w-11 items-center rounded-full transition-colors duration-200 + focus:outline-none focus:ring-2 focus:ring-primary/50 + disabled:opacity-50 + ${status === "subscribed" ? "bg-primary" : "bg-border"} + `} + > + <span + className={` + inline-block h-4 w-4 rounded-full bg-white shadow transition-transform duration-200 + ${status === "subscribed" ? "translate-x-6" : "translate-x-1"} + `} + /> + </button> + </label> + )} + </section> + ); +} diff --git a/src/frontend/app/components/ServiceAlerts.tsx b/src/frontend/app/components/ServiceAlerts.tsx index a6a1ee8..168ea83 100644 --- a/src/frontend/app/components/ServiceAlerts.tsx +++ b/src/frontend/app/components/ServiceAlerts.tsx @@ -1,22 +1,117 @@ +import { useQuery } from "@tanstack/react-query"; import React from "react"; import { useTranslation } from "react-i18next"; import "./ServiceAlerts.css"; -const ServiceAlerts: React.FC = () => { - const { t } = useTranslation(); +interface ServiceAlert { + id: string; + version: number; + phase: string; + cause: string; + effect: string; + header: Record<string, string>; + description: Record<string, string>; + selectors: string[]; + infoUrls: string[]; + eventStartDate: string; + eventEndDate: string; +} + +/** Maps an alert effect to one of the three CSS severity classes. */ +function effectToSeverity(effect: string): "info" | "warning" | "error" { + if (["NoService", "SignificantDelays", "AccessibilityIssue"].includes(effect)) + return "error"; + if ( + ["ReducedService", "Detour", "ModifiedService", "StopMoved"].includes( + effect + ) + ) + return "warning"; + return "info"; +} + +/** Maps an effect to an emoji icon. */ +function effectToIcon(effect: string): string { + const map: Record<string, string> = { + NoService: "🚫", + ReducedService: "⚠️", + SignificantDelays: "🕐", + Detour: "↩️", + AdditionalService: "➕", + ModifiedService: "🔄", + StopMoved: "📍", + AccessibilityIssue: "♿", + }; + return map[effect] ?? "ℹ️"; +} + +interface ServiceAlertsProps { + /** If provided, only alerts whose selectors overlap with this list are shown. */ + selectorFilter?: string[]; +} + +const ServiceAlerts: React.FC<ServiceAlertsProps> = ({ selectorFilter }) => { + const { t, i18n } = useTranslation(); + const lang = i18n.language.slice(0, 2); + + const { data: alerts, isLoading } = useQuery<ServiceAlert[]>({ + queryKey: ["service-alerts"], + queryFn: () => fetch("/api/alerts").then((r) => r.json()), + staleTime: 5 * 60 * 1000, + retry: false, + }); + + if (isLoading || !alerts) return null; + + const visible = alerts.filter((alert) => { + if (!selectorFilter || selectorFilter.length === 0) return true; + return alert.selectors.some((s) => selectorFilter.includes(s)); + }); + + if (visible.length === 0) return null; return ( <div className="service-alerts-container stoplist-section"> <h2 className="page-subtitle">{t("stoplist.service_alerts")}</h2> - <div className="service-alert info"> - <div className="alert-icon">ℹ️</div> - <div className="alert-content"> - <div className="alert-title">{t("stoplist.alerts_coming_soon")}</div> - <div className="alert-message"> - {t("stoplist.alerts_description")} + {visible.map((alert) => { + const severity = effectToSeverity(alert.effect); + const icon = effectToIcon(alert.effect); + const title = + alert.header[lang] ?? + alert.header["es"] ?? + Object.values(alert.header)[0] ?? + ""; + const body = + alert.description[lang] ?? + alert.description["es"] ?? + Object.values(alert.description)[0] ?? + ""; + + return ( + <div key={alert.id} className={`service-alert ${severity}`}> + <div className="alert-icon">{icon}</div> + <div className="alert-content"> + <div className="alert-title">{title}</div> + {body && <div className="alert-message">{body}</div>} + {alert.infoUrls.length > 0 && ( + <div className="alert-message" style={{ marginTop: "0.25rem" }}> + {alert.infoUrls.map((url, i) => ( + <a + key={i} + href={url} + target="_blank" + rel="noopener noreferrer" + style={{ display: "block" }} + > + {url} + </a> + ))} + </div> + )} + </div> </div> - </div> - </div> + ); + })} </div> ); }; diff --git a/src/frontend/app/data/StopDataProvider.ts b/src/frontend/app/data/StopDataProvider.ts index d8219c9..c60f9aa 100644 --- a/src/frontend/app/data/StopDataProvider.ts +++ b/src/frontend/app/data/StopDataProvider.ts @@ -1,3 +1,5 @@ +import { writeFavorites } from "~/utils/idb"; + export interface Stop { stopId: string; stopCode?: string; @@ -168,6 +170,9 @@ function addFavourite(stopId: string | number) { if (!favouriteStops.includes(id)) { favouriteStops.push(id); localStorage.setItem(`favouriteStops`, JSON.stringify(favouriteStops)); + writeFavorites("favouriteStops", favouriteStops).catch(() => { + /* best-effort */ + }); } } @@ -183,6 +188,9 @@ function removeFavourite(stopId: string | number) { const newFavouriteStops = favouriteStops.filter((sid) => sid !== id); localStorage.setItem(`favouriteStops`, JSON.stringify(newFavouriteStops)); + writeFavorites("favouriteStops", newFavouriteStops).catch(() => { + /* best-effort */ + }); } function isFavourite(stopId: string | number): boolean { diff --git a/src/frontend/app/hooks/useFavorites.ts b/src/frontend/app/hooks/useFavorites.ts index 962ac2d..0eceba9 100644 --- a/src/frontend/app/hooks/useFavorites.ts +++ b/src/frontend/app/hooks/useFavorites.ts @@ -1,7 +1,10 @@ import { useState } from "react"; +import { writeFavorites } from "~/utils/idb"; /** * A simple hook for managing favorite items in localStorage. + * Also mirrors changes to IndexedDB so the service worker can filter push + * notifications by favourites without access to localStorage. * @param key LocalStorage key to use * @returns [favorites, toggleFavorite, isFavorite] */ @@ -18,6 +21,9 @@ export function useFavorites(key: string) { ? prev.filter((item) => item !== id) : [...prev, id]; localStorage.setItem(key, JSON.stringify(next)); + writeFavorites(key, next).catch(() => { + /* best-effort */ + }); return next; }); }; diff --git a/src/frontend/app/routes/favourites.tsx b/src/frontend/app/routes/favourites.tsx index 3d786b6..1b1d09b 100644 --- a/src/frontend/app/routes/favourites.tsx +++ b/src/frontend/app/routes/favourites.tsx @@ -99,7 +99,10 @@ export default function Favourites() { return routes.reduce( (acc, route) => { const agency = route.agencyName || t("routes.unknown_agency", "Otros"); - if (!isFavoriteAgency(agency)) { + // Match by the agency's own gtfsId (feedId:agencyId) — consistent with + // what routes.tsx stores and with the alert selector format. + const agencyId = route.agencyId ?? route.id.split(":")[0]; + if (!isFavoriteAgency(agencyId)) { return acc; } diff --git a/src/frontend/app/routes/routes.tsx b/src/frontend/app/routes/routes.tsx index 57dfe00..f65adaa 100644 --- a/src/frontend/app/routes/routes.tsx +++ b/src/frontend/app/routes/routes.tsx @@ -63,16 +63,30 @@ export default function RoutesPage() { const sortedAgencyEntries = useMemo(() => { if (!routesByAgency) return []; - return Object.entries(routesByAgency).sort(([a], [b]) => { + return Object.entries(routesByAgency).sort(([a, routesA], [b, routesB]) => { + // Use the agency's own gtfsId (feedId:agencyId) as the stable key — this + // matches the "agency#feedId:agencyId" alert selector format and correctly + // handles feeds that contain multiple agencies. + const agencyIdA = + routesA?.[0]?.agencyId ?? + routesA?.[0]?.id.split(":")[0] ?? + a.toLowerCase(); + const agencyIdB = + routesB?.[0]?.agencyId ?? + routesB?.[0]?.id.split(":")[0] ?? + b.toLowerCase(); + const feedIdA = agencyIdA.split(":")[0]; + const feedIdB = agencyIdB.split(":")[0]; + // First, sort by favorite status - const isFavA = isFavoriteAgency(a); - const isFavB = isFavoriteAgency(b); + const isFavA = isFavoriteAgency(agencyIdA); + const isFavB = isFavoriteAgency(agencyIdB); if (isFavA && !isFavB) return -1; if (!isFavA && isFavB) return 1; // Then by fixed order - const indexA = orderedAgencies.indexOf(a.toLowerCase()); - const indexB = orderedAgencies.indexOf(b.toLowerCase()); + const indexA = orderedAgencies.indexOf(feedIdA); + const indexB = orderedAgencies.indexOf(feedIdB); if (indexA === -1 && indexB === -1) { return a.localeCompare(b); } @@ -156,10 +170,15 @@ export default function RoutesPage() { )} {sortedAgencyEntries.map(([agency, agencyRoutes]) => { - const isFav = isFavoriteAgency(agency); + // Use the agency's own gtfsId (feedId:agencyId) as the stable favourite key. + const agencyId = + agencyRoutes?.[0]?.agencyId ?? + agencyRoutes?.[0]?.id.split(":")[0] ?? + agency.toLowerCase(); + const isFav = isFavoriteAgency(agencyId); const isExpanded = searchQuery ? true - : (expandedAgencies[agency] ?? false); + : (expandedAgencies[agency] ?? isFav); return ( <div @@ -190,7 +209,7 @@ export default function RoutesPage() { </button> <button type="button" - onClick={() => toggleFavoriteAgency(agency)} + onClick={() => toggleFavoriteAgency(agencyId)} className={`rounded-full p-2 transition-colors ${ isFav ? "text-yellow-500" diff --git a/src/frontend/app/routes/settings.tsx b/src/frontend/app/routes/settings.tsx index 0497f34..a716030 100644 --- a/src/frontend/app/routes/settings.tsx +++ b/src/frontend/app/routes/settings.tsx @@ -2,6 +2,7 @@ import { Computer, Moon, Sun, Trash2 } from "lucide-react"; import { useState } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; +import { PushNotificationSettings } from "~/components/PushNotificationSettings"; import { usePageTitle } from "~/contexts/PageTitleContext"; import { useApp, type Theme } from "../AppContext"; import "../tailwind-full.css"; @@ -178,7 +179,7 @@ export default function Settings() { className="block text-lg font-medium text-text mb-3" > {t("about.language", "Idioma")} - </label> + </label>{" "} <select id="language" className=" @@ -197,6 +198,11 @@ export default function Settings() { </select> </section> + {/* Push Notifications */} + <div className="mt-8 pt-8 border-t border-border"> + <PushNotificationSettings /> + </div> + {/* Privacy / Clear data */} <section className="mt-8 pt-8 border-t border-border"> <h2 className="text-xl font-semibold mb-4 text-text"> diff --git a/src/frontend/app/utils/idb.ts b/src/frontend/app/utils/idb.ts new file mode 100644 index 0000000..4d0aba7 --- /dev/null +++ b/src/frontend/app/utils/idb.ts @@ -0,0 +1,83 @@ +/** + * IndexedDB helpers for sharing data with the service worker. + * + * The service worker is a classic script and cannot import ES modules, so it + * contains its own equivalent implementation. Keep the schema (DB name, version, + * store names, and key shapes) in sync with the inline IDB code in pwa-worker.js. + * + * DB: "enmarcha-sw", version 1 + * Store "favorites" — { key: string, ids: string[] } + * Store "alertState" — { alertId: string, silenced: boolean, lastVersion: number } + */ + +const DB_NAME = "enmarcha-sw"; +const DB_VERSION = 1; + +function openDb(): Promise<IDBDatabase> { + return new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, DB_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); + }); +} + +/** Persist a favourites list to IndexedDB so the service worker can read it. */ +export async function writeFavorites( + key: string, + ids: string[] +): Promise<void> { + const db = await openDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction("favorites", "readwrite"); + tx.objectStore("favorites").put({ key, ids }); + tx.oncomplete = () => { + db.close(); + resolve(); + }; + tx.onerror = () => reject(tx.error); + }); +} + +/** Read a favourites list from IndexedDB. */ +export async function readFavorites(key: string): Promise<string[]> { + const db = await openDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction("favorites", "readonly"); + const req = tx.objectStore("favorites").get(key); + req.onsuccess = () => { + db.close(); + resolve( + (req.result as { key: string; ids: string[] } | undefined)?.ids ?? [] + ); + }; + req.onerror = () => reject(req.error); + }); +} + +/** Persist per-alert notification state (silenced flag and last notified version). */ +export async function writeAlertState( + alertId: string, + state: { silenced: boolean; lastVersion: number } +): Promise<void> { + const db = await openDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction("alertState", "readwrite"); + tx.objectStore("alertState").put({ alertId, ...state }); + tx.oncomplete = () => { + db.close(); + resolve(); + }; + tx.onerror = () => reject(tx.error); + }); +} 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, + }), + }); + })() + ); +}); |
