summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAriel Costas Guerrero <ariel@costas.dev>2026-03-16 13:56:06 +0100
committerAriel Costas Guerrero <ariel@costas.dev>2026-03-16 13:56:15 +0100
commit8942cf3c705bbc78a6b3317599658e9bb86dd31b (patch)
treec02c432dad7b31fa11160f16c221dfac45255920
parent3ce586243a49f34b36d0fe4099bbfb2631610f11 (diff)
Add legal document shenanigans
Closes #147
-rw-r--r--docs/ROPA.md159
-rw-r--r--src/Enmarcha.Backend/Program.cs23
-rw-r--r--src/Enmarcha.Backend/Services/Geocoding/GeoapifyGeocodingService.cs4
-rw-r--r--src/Enmarcha.Backend/Services/Geocoding/NominatimGeocodingService.cs2
-rw-r--r--src/frontend/app/components/layout/Drawer.tsx6
-rw-r--r--src/frontend/app/config/constants.ts1
-rw-r--r--src/frontend/app/contexts/MapContext.tsx16
-rw-r--r--src/frontend/app/data/StopDataProvider.ts93
-rw-r--r--src/frontend/app/routes.tsx1
-rw-r--r--src/frontend/app/routes/privacy.tsx375
-rw-r--r--src/frontend/app/routes/settings.tsx82
-rw-r--r--src/frontend/public/maps/styles/openfreemap-light.json6
12 files changed, 688 insertions, 80 deletions
diff --git a/docs/ROPA.md b/docs/ROPA.md
new file mode 100644
index 0000000..dc49b25
--- /dev/null
+++ b/docs/ROPA.md
@@ -0,0 +1,159 @@
+# Registro de Actividades de Tratamiento (ROPA)
+
+**Artículo 30 del RGPD (UE) 2016/679 · Ley Orgánica 3/2018 (LOPDGDD)**
+
+| Campo | Valor |
+|---|---|
+| **Responsable del tratamiento** | Ariel Costas Guerrero |
+| **Contacto** | <privacidad@enmarcha.app> |
+| **Fecha de elaboración** | 16 de marzo de 2026 |
+| **Versión** | 1.0 |
+
+---
+
+## Actividades de tratamiento
+
+### AT-01 · Consulta de llegadas a una parada
+
+| Campo | Detalle |
+|---|---|
+| **Finalidad** | Mostrar las próximas llegadas de autobús a la parada consultada |
+| **Categoría de datos** | Código numérico de parada (no personal) |
+| **Interesados** | Usuarios de la aplicación |
+| **Base jurídica** | Interés legítimo — prestación del servicio solicitado (art. 6.1.f RGPD) |
+| **Plazo de conservación** | No se almacena en servidor; caché en memoria con TTL de 15 min |
+| **Destinatarios** | Vitrasa/Concello de Vigo (dados.vigo.org), TUSSA (app.tussa.org), Tranvías Coruña (itranvias.com), CTAG Shuttle, Renfe GTFS-Realtime |
+| **Transferencias internacionales** | Ninguna |
+
+---
+
+### AT-02 · Planificación de rutas
+
+| Campo | Detalle |
+|---|---|
+| **Finalidad** | Calcular itinerarios de transporte público entre dos puntos |
+| **Categoría de datos** | Coordenadas de origen y destino (WGS84), hora de viaje, preferencia salida/llegada |
+| **Interesados** | Usuarios de la aplicación |
+| **Base jurídica** | Interés legítimo — prestación del servicio solicitado (art. 6.1.f RGPD) |
+| **Plazo de conservación** | No se almacena en servidor; caché en localStorage del dispositivo con TTL de 2 h |
+| **Destinatarios** | OpenTripPlanner (URL configurada en backend) |
+| **Transferencias internacionales** | Depende de dónde se aloje OpenTripPlanner |
+
+---
+
+### AT-03 · Geocodificación y geocodificación inversa
+
+| Campo | Detalle |
+|---|---|
+| **Finalidad** | Convertir texto de búsqueda en coordenadas, o coordenadas en nombre de lugar |
+| **Categoría de datos** | Texto de búsqueda libre O coordenadas lat/lon |
+| **Interesados** | Usuarios de la aplicación |
+| **Base jurídica** | Interés legítimo — prestación del servicio solicitado (art. 6.1.f RGPD) |
+| **Plazo de conservación** | No se almacena en servidor; caché en memoria con TTL de 60 min |
+| **Destinatarios** | Geoapify (api.geoapify.com) — ver política en geoapify.com/privacy-policy |
+| **Transferencias internacionales** | Posible transferencia a servidores de Geoapify fuera del EEE; Geoapify tiene Privacy Shield / SCCs |
+
+---
+
+### AT-04 · Paradas favoritas y nombres personalizados
+
+| Campo | Detalle |
+|---|---|
+| **Finalidad** | Recordar las paradas que el usuario marca como favoritas y los nombres que les asigna |
+| **Categoría de datos** | Identificadores de parada (p. ej. `vitrasa:1400`), nombres de texto libre introducidos por el usuario |
+| **Interesados** | Usuarios de la aplicación |
+| **Base jurídica** | Acción propia del interesado (función solicitada voluntariamente, art. 6.1.a/f RGPD) |
+| **Plazo de conservación** | Indefinido en localStorage del dispositivo; el usuario puede borrarlos en cualquier momento |
+| **Destinatarios** | Nadie — solo localStorage del dispositivo del usuario |
+| **Transferencias internacionales** | Ninguna |
+
+---
+
+### AT-05 · Paradas y lugares recientes
+
+| Campo | Detalle |
+|---|---|
+| **Finalidad** | Mostrar sugerencias de paradas y búsquedas recientes para agilizar el uso |
+| **Categoría de datos** | Códigos de parada, coordenadas de búsquedas del planificador, nombres de lugares |
+| **Interesados** | Usuarios de la aplicación |
+| **Base jurídica** | Interés legítimo — facilitar el uso recurrente de la aplicación (art. 6.1.f RGPD) |
+| **Plazo de conservación** | Indefinido en localStorage; máx. 20 lugares / 10 paradas; el usuario puede borrarlos |
+| **Destinatarios** | Nadie — solo localStorage del dispositivo del usuario |
+| **Transferencias internacionales** | Ninguna |
+
+---
+
+### AT-06 · Ubicaciones de casa y trabajo
+
+| Campo | Detalle |
+|---|---|
+| **Finalidad** | Permitir que el usuario configure atajos hacia sus ubicaciones habituales |
+| **Categoría de datos** | Nombre descriptivo, tipo (parada o dirección), coordenadas lat/lon |
+| **Interesados** | Usuarios de la aplicación |
+| **Base jurídica** | Acción propia del interesado (función configurada voluntariamente, art. 6.1.f RGPD) |
+| **Plazo de conservación** | Indefinido en localStorage; el usuario puede borrarlos en cualquier momento |
+| **Destinatarios** | Nadie — solo localStorage del dispositivo del usuario |
+| **Transferencias internacionales** | Ninguna |
+
+---
+
+### AT-07 · Posición GPS del mapa
+
+| Campo | Detalle |
+|---|---|
+| **Finalidad** | Centrar el mapa en la posición del usuario; calcular paradas cercanas |
+| **Categoría de datos** | Coordenadas GPS (lat/lon) |
+| **Interesados** | Usuarios de la aplicación que conceden permiso de geolocalización |
+| **Base jurídica** | Consentimiento del interesado a través del permiso de geolocalización del navegador (art. 6.1.a RGPD) |
+| **Plazo de conservación** | 30 días en localStorage; se descarta automáticamente al superarse |
+| **Destinatarios** | Solo se transmite al servidor si el usuario inicia una planificación de ruta desde su posición actual (AT-02) |
+| **Transferencias internacionales** | Ninguna (salvo si aplica AT-02) |
+
+---
+
+### AT-08 · Registros operativos del servidor
+
+| Campo | Detalle |
+|---|---|
+| **Finalidad** | Diagnóstico de errores, monitorización de disponibilidad y seguridad |
+| **Categoría de datos** | Dirección IP **anonimizada** (IPv4: /24; IPv6: /48), identificadores de parada/ruta, método HTTP, código de respuesta |
+| **Interesados** | Cualquier usuario que acceda a la API backend |
+| **Base jurídica** | Interés legítimo — operación segura del servicio (art. 6.1.f RGPD) |
+| **Plazo de conservación** | Rotación estándar de logs del servidor (típicamente 7-30 días) |
+| **Destinatarios** | Solo el responsable del tratamiento |
+| **Transferencias internacionales** | Depende del proveedor de hosting |
+| **Nota técnica** | Los IPs se truncan _antes_ de que entren en el pipeline de logging mediante middleware de ASP.NET Core |
+
+---
+
+### AT-09 · Telemetría de rendimiento (OpenTelemetry)
+
+| Campo | Detalle |
+|---|---|
+| **Finalidad** | Monitorización de rendimiento, trazabilidad de errores |
+| **Categoría de datos** | Identificadores de parada/ruta, duración de peticiones, códigos de estado — **sin coordenadas ni IPs completas** |
+| **Interesados** | Cualquier usuario que acceda a la API backend |
+| **Base jurídica** | Interés legítimo — mejora del servicio (art. 6.1.f RGPD) |
+| **Plazo de conservación** | Según configuración del colector OTLP (si se activa) |
+| **Destinatarios** | Colector OTLP autogestionado o tercero (p. ej. Grafana Cloud) si se configura |
+| **Transferencias internacionales** | Posible si se usa SaaS de telemetría fuera del EEE |
+| **Nota técnica** | Las coordenadas (lat/lon) se han eliminado de los atributos de span; las IPs se anonymizan vía `EnrichWithHttpRequest` |
+
+---
+
+## Medidas técnicas y organizativas
+
+| Medida | Descripción |
+|---|---|
+| Anonimización de IPs | IPv4 → último octeto = 0; IPv6 → últimos 80 bits = 0 (antes de logs y spans) |
+| Sin coordenadas en spans | El atributo `lat`/`lon` se ha eliminado de los spans de OpenTelemetry |
+| User-Agent sin email | El encabezado User-Agent enviado a Geoapify y Nominatim no contiene datos personales del responsable |
+| Expiración automática de ubicación | La posición GPS guardada en localStorage se descarta tras 30 días |
+| Borrado por el usuario | Los usuarios pueden eliminar todos sus datos locales desde Ajustes → Privacidad y datos |
+| Sin cuentas de usuario | No se crean perfiles, contraseñas ni identificadores persistentes de usuario |
+| Sin cookies de seguimiento | Solo se usa `localStorage` de primer nivel y, opcionalmente, una cookie de idioma |
+| Política de privacidad pública | Disponible en `/politica-privacidad` dentro de la aplicación |
+
+---
+
+_Documento interno para uso del responsable del tratamiento. No es un documento público._
diff --git a/src/Enmarcha.Backend/Program.cs b/src/Enmarcha.Backend/Program.cs
index 6eddfc8..587da78 100644
--- a/src/Enmarcha.Backend/Program.cs
+++ b/src/Enmarcha.Backend/Program.cs
@@ -46,7 +46,28 @@ builder.Services.AddOpenTelemetry()
tracing
.SetResourceBuilder(ResourceBuilder.CreateDefault().AddService("Enmarcha.Backend"))
.AddSource(Telemetry.Source.Name)
- .AddAspNetCoreInstrumentation()
+ .AddAspNetCoreInstrumentation(options =>
+ {
+ options.EnrichWithHttpRequest = (activity, request) =>
+ {
+ var ip = request.HttpContext.Connection.RemoteIpAddress;
+ if (ip == null) return;
+ string anonymised;
+ if (ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
+ {
+ var bytes = ip.GetAddressBytes();
+ bytes[3] = 0;
+ anonymised = new System.Net.IPAddress(bytes).ToString();
+ }
+ else
+ {
+ var bytes = ip.GetAddressBytes();
+ for (var i = 6; i < 16; i++) bytes[i] = 0;
+ anonymised = new System.Net.IPAddress(bytes).ToString();
+ }
+ activity.SetTag("client.address", anonymised);
+ };
+ })
.AddHttpClientInstrumentation(options =>
{
options.EnrichWithHttpRequestMessage = (activity, req) =>
diff --git a/src/Enmarcha.Backend/Services/Geocoding/GeoapifyGeocodingService.cs b/src/Enmarcha.Backend/Services/Geocoding/GeoapifyGeocodingService.cs
index 86386e8..ce86c49 100644
--- a/src/Enmarcha.Backend/Services/Geocoding/GeoapifyGeocodingService.cs
+++ b/src/Enmarcha.Backend/Services/Geocoding/GeoapifyGeocodingService.cs
@@ -26,7 +26,7 @@ public class GeoapifyGeocodingService : IGeocodingService
// Geoapify requires a User-Agent
if (!_httpClient.DefaultRequestHeaders.Contains("User-Agent"))
{
- _httpClient.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Compatible; Enmarcha/0.1; https://enmarcha.app; ariel@costas.dev)");
+ _httpClient.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Compatible; Enmarcha/0.1; https://enmarcha.app; contacto@enmarcha.app)");
}
}
@@ -76,8 +76,6 @@ public class GeoapifyGeocodingService : IGeocodingService
public async Task<PlannerSearchResult?> GetReverseGeocodeAsync(double lat, double lon)
{
using var activity = Telemetry.Source.StartActivity("GeoapifyReverseGeocode");
- activity?.SetTag("lat", lat);
- activity?.SetTag("lon", lon);
var cacheKey = $"nominatim_reverse_{lat:F5}_{lon:F5}";
var cacheHit = _cache.TryGetValue(cacheKey, out PlannerSearchResult? cachedResult);
diff --git a/src/Enmarcha.Backend/Services/Geocoding/NominatimGeocodingService.cs b/src/Enmarcha.Backend/Services/Geocoding/NominatimGeocodingService.cs
index c38b1e6..b58b5a4 100644
--- a/src/Enmarcha.Backend/Services/Geocoding/NominatimGeocodingService.cs
+++ b/src/Enmarcha.Backend/Services/Geocoding/NominatimGeocodingService.cs
@@ -26,7 +26,7 @@ public class NominatimGeocodingService : IGeocodingService
// Nominatim requires a User-Agent
if (!_httpClient.DefaultRequestHeaders.Contains("User-Agent"))
{
- _httpClient.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Compatible; Enmarcha/0.1; https://enmarcha.app; ariel@costas.dev)");
+ _httpClient.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Compatible; Enmarcha/0.1; https://enmarcha.app; contacto@enmarcha.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<DrawerProps> = ({ isOpen, onClose }) => {
<Info size={20} />
<span>{t("about.title", "Acerca de")}</span>
</Link>
+ <Link to="/politica-privacidad" className="drawer__link">
+ <Shield size={20} />
+ <span>{t("navbar.privacy", "Privacidad")}</span>
+ </Link>
</nav>
</div>
</>
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<MapContextProps | undefined>(undefined);
+const LOCATION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
+
export const MapProvider = ({ children }: { children: ReactNode }) => {
const [mapState, setMapState] = useState<MapState>(() => {
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<string, Stop> = {};
// Custom names loaded from localStorage per region
-const customNamesByRegion: Record<string, Record<string, string>> = {};
+let customNames: Record<string, string> = {};
// 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 (
+ <div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
+ <h1 className="text-3xl font-bold mb-2 text-text">
+ Política de privacidad
+ </h1>
+ <p className="text-sm text-muted mb-8">
+ Última actualización: 16 de marzo de 2026
+ </p>
+
+ {/* 1. Responsable */}
+ <section className="mb-8">
+ <h2 className="text-2xl font-semibold mb-4 border-b border-border pb-2 text-text">
+ 1. Responsable del tratamiento
+ </h2>
+ <p className="text-text opacity-90 leading-relaxed">
+ El responsable del tratamiento de los datos personales recogidos a
+ través de esta aplicación es:
+ </p>
+ <ul className="mt-3 space-y-1 text-text opacity-90 list-none ml-0">
+ <li>
+ <strong>Nombre:</strong> Ariel Costas Guerrero
+ </li>
+ <li>
+ <strong>Correo de contacto:</strong>{" "}
+ <a
+ href="mailto:privacidad@enmarcha.app"
+ className="text-blue-600 dark:text-blue-400 hover:underline"
+ >
+ privacidad@enmarcha.app
+ </a>
+ </li>
+ </ul>
+ </section>
+
+ {/* 2. Datos tratados */}
+ <section className="mb-8">
+ <h2 className="text-2xl font-semibold mb-4 border-b border-border pb-2 text-text">
+ 2. Datos que recogemos y por qué
+ </h2>
+ <p className="text-text opacity-90 mb-4">
+ 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.
+ </p>
+
+ <div className="overflow-x-auto">
+ <table className="w-full text-sm text-text border-collapse bg-surface rounded shadow overflow-hidden">
+ <thead>
+ <tr className="border-b border-border">
+ <th className="text-left p-3 font-semibold">Actividad</th>
+ <th className="text-left p-3 font-semibold">Datos</th>
+ <th className="text-left p-3 font-semibold">Base jurídica</th>
+ <th className="text-left p-3 font-semibold">Conservación</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr className="border-b border-border">
+ <td className="p-3">Consulta de llegadas a una parada</td>
+ <td className="p-3">Código de parada (no personal)</td>
+ <td className="p-3">
+ Interés legítimo (prestación del servicio)
+ </td>
+ <td className="p-3">No se almacena</td>
+ </tr>
+ <tr className="border-b border-border bg-surface/40">
+ <td className="p-3">Planificación de rutas</td>
+ <td className="p-3">
+ Coordenadas de origen y destino, hora deseada
+ </td>
+ <td className="p-3">
+ Interés legítimo (prestación del servicio)
+ </td>
+ <td className="p-3">2 horas (caché local en tu dispositivo)</td>
+ </tr>
+ <tr className="border-b border-border">
+ <td className="p-3">
+ Geocodificación / geocodificación inversa
+ </td>
+ <td className="p-3">Coordenadas o texto de búsqueda</td>
+ <td className="p-3">
+ Interés legítimo (prestación del servicio)
+ </td>
+ <td className="p-3">No se almacena en servidor</td>
+ </tr>
+ <tr className="border-b border-border bg-surface/40">
+ <td className="p-3">
+ Paradas favoritas y nombres personalizados
+ </td>
+ <td className="p-3">
+ Identificadores de parada, nombres que tú asignas
+ </td>
+ <td className="p-3">
+ Acción del usuario (guardado voluntario)
+ </td>
+ <td className="p-3">Hasta que los borres tú</td>
+ </tr>
+ <tr className="border-b border-border">
+ <td className="p-3">Paradas y lugares recientes</td>
+ <td className="p-3">
+ Códigos de parada, coordenadas de búsquedas recientes
+ </td>
+ <td className="p-3">Interés legítimo (facilitar el uso)</td>
+ <td className="p-3">Persistente hasta que los borres tú</td>
+ </tr>
+ <tr className="border-b border-border bg-surface/40">
+ <td className="p-3">Ubicaciones de casa y trabajo</td>
+ <td className="p-3">Dirección descriptiva y coordenadas</td>
+ <td className="p-3">
+ Acción del usuario (configurado voluntariamente)
+ </td>
+ <td className="p-3">Hasta que los borres tú</td>
+ </tr>
+ <tr className="border-b border-border">
+ <td className="p-3">Posición GPS del mapa</td>
+ <td className="p-3">Coordenadas GPS de tu dispositivo</td>
+ <td className="p-3">
+ Consentimiento (permiso de geolocalización del navegador)
+ </td>
+ <td className="p-3">
+ 30 días (caché local; se descarta automáticamente)
+ </td>
+ </tr>
+ <tr className="border-b border-border bg-surface/40">
+ <td className="p-3">Registros operativos del servidor</td>
+ <td className="p-3">
+ Dirección IP anonimizada, identificadores de parada/ruta
+ </td>
+ <td className="p-3">
+ Interés legítimo (operación y seguridad)
+ </td>
+ <td className="p-3">Rotación estándar de logs del servidor</td>
+ </tr>
+ <tr className="border-b border-border">
+ <td className="p-3">Telemetría de rendimiento</td>
+ <td className="p-3">
+ Identificadores de parada/ruta (sin coordenadas)
+ </td>
+ <td className="p-3">Interés legítimo (mejora del servicio)</td>
+ <td className="p-3">Según configuración del colector OTLP</td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ </section>
+
+ {/* 3. Geolocalización */}
+ <section className="mb-8">
+ <h2 className="text-2xl font-semibold mb-4 border-b border-border pb-2 text-text">
+ 3. Geolocalización
+ </h2>
+ <p className="text-text opacity-90 leading-relaxed">
+ 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.
+ </p>
+ <p className="mt-3 text-text opacity-90 leading-relaxed">
+ Tus coordenadas <strong>no se transmiten al servidor</strong> 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.
+ </p>
+ <p className="mt-3 text-text opacity-90 leading-relaxed">
+ La posición guardada localmente en tu dispositivo se descarta
+ automáticamente tras 30 días.
+ </p>
+ </section>
+
+ {/* 4. Almacenamiento local */}
+ <section className="mb-8">
+ <h2 className="text-2xl font-semibold mb-4 border-b border-border pb-2 text-text">
+ 4. Almacenamiento local (localStorage)
+ </h2>
+ <p className="text-text opacity-90 leading-relaxed">
+ Esta aplicación utiliza el almacenamiento local del navegador (
+ <code className="bg-surface px-1 rounded text-sm">localStorage</code>)
+ para guardar tus preferencias, favoritos e historial de uso. Este
+ almacenamiento <strong>nunca se comparte con terceros</strong> y es de
+ carácter estrictamente funcional, no de seguimiento ni publicidad.
+ </p>
+ <p className="mt-3 text-text opacity-90 leading-relaxed">
+ No se usan <em>cookies</em> de sesión ni de rastreo. La única
+ excepción es la preferencia de idioma, que puede almacenarse tanto en{" "}
+ <code className="bg-surface px-1 rounded text-sm">localStorage</code>{" "}
+ como en una cookie de primer nivel para mantener la selección entre
+ visitas.
+ </p>
+ <p className="mt-3 text-text opacity-90 leading-relaxed">
+ Puedes eliminar todos los datos personales guardados localmente en
+ cualquier momento desde{" "}
+ <strong>
+ Ajustes → Privacidad y datos → Borrar mis datos guardados
+ </strong>
+ .
+ </p>
+ </section>
+
+ {/* 5. Encargados del tratamiento */}
+ <section className="mb-8">
+ <h2 className="text-2xl font-semibold mb-4 border-b border-border pb-2 text-text">
+ 5. Encargados y destinatarios del tratamiento
+ </h2>
+ <p className="text-text opacity-90 mb-4">
+ 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.
+ </p>
+ <ul className="space-y-3 text-text opacity-90 list-disc ml-5">
+ <li>
+ <strong>Geoapify</strong> (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:{" "}
+ <a
+ href="https://www.geoapify.com/privacy-policy"
+ className="text-blue-600 dark:text-blue-400 hover:underline"
+ rel="nofollow noreferrer noopener"
+ target="_blank"
+ >
+ geoapify.com/privacy-policy
+ </a>
+ </li>
+ <li>
+ <strong>OpenTripPlanner</strong> — motor de planificación de rutas.
+ Recibe las coordenadas de origen y destino y la hora del viaje.
+ </li>
+ <li>
+ <strong>Vitrasa / Concello de Vigo</strong> (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.
+ </li>
+ <li>
+ <strong>TUSSA</strong> (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.
+ </li>
+ <li>
+ <strong>Tranvías de A Coruña</strong> (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.
+ </li>
+ <li>
+ <strong>Renfe GTFS-Realtime</strong> — feed público de posiciones de
+ trenes. No se envían datos del usuario.
+ </li>
+ </ul>
+ </section>
+
+ {/* 6. Derechos */}
+ <section className="mb-8">
+ <h2 className="text-2xl font-semibold mb-4 border-b border-border pb-2 text-text">
+ 6. Tus derechos
+ </h2>
+ <p className="text-text opacity-90 mb-3 leading-relaxed">
+ 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:
+ </p>
+ <ul className="space-y-2 text-text opacity-90 list-disc ml-5">
+ <li>
+ <strong>Acceso</strong>: conocer qué datos tratamos sobre ti (los
+ datos que mencionamos previamente).
+ </li>
+ <li>
+ <strong>Rectificación</strong>: solicitar la corrección de datos
+ inexactos (puedes hacerlo a través de la aplicación).
+ </li>
+ <li>
+ <strong>Supresión</strong>: 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.
+ </li>
+ <li>
+ <strong>Oposición</strong>: 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).
+ </li>
+ <li>
+ <strong>Portabilidad</strong>: 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.
+ </li>
+ <li>
+ <strong>Limitación</strong>: 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.
+ </li>
+ </ul>
+ <p className="mt-4 text-text opacity-90 leading-relaxed">
+ 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).
+ </p>
+ <p className="mt-3 text-text opacity-90 leading-relaxed">
+ Para cualquier consulta o solicitud formal, puedes dirigirte a{" "}
+ <a
+ href="mailto:privacidad@enmarcha.app"
+ className="text-blue-600 dark:text-blue-400 hover:underline"
+ >
+ privacidad@enmarcha.app
+ </a>
+ .
+ </p>
+ </section>
+
+ {/* 7. AEPD */}
+ <section className="mb-8">
+ <h2 className="text-2xl font-semibold mb-4 border-b border-border pb-2 text-text">
+ 7. Derecho a reclamar ante la autoridad de control
+ </h2>
+ <p className="text-text opacity-90 leading-relaxed">
+ Si consideras que el tratamiento de tus datos no se ajusta a la
+ normativa vigente, puedes presentar una reclamación ante la{" "}
+ <strong>Agencia Española de Protección de Datos (AEPD)</strong>:
+ </p>
+ <ul className="mt-3 space-y-1 text-text opacity-90 list-none ml-0">
+ <li>
+ Web:{" "}
+ <a
+ href="https://www.aepd.es"
+ className="text-blue-600 dark:text-blue-400 hover:underline"
+ rel="nofollow noreferrer noopener"
+ target="_blank"
+ >
+ www.aepd.es
+ </a>
+ </li>
+ <li>
+ Sede electrónica:{" "}
+ <a
+ href="https://sedeagpd.gob.es"
+ className="text-blue-600 dark:text-blue-400 hover:underline"
+ rel="nofollow noreferrer noopener"
+ target="_blank"
+ >
+ sedeagpd.gob.es
+ </a>
+ </li>
+ </ul>
+ </section>
+
+ {/* 8. Cambios */}
+ <section className="mb-8">
+ <h2 className="text-2xl font-semibold mb-4 border-b border-border pb-2 text-text">
+ 8. Cambios en esta política
+ </h2>
+ <p className="text-text opacity-90 leading-relaxed">
+ 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.
+ </p>
+ </section>
+ </div>
+ );
+}
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() {
<option value="en-GB">English</option>
</select>
</section>
+
+ {/* Privacy / Clear data */}
+ <section className="mt-8 pt-8 border-t border-border">
+ <h2 className="text-xl font-semibold mb-4 text-text">
+ {t("settings.privacy_title", "Privacidad y datos")}
+ </h2>
+ {!showClearConfirm && !cleared && (
+ <button
+ onClick={() => setShowClearConfirm(true)}
+ className="flex items-center gap-2 px-4 py-3 rounded-lg border border-border
+ bg-surface text-text hover:bg-red-50 hover:border-red-300 hover:text-red-700
+ dark:hover:bg-red-950 dark:hover:border-red-700 dark:hover:text-red-400
+ transition-colors duration-200 focus:outline-none focus:ring focus:ring-red-300"
+ >
+ <Trash2 className="w-5 h-5" />
+ {t("settings.clear_data", "Borrar mis datos guardados")}
+ </button>
+ )}
+ {showClearConfirm && (
+ <div className="p-4 rounded-lg border border-red-300 bg-red-50 dark:bg-red-950 dark:border-red-700">
+ <p className="text-sm text-text mb-4">
+ {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."
+ )}
+ </p>
+ <div className="flex gap-3">
+ <button
+ onClick={() => {
+ const personalKeys = [
+ "mapState",
+ "specialPlace_home",
+ "specialPlace_work",
+ "recentPlaces",
+ "planner_route_history",
+ `favouriteStops`,
+ `recentStops`,
+ `customStopNames`,
+ `stops_cache`,
+ ];
+ personalKeys.forEach((key) => localStorage.removeItem(key));
+ setShowClearConfirm(false);
+ setCleared(true);
+ }}
+ className="px-4 py-2 rounded-lg bg-red-600 text-white hover:bg-red-700
+ transition-colors duration-200 focus:outline-none focus:ring focus:ring-red-300"
+ >
+ {t("settings.clear_data_confirm_btn", "Sí, borrar datos")}
+ </button>
+ <button
+ onClick={() => setShowClearConfirm(false)}
+ className="px-4 py-2 rounded-lg border border-border bg-surface text-text
+ hover:bg-surface/50 transition-colors duration-200 focus:outline-none focus:ring focus:ring-primary/50"
+ >
+ {t("settings.cancel", "Cancelar")}
+ </button>
+ </div>
+ </div>
+ )}
+ {cleared && (
+ <p className="text-sm text-green-700 dark:text-green-400">
+ {t(
+ "settings.clear_data_done",
+ "Tus datos se han borrado correctamente."
+ )}
+ </p>
+ )}
+ <p className="mt-4 text-sm text-muted">
+ <Link
+ to="/politica-privacidad"
+ className="underline hover:text-text transition-colors"
+ >
+ {t("settings.privacy_policy_link", "Política de privacidad")}
+ </Link>
+ </p>
+ </section>
</div>
);
}
diff --git a/src/frontend/public/maps/styles/openfreemap-light.json b/src/frontend/public/maps/styles/openfreemap-light.json
index da5a788..18053f6 100644
--- a/src/frontend/public/maps/styles/openfreemap-light.json
+++ b/src/frontend/public/maps/styles/openfreemap-light.json
@@ -3,15 +3,15 @@
"sources": {
"openmaptiles": {
"type": "vector",
- "url": "https://tiles.openfreemap.org/planet"
+ "url": "https://enmarcha.app/ofm/planet"
},
"vigo_traffic": {
"type": "geojson",
"data": "/api/traffic"
}
},
- "sprite": "https://tiles.openfreemap.org/sprites/ofm_f384/ofm",
- "glyphs": "https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf",
+ "sprite": "https://enmarcha.app/ofm/sprites/ofm_f384/ofm",
+ "glyphs": "https://enmarcha.app/ofm/fonts/{fontstack}/{range}.pbf",
"layers": [
{
"id": "background",