aboutsummaryrefslogtreecommitdiff
path: root/src/frontend/app
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/app
parent749e04d6fc2304bb29920db297d1fa4d73b57648 (diff)
Basic push notification system for service alerts
Co-authored-by: Copilot <copilot@github.com>
Diffstat (limited to 'src/frontend/app')
-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
9 files changed, 439 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);
+ });
+}