aboutsummaryrefslogtreecommitdiff
path: root/src/frontend
diff options
context:
space:
mode:
authorAriel Costas Guerrero <ariel@costas.dev>2026-04-02 12:38:10 +0200
committerAriel Costas Guerrero <ariel@costas.dev>2026-04-02 12:45:33 +0200
commit1b4f4a674ac533c0b51260ba35ab91dd2cf9486d (patch)
tree9fdaf418bef86c51737bcf203483089c9e2b908b /src/frontend
parent749e04d6fc2304bb29920db297d1fa4d73b57648 (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.ts1
-rw-r--r--src/frontend/app/components/PushNotificationSettings.tsx198
-rw-r--r--src/frontend/app/components/ServiceAlerts.tsx115
-rw-r--r--src/frontend/app/data/StopDataProvider.ts8
-rw-r--r--src/frontend/app/hooks/useFavorites.ts6
-rw-r--r--src/frontend/app/routes/favourites.tsx5
-rw-r--r--src/frontend/app/routes/routes.tsx35
-rw-r--r--src/frontend/app/routes/settings.tsx8
-rw-r--r--src/frontend/app/utils/idb.ts83
-rw-r--r--src/frontend/public/pwa-worker.js247
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,
+ }),
+ });
+ })()
+ );
+});