From 8942cf3c705bbc78a6b3317599658e9bb86dd31b Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Mon, 16 Mar 2026 13:56:06 +0100 Subject: Add legal document shenanigans Closes #147 --- src/frontend/app/components/layout/Drawer.tsx | 6 +- src/frontend/app/config/constants.ts | 1 - src/frontend/app/contexts/MapContext.tsx | 16 +- src/frontend/app/data/StopDataProvider.ts | 93 ++----- src/frontend/app/routes.tsx | 1 + src/frontend/app/routes/privacy.tsx | 375 ++++++++++++++++++++++++++ src/frontend/app/routes/settings.tsx | 82 +++++- 7 files changed, 502 insertions(+), 72 deletions(-) create mode 100644 src/frontend/app/routes/privacy.tsx (limited to 'src/frontend/app') diff --git a/src/frontend/app/components/layout/Drawer.tsx b/src/frontend/app/components/layout/Drawer.tsx index 55aa3a0..3f7b4d5 100644 --- a/src/frontend/app/components/layout/Drawer.tsx +++ b/src/frontend/app/components/layout/Drawer.tsx @@ -1,4 +1,4 @@ -import { Info, Settings, Star, X } from "lucide-react"; +import { Info, Settings, Shield, Star, X } from "lucide-react"; import React, { useEffect } from "react"; import { useTranslation } from "react-i18next"; import { Link, useLocation } from "react-router"; @@ -45,6 +45,10 @@ export const Drawer: React.FC = ({ isOpen, onClose }) => { {t("about.title", "Acerca de")} + + + {t("navbar.privacy", "Privacidad")} + diff --git a/src/frontend/app/config/constants.ts b/src/frontend/app/config/constants.ts index 3cbb37c..5ede933 100644 --- a/src/frontend/app/config/constants.ts +++ b/src/frontend/app/config/constants.ts @@ -1,7 +1,6 @@ import type { LngLatLike } from "maplibre-gl"; export const APP_CONSTANTS = { - id: "vigo", defaultCenter: { lat: 42.229188855975046, lng: -8.72246955783102, diff --git a/src/frontend/app/contexts/MapContext.tsx b/src/frontend/app/contexts/MapContext.tsx index 75851f4..d93fefc 100644 --- a/src/frontend/app/contexts/MapContext.tsx +++ b/src/frontend/app/contexts/MapContext.tsx @@ -25,15 +25,23 @@ interface MapContextProps { const MapContext = createContext(undefined); +const LOCATION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days + export const MapProvider = ({ children }: { children: ReactNode }) => { const [mapState, setMapState] = useState(() => { const savedMapState = localStorage.getItem("mapState"); if (savedMapState) { try { const parsed = JSON.parse(savedMapState); + const locationAge = parsed.userLocationTimestamp + ? Date.now() - parsed.userLocationTimestamp + : Infinity; return { paths: parsed.paths || {}, - userLocation: parsed.userLocation || null, + userLocation: + locationAge < LOCATION_TTL_MS + ? (parsed.userLocation ?? null) + : null, hasLocationPermission: parsed.hasLocationPermission || false, }; } catch (e) { @@ -51,7 +59,11 @@ export const MapProvider = ({ children }: { children: ReactNode }) => { const setUserLocation = useCallback((userLocation: LngLatLike | null) => { setMapState((prev) => { - const newState = { ...prev, userLocation }; + const newState = { + ...prev, + userLocation, + userLocationTimestamp: userLocation ? Date.now() : null, + }; localStorage.setItem("mapState", JSON.stringify(newState)); return newState; }); diff --git a/src/frontend/app/data/StopDataProvider.ts b/src/frontend/app/data/StopDataProvider.ts index 76182c7..d8219c9 100644 --- a/src/frontend/app/data/StopDataProvider.ts +++ b/src/frontend/app/data/StopDataProvider.ts @@ -1,5 +1,3 @@ -import { APP_CONSTANTS } from "~/config/constants"; - export interface Stop { stopId: string; stopCode?: string; @@ -20,14 +18,14 @@ interface CacheEntry { timestamp: number; } -const CACHE_KEY = `stops_cache_${APP_CONSTANTS.id}`; +const CACHE_KEY = `stops_cache`; const CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours // In-memory cache for the current session const memoryCache: Record = {}; // Custom names loaded from localStorage per region -const customNamesByRegion: Record> = {}; +let customNames: Record = {}; // Helper to normalize ID function normalizeId(id: number | string): string { @@ -128,58 +126,38 @@ function getDisplayName(stop: Stop): string { function setCustomName(stopId: string | number, label: string) { const id = normalizeId(stopId); - if (!customNamesByRegion[APP_CONSTANTS.id]) { - const rawCustom = localStorage.getItem( - `customStopNames_${APP_CONSTANTS.id}` - ); - customNamesByRegion[APP_CONSTANTS.id] = rawCustom - ? JSON.parse(rawCustom) - : {}; + if (!customNames) { + const rawCustom = localStorage.getItem(`customStopNames`); + customNames = rawCustom ? JSON.parse(rawCustom) : {}; } - customNamesByRegion[APP_CONSTANTS.id][id] = label; - localStorage.setItem( - `customStopNames_${APP_CONSTANTS.id}`, - JSON.stringify(customNamesByRegion[APP_CONSTANTS.id]) - ); + customNames[id] = label; + localStorage.setItem(`customStopNames`, JSON.stringify(customNames)); } function removeCustomName(stopId: string | number) { const id = normalizeId(stopId); - if (!customNamesByRegion[APP_CONSTANTS.id]) { - const rawCustom = localStorage.getItem( - `customStopNames_${APP_CONSTANTS.id}` - ); - customNamesByRegion[APP_CONSTANTS.id] = rawCustom - ? JSON.parse(rawCustom) - : {}; + if (!customNames) { + const rawCustom = localStorage.getItem(`customStopNames`); + customNames = rawCustom ? JSON.parse(rawCustom) : {}; } - if (customNamesByRegion[APP_CONSTANTS.id][id]) { - delete customNamesByRegion[APP_CONSTANTS.id][id]; - localStorage.setItem( - `customStopNames_${APP_CONSTANTS.id}`, - JSON.stringify(customNamesByRegion[APP_CONSTANTS.id]) - ); + if (customNames[id]) { + delete customNames[id]; + localStorage.setItem(`customStopNames`, JSON.stringify(customNames)); } } function getCustomName(stopId: string | number): string | undefined { const id = normalizeId(stopId); - if (!customNamesByRegion[APP_CONSTANTS.id]) { - const rawCustom = localStorage.getItem( - `customStopNames_${APP_CONSTANTS.id}` - ); - customNamesByRegion[APP_CONSTANTS.id] = rawCustom - ? JSON.parse(rawCustom) - : {}; + if (!customNames) { + const rawCustom = localStorage.getItem(`customStopNames`); + customNames = rawCustom ? JSON.parse(rawCustom) : {}; } - return customNamesByRegion[APP_CONSTANTS.id][id]; + return customNames[id]; } function addFavourite(stopId: string | number) { const id = normalizeId(stopId); - const rawFavouriteStops = localStorage.getItem( - `favouriteStops_${APP_CONSTANTS.id}` - ); + const rawFavouriteStops = localStorage.getItem(`favouriteStops`); let favouriteStops: string[] = []; if (rawFavouriteStops) { favouriteStops = (JSON.parse(rawFavouriteStops) as (number | string)[]).map( @@ -189,18 +167,13 @@ function addFavourite(stopId: string | number) { if (!favouriteStops.includes(id)) { favouriteStops.push(id); - localStorage.setItem( - `favouriteStops_${APP_CONSTANTS.id}`, - JSON.stringify(favouriteStops) - ); + localStorage.setItem(`favouriteStops`, JSON.stringify(favouriteStops)); } } function removeFavourite(stopId: string | number) { const id = normalizeId(stopId); - const rawFavouriteStops = localStorage.getItem( - `favouriteStops_${APP_CONSTANTS.id}` - ); + const rawFavouriteStops = localStorage.getItem(`favouriteStops`); let favouriteStops: string[] = []; if (rawFavouriteStops) { favouriteStops = (JSON.parse(rawFavouriteStops) as (number | string)[]).map( @@ -209,17 +182,12 @@ function removeFavourite(stopId: string | number) { } const newFavouriteStops = favouriteStops.filter((sid) => sid !== id); - localStorage.setItem( - `favouriteStops_${APP_CONSTANTS.id}`, - JSON.stringify(newFavouriteStops) - ); + localStorage.setItem(`favouriteStops`, JSON.stringify(newFavouriteStops)); } function isFavourite(stopId: string | number): boolean { const id = normalizeId(stopId); - const rawFavouriteStops = localStorage.getItem( - `favouriteStops_${APP_CONSTANTS.id}` - ); + const rawFavouriteStops = localStorage.getItem(`favouriteStops`); if (rawFavouriteStops) { const favouriteStops = ( JSON.parse(rawFavouriteStops) as (number | string)[] @@ -233,9 +201,7 @@ const RECENT_STOPS_LIMIT = 10; function pushRecent(stopId: string | number) { const id = normalizeId(stopId); - const rawRecentStops = localStorage.getItem( - `recentStops_${APP_CONSTANTS.id}` - ); + const rawRecentStops = localStorage.getItem(`recentStops`); let recentStops: string[] = []; if (rawRecentStops) { recentStops = (JSON.parse(rawRecentStops) as (number | string)[]).map( @@ -251,16 +217,11 @@ function pushRecent(stopId: string | number) { recentStops = recentStops.slice(0, RECENT_STOPS_LIMIT); } - localStorage.setItem( - `recentStops_${APP_CONSTANTS.id}`, - JSON.stringify(recentStops) - ); + localStorage.setItem(`recentStops`, JSON.stringify(recentStops)); } function getRecent(): string[] { - const rawRecentStops = localStorage.getItem( - `recentStops_${APP_CONSTANTS.id}` - ); + const rawRecentStops = localStorage.getItem(`recentStops`); if (rawRecentStops) { return (JSON.parse(rawRecentStops) as (number | string)[]).map(normalizeId); } @@ -268,9 +229,7 @@ function getRecent(): string[] { } function getFavouriteIds(): string[] { - const rawFavouriteStops = localStorage.getItem( - `favouriteStops_${APP_CONSTANTS.id}` - ); + const rawFavouriteStops = localStorage.getItem(`favouriteStops`); if (rawFavouriteStops) { return (JSON.parse(rawFavouriteStops) as (number | string)[]).map( normalizeId diff --git a/src/frontend/app/routes.tsx b/src/frontend/app/routes.tsx index 8e98734..7c02728 100644 --- a/src/frontend/app/routes.tsx +++ b/src/frontend/app/routes.tsx @@ -11,4 +11,5 @@ export default [ route("/about", "routes/about.tsx"), route("/favourites", "routes/favourites.tsx"), route("/planner", "routes/planner.tsx"), + route("/politica-privacidad", "routes/privacy.tsx"), ] satisfies RouteConfig; diff --git a/src/frontend/app/routes/privacy.tsx b/src/frontend/app/routes/privacy.tsx new file mode 100644 index 0000000..2998223 --- /dev/null +++ b/src/frontend/app/routes/privacy.tsx @@ -0,0 +1,375 @@ +import { usePageTitle } from "~/contexts/PageTitleContext"; +import "../tailwind-full.css"; + +export default function Privacy() { + usePageTitle("Política de privacidad"); + + return ( +
+

+ Política de privacidad +

+

+ Última actualización: 16 de marzo de 2026 +

+ + {/* 1. Responsable */} +
+

+ 1. Responsable del tratamiento +

+

+ El responsable del tratamiento de los datos personales recogidos a + través de esta aplicación es: +

+ +
+ + {/* 2. Datos tratados */} +
+

+ 2. Datos que recogemos y por qué +

+

+ Esta aplicación es un servicio de consulta de transporte público. No + se crean cuentas de usuario ni se realiza ningún tipo de seguimiento + publicitario. +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ActividadDatosBase jurídicaConservación
Consulta de llegadas a una paradaCódigo de parada (no personal) + Interés legítimo (prestación del servicio) + No se almacena
Planificación de rutas + Coordenadas de origen y destino, hora deseada + + Interés legítimo (prestación del servicio) + 2 horas (caché local en tu dispositivo)
+ Geocodificación / geocodificación inversa + Coordenadas o texto de búsqueda + Interés legítimo (prestación del servicio) + No se almacena en servidor
+ Paradas favoritas y nombres personalizados + + Identificadores de parada, nombres que tú asignas + + Acción del usuario (guardado voluntario) + Hasta que los borres tú
Paradas y lugares recientes + Códigos de parada, coordenadas de búsquedas recientes + Interés legítimo (facilitar el uso)Persistente hasta que los borres tú
Ubicaciones de casa y trabajoDirección descriptiva y coordenadas + Acción del usuario (configurado voluntariamente) + Hasta que los borres tú
Posición GPS del mapaCoordenadas GPS de tu dispositivo + Consentimiento (permiso de geolocalización del navegador) + + 30 días (caché local; se descarta automáticamente) +
Registros operativos del servidor + Dirección IP anonimizada, identificadores de parada/ruta + + Interés legítimo (operación y seguridad) + Rotación estándar de logs del servidor
Telemetría de rendimiento + Identificadores de parada/ruta (sin coordenadas) + Interés legítimo (mejora del servicio)Según configuración del colector OTLP
+
+
+ + {/* 3. Geolocalización */} +
+

+ 3. Geolocalización +

+

+ La aplicación puede solicitar acceso a tu ubicación GPS a través del + permiso de geolocalización del navegador. Este acceso es opcional: si + lo denegas, la aplicación seguirá funcionando sin centrar el mapa en + tu posición. +

+

+ Tus coordenadas no se transmiten al servidor salvo + cuando usas el planificador de rutas para calcular un trayecto desde + tu ubicación actual. En ese caso, las coordenadas se envían al + servidor únicamente para procesar la consulta y no se almacenan. +

+

+ La posición guardada localmente en tu dispositivo se descarta + automáticamente tras 30 días. +

+
+ + {/* 4. Almacenamiento local */} +
+

+ 4. Almacenamiento local (localStorage) +

+

+ Esta aplicación utiliza el almacenamiento local del navegador ( + localStorage) + para guardar tus preferencias, favoritos e historial de uso. Este + almacenamiento nunca se comparte con terceros y es de + carácter estrictamente funcional, no de seguimiento ni publicidad. +

+

+ No se usan cookies de sesión ni de rastreo. La única + excepción es la preferencia de idioma, que puede almacenarse tanto en{" "} + localStorage{" "} + como en una cookie de primer nivel para mantener la selección entre + visitas. +

+

+ Puedes eliminar todos los datos personales guardados localmente en + cualquier momento desde{" "} + + Ajustes → Privacidad y datos → Borrar mis datos guardados + + . +

+
+ + {/* 5. Encargados del tratamiento */} +
+

+ 5. Encargados y destinatarios del tratamiento +

+

+ Para prestar el servicio, algunas de tus consultas se transmiten a los + siguientes terceros. En ningún caso se transfiere información que + permita identificarte personalmente como usuario registrado, ya que la + aplicación no tiene sistema de cuentas. +

+
    +
  • + Geoapify (api.geoapify.com) — buscador de + direcciones y geocodificación inversa. Recibe el texto de búsqueda o + las coordenadas de la consulta. Política de privacidad:{" "} + + geoapify.com/privacy-policy + +
  • +
  • + OpenTripPlanner — motor de planificación de rutas. + Recibe las coordenadas de origen y destino y la hora del viaje. +
  • +
  • + Vitrasa / Concello de Vigo (datos.vigo.org) — datos + de llegadas en tiempo real para Vigo. Solo recibe el código numérico + de la parada, y un resultado puede ser utilizado para varios + usuarios, de modo que no se asocia a un individuo concreto. +
  • +
  • + TUSSA (app.tussa.org) — datos de llegadas en tiempo + real para Santiago de Compostela. Solo recibe el código de la + parada, y un resultado puede ser utilizado para varios usuarios, de + modo que no se asocia a un individuo concreto. +
  • +
  • + Tranvías de A Coruña (itranvias.com) — datos de + llegadas en tiempo real para A Coruña. Solo recibe el código de la + parada, y un resultado puede ser utilizado para varios usuarios, de + modo que no se asocia a un individuo concreto. +
  • +
  • + Renfe GTFS-Realtime — feed público de posiciones de + trenes. No se envían datos del usuario. +
  • +
+
+ + {/* 6. Derechos */} +
+

+ 6. Tus derechos +

+

+ De acuerdo con el Reglamento General de Protección de Datos (RGPD) y + la Ley Orgánica 3/2018 de Protección de Datos Personales y garantía de + los derechos digitales (LOPDGDD), tienes derecho a: +

+
    +
  • + Acceso: conocer qué datos tratamos sobre ti (los + datos que mencionamos previamente). +
  • +
  • + Rectificación: solicitar la corrección de datos + inexactos (puedes hacerlo a través de la aplicación). +
  • +
  • + Supresión: solicitar la eliminación de tus datos + («derecho al olvido»). Los datos guardados en el dispositivo los + debes eliminar manualmente, los datos en servidores se eliminan tras + el periodo de consevación indicado, y no se pueden eliminar + previamente por no ser asociados a un usuario concreto. +
  • +
  • + Oposición: oponerte al tratamiento basado en + interés legítimo. Dado que el tratamiento de datos en esta + aplicación se basa principalmente en el interés legítimo para + prestar el servicio, no es posible ejercer este derecho sin dejar de + usar la aplicación, o las funciones que implican este tratamiento + (planificador de rutas con "ubicación actual", por ejemplo). +
  • +
  • + Portabilidad: recibir tus datos en un formato + estructurado. Los datos están exclusivamente en el dispositivo, + basta con acceder mediante las herramientas de desarrollo del + navegador para copiarlos, o visualizarlos en la aplicación. +
  • +
  • + Limitación: solicitar que restrinjamos el + tratamiento de tus datos. Dado que la aplicación no almacena datos + personales en servidores de forma persistente, este derecho se puede + ejercer eliminando los datos desde la propia aplicación o dejando de + usar las funciones que implican el tratamiento. +
  • +
+

+ Dado que la aplicación no almacena datos personales en ningún servidor + de forma persistente (todo lo personal reside en tu propio + dispositivo), la mayoría de estos derechos los puedes ejercer + directamente borrando los datos desde la propia aplicación (ver + sección 4). +

+

+ Para cualquier consulta o solicitud formal, puedes dirigirte a{" "} + + privacidad@enmarcha.app + + . +

+
+ + {/* 7. AEPD */} +
+

+ 7. Derecho a reclamar ante la autoridad de control +

+

+ Si consideras que el tratamiento de tus datos no se ajusta a la + normativa vigente, puedes presentar una reclamación ante la{" "} + Agencia Española de Protección de Datos (AEPD): +

+ +
+ + {/* 8. Cambios */} +
+

+ 8. Cambios en esta política +

+

+ Esta política puede actualizarse para reflejar cambios en la + aplicación o en la normativa aplicable. La fecha de «Última + actualización» indicada en la cabecera refleja la versión vigente. Te + recomendamos revisarla periódicamente. +

+
+
+ ); +} diff --git a/src/frontend/app/routes/settings.tsx b/src/frontend/app/routes/settings.tsx index e7fdffa..0497f34 100644 --- a/src/frontend/app/routes/settings.tsx +++ b/src/frontend/app/routes/settings.tsx @@ -1,5 +1,7 @@ -import { Computer, Moon, Sun } from "lucide-react"; +import { Computer, Moon, Sun, Trash2 } from "lucide-react"; +import { useState } from "react"; import { useTranslation } from "react-i18next"; +import { Link } from "react-router"; import { usePageTitle } from "~/contexts/PageTitleContext"; import { useApp, type Theme } from "../AppContext"; import "../tailwind-full.css"; @@ -7,6 +9,8 @@ import "../tailwind-full.css"; export default function Settings() { const { t, i18n } = useTranslation(); usePageTitle(t("navbar.settings", "Ajustes")); + const [showClearConfirm, setShowClearConfirm] = useState(false); + const [cleared, setCleared] = useState(false); const { theme, setTheme, @@ -192,6 +196,82 @@ export default function Settings() { + + {/* Privacy / Clear data */} +
+

+ {t("settings.privacy_title", "Privacidad y datos")} +

+ {!showClearConfirm && !cleared && ( + + )} + {showClearConfirm && ( +
+

+ {t( + "settings.clear_data_confirm", + "Se eliminarán tus paradas favoritas, nombres personalizados, paradas recientes, historial de rutas, lugares guardados y posición del mapa. Las preferencias de ajustes se conservarán." + )} +

+
+ + +
+
+ )} + {cleared && ( +

+ {t( + "settings.clear_data_done", + "Tus datos se han borrado correctamente." + )} +

+ )} +

+ + {t("settings.privacy_policy_link", "Política de privacidad")} + +

+
); } -- cgit v1.3