Files
localesp/openspec/changes/archive/2026-08-16-pasarela-proponer-local/design.md
T
edgar.friendly 7bb3ea22da
deploy-branch / deploy (push) Failing after 6s
deploy-branch / teardown (push) Skipped
chore(openspec): archiva pasarela-proponer-local y sincroniza specs principales
- Mueve el cambio (36/36 tareas, 4/4 artefactos) a changes/archive/
- Crea las specs principales de boton-flotante-proponer-local,
  pasarela-proponer-local y proveedores-ubicacion (con Purpose)
2026-08-16 11:51:11 +02:00

13 KiB
Raw Blame History

Diseño — Pasarela para proponer locales

Context

  • src/App.jsx es la única app en producción (PC y móvil): main.jsx solo monta App y AdminPanel; src/AppMovil.jsx es código muerto no referenciado. Por tanto "funcionar para PC y móvil" se resuelve haciendo el FAB y el modal responsive dentro de App.jsx.
  • El formulario actual es un bloque en línea (mostrarForm) con nombre, provincia, categoría, subcategoría, ubicación (dos modos: dirección / enlace de Google Maps), descripción y puntuación, más checkbox de privacidad, filtro de palabras prohibidas y comprobación de duplicados contra server/index.js.
  • El backend ya tolera la ausencia de descripcion (columna nullable) y puntuacion (?? 0 al insertar), así que eliminarlos del cliente no exige cambios de API ni migraciones.
  • La extracción de coordenadas hoy es una única función parsearEnlaceGoogleMaps en src/utils/geo.js, y la geocodificación por texto se hace con Nominatim (geocodificarDireccion, buscarLugares).
  • Convenciones del proyecto: estilos inline con variables CSS (--color-*), componentes pequeños en src/components/, utilidades puras en src/utils/, sin dependencias de UI externas. El patrón de modal existente es ModalPrivacidad.jsx (overlay fixed + stopPropagation + role="dialog").
  • PWA instalada (service worker): el FAB debe convivir con la barra de navegación de la PWA en iOS/Android (env(safe-area-inset-bottom)).

Goals / Non-Goals

Goals:

  • Sustituir el botón de cabecera por un FAB fijo abajo a la derecha, con tooltip, usable en PC y móvil.
  • Pasarela en modal con un campo por pantalla, empezando por Ubicación.
  • Campo universal de ubicación que autodetecta Google Maps / Waze / OpenStreetMap / coordenadas (fallback: texto vía Nominatim).
  • Inferir Nombre y Provincia desde la ubicación (geocodificación inversa Nominatim), editables.
  • Arquitectura de proveedores extensible: varias clases con una interfaz común (detecta/extrae), resultado extensible, registro abierto/cerrado.
  • Eliminar comentario y puntuación del flujo de propuesta.

Non-Goals:

  • Gestionar comentarios/valoraciones de otra forma (cambio posterior).
  • Modificar backend, base de datos, panel de administración o AppMovil.jsx.
  • Autocompletado en la pantalla de Nombre (se anota como mejora futura; ahora es un input simple pre-rellenado).
  • Mapa de previsualización dentro del modal (se anota como mejora futura).

Decisions

D1 — Un único App.jsx responsive para PC y móvil

Decisión: FAB y pasarela se implementan en App.jsx; no se toca AppMovil.jsx (muerto). Alternativas: duplicar la UI en AppMovil.jsx — rechazado: duplicaría lógica y mantiene vivo código que no está en producción.

D2 — FAB con tooltip propio, sin dependencias

Decisión: botón position: fixed; bottom/right con z-index por debajo del modal (modal usa 1000) y padding-bottom: env(safe-area-inset-bottom) en móvil. Tooltip implementado con estado React (onMouseEnter/onMouseLeave/onFocus/onBlur) y posicionado a la izquierda del botón, con aria-label en el botón para accesibilidad sin ratón. Alternativas: atributo title nativo — rechazado por no ser estilizable ni fiable en móvil/táctil; librería de tooltips — rechazada para no añadir dependencias a un proyecto sin ellas.

D3 — Modal propio siguiendo el patrón ModalPrivacidad

Decisión: overlay fixed con role="dialog"/aria-modal, cierre con click en fondo, ✕ y Escape (listener de teclado en useEffect), foco inicial en el primer control de cada pantalla. En pantallas estrechas (matchMedia vía hook useEsMovil), el panel ocupa todo el ancho y casi todo el alto; en escritorio queda centrado con maxWidth ~480px. Sin portal: se renderiza al final del árbol de App, igual que ModalPrivacidad. Alternativas: createPortal — innecesario aquí (no hay overflow: hidden ancestros problemáticos); librería de modals — descartada por política de cero dependencias de UI.

D4 — Pasarela como máquina de pasos sobre el form existente

Decisión: el estado del formulario (form) vive en App.jsx como hoy; la pasarela añade un índice paso y un array declarativo de pantallas: [ubicacion, nombre, provincia, categoria, subcategoria, resumen]. Cada pantalla es un componente que recibe form/setForm y expone su validez (puedeContinuar) para habilitar "Siguiente". "Atrás" simplemente decrementa el índice (los datos se conservan). La pantalla final muestra el resumen + checkbox de privacidad y dispara el flujo de envío existente (contienepalabrasProhibidascomprobarDuplicadoguardarPropuesta), con los avisos de error/duplicado renderizados dentro del modal. Alternativas: cada paso con estado propio y despachar al final — más propenso a inconsistencias; mover el envío a un hook nuevo — innecesario, la lógica actual ya está aislada en funciones de App.jsx.

D5 — FORM_VACIO sin descripcion ni puntuacion

Decisión: el objeto vacío del formulario elimina ambos campos y se dejan de enviar en el payload (enviarPropuesta los omite; el backend ya aplica defaults). formValido deja de exigir puntuacion > 0. StarRating deja de usarse en la propuesta (se conserva para mostrar valoraciones existentes en tarjetas). Alternativas: seguir enviando puntuacion: 0 y descripcion: "" explícitos — innecesario: el servidor ya normaliza.

D6 — Arquitectura de proveedores de ubicación (núcleo extensible)

Decisión: nuevo módulo src/utils/ubicacion/:

src/utils/ubicacion/
  ProveedorUbicacion.js   # clase base = interfaz + helpers compartidos
  proveedorGoogleMaps.js  # detecta: *.google.* / maps.app.goo.gl …
  proveedorWaze.js        # detecta: waze.com/ul?ll=… (o q=texto → delega en Nominatim)
  proveedorOSM.js         # detecta: openstreetmap.org (mlat/mlon, #map=…)
  proveedorCoordenadas.js # detecta: "41.38, 2.17" (± grados, coma o punto)
  proveedorNominatim.js   # fallback: texto de dirección → search; y reverse() para inferir
  index.js                # registro ordenado + resolverUbicacion(entrada) + inferirDesdeCoords()
  • Interfaz (JS no tiene interfaces nativas): la clase base ProveedorUbicacion define el contrato documentado — id, detecta(entrada) → boolean, extrae(entrada) → Promise<ResultadoUbicacion|null> (la base lanza Error("sin implementar"), como una abstracta) — y helpers comunes: cabeceras/UA para Nominatim, normalizar() de textos y emparejarProvincia() contra PROVINCIAS (insensible a acentos/mayúsculas, reutilizando la lógica de AutocompletarLocal).
  • Resultado extensible: { proveedor, lat, lng, nombre?, provincia?, direccion?, …extra }. Los consumidores leen solo lo que conocen; añadir campos mañana (teléfono, horarios…) no rompe nada.
  • Resolver: resolverUbicacion(entrada) recorre el array PROVEEDORES en orden de prioridad (coordenadas → Google → Waze → OSM → Nominatim texto) y devuelve el resultado del primero cuyo detecta acepte. Registrar un proveedor nuevo = añadir la clase y push al array (abierto/cerrado: ni el resolver ni las demás clases cambian).
  • Inferencia: tras obtener coordenadas, inferirDesdeCoords(lat, lng) llama a Nominatim reverse (/reverse?format=jsonv2&addressdetails=1&namedetails=1) y devuelve { nombre, provincia, direccion } con la provincia normalizada al listado canónico (si no casa, provincia "" para forzar selección manual). geocodificarDireccion/parsearEnlaceGoogleMaps de geo.js se reubican como implementación interna de los proveedores (se mantiene geo.js exportando lo que usan LocalCard y el mapa: enlaces y distancias).
  • Errores de red: todo fetch de proveedor va envuelto para devolver null/{ ok: false } controlado; la UI muestra aviso, nunca lanza al consumidor.

Alternativas: funciones sueltas con un switch — no extensible (habría que editar el switch por cada backend); cadena de responsabilidad con instancias singleton — equivalente pero más boilerplate en un codebase sin clases hasta ahora; strategy registry en JSON/config — pierde la capacidad de lógica por proveedor (p.ej. Waze delegando en Nominatim para q=).

D7 — Pantalla de Ubicación: un solo input con feedback inmediato

Decisión: un input universal con debounce (~600ms). Al resolver, muestra un chip con el proveedor detectado (p. ej. "Google Maps detectado") y las coordenadas; si hay inferencia, se marcan nombre/provincia en el form (el usuario las ve ya pre-rellenadas en sus pantallas). Si detecta no reconoce nada, aviso "No se pudo interpretar la ubicación" y "Siguiente" deshabilitado hasta obtener una resolución válida. Alternativas: mantener los dos modos (dirección/enlace) — rechazado por requisito ("un solo campo universal"); validar solo al pulsar Siguiente — peor UX, no avisa de errores mientras se pega el enlace.

D8 — Envío y confirmación

Decisión: al enviar, el modal permanece abierto con estados "Comprobando…/Enviando…" y, si hay duplicado, muestra el aviso con las opciones actuales ("Enviar de todos modos" / "Revisar datos"). Tras éxito: cierra modal, resetea form, y muestra el banner de confirmación existente (enviado). Alternativas: mover el banner dentro del modal — innecesario y menos visible.

Risks / Trade-offs

  • [Rate-limit de Nominatim (política ~1 req/s)] → Debounce de 600ms, una sola llamada por resolución (search o reverse, no ambas salvo fallback), y mensaje de error amable con reintento.
  • [FAB solapa contenido o la barra PWA en móvil] → safe-area-inset-bottom, tamaño moderado (5256px) y z-index inferior al modal; se prueba en viewport estrecho.
  • [Teclado móvil sube y tapa el modal] → modal con height flexible y overflow-y: auto; inputs al inicio de cada pantalla.
  • [Enlaces acortados (maps.app.goo.gl, waze.to) sin coordenadas en la URL] → v1 no hace unshorten (requiere fetch al proveedor y CORS no garantizado): se avisa "pega el enlace completo"; se anota como mejora futura.
  • [Inferencia errónea (nombre genérico tipo "Calle Mayor")] → los campos son editables y la pantalla de resumen lo hace evidente antes de enviar.
  • [JS sin interfaces nativas] → el contrato se documenta en la clase base y se comprueba en el registro (métodos presentes); riesgo bajo al ser codebase propio.

Migration Plan

  1. Implementación puramente frontend (rama/PR normal). Sin cambios de esquema ni de API: desplegar y listo.
  2. Rollback: revert del deploy (Netlify) — el backend nunca cambia de forma durante este cambio.

Open Questions

  • ¿Querremos unshorten de enlaces acortados en el futuro? (Anotado como mejora; no bloquea.) -> mejora
  • ¿Autocompletado en la pantalla de Nombre reutilizando AutocompletarLocal? (Anotado como mejora futura; hoy input simple pre-rellenado.)

Extensión: pruebas automatizadas (añadida tras la implementación inicial)

La verificación manual (sección 5 de tasks) se complementa con una suite programática para poder regresionar sin navegador. Se decide:

D9 — Stack de pruebas ligado a Vite, sin dependencias de más

Decisión: vitest como runner (mismo ecosistema que el proyecto, sin Jest), jsdom + @testing-library/react para pruebas de componente (con // @vitest-environment jsdom por archivo) y @testing-library/dom como peer obligatorio. El fetch se simula con vi.stubGlobal — no se añade msw ni librerías de mock HTTP, en línea con la política de cero dependencias extra del proyecto. Alternativas: Jest + jsdom — duplicaría configuración fuera del ecosistema Vite; msw — potente pero innecesario para el alcance actual.

D10 — Integración con el backend local: supertest + BD en memoria

Decisión: refactor mínimo y sin cambio de comportamiento en server/: server/index.js exporta app y solo escucha cuando se ejecuta como script (node server/index.js), y server/db.js admite process.env.LOCALESP_DB (incluido :memory:) como ruta de la base de datos. Las pruebas de integración usan supertest contra la app Express con SQLite en memoria: totalmente offline y deterministas, sin puerto real. Alternativas: levantar el servidor en un puerto efímero y hacer fetch real — más frágil y lento; mockear la BD — dejaría de ser integración.

D11 — Integración con Nominatim real: suite opt-in respetuosa con el rate-limit

Decisión: suite separada ejecutada con npm run test:integration (config propia) que hace llamadas reales a Nominatim: sondea la conectividad en beforeAll y se auto-salta (describe.skipIf) sin red; pocos casos secuenciales con ~1 req/s para respetar la política de uso. La suite rápida (npm test) queda 100% offline para CI. Alternativas: grabar/replicar respuestas — mantendría los fixtures sincronizados con la API real; llamadas reales en la suite por defecto — haría el CI dependiente de un servicio externo.

D12 — Alias de nombres cooficiales de provincias (endurecimiento)

Al escribir las pruebas surgió que respuestas de Nominatim en euskera/catalán/gallego (Bizkaia, Girona, Lleida, Ourense, Illes Balears, Asturies) no casaban con el listado canónico. ProveedorUbicacion.emparejarProvincia gana un mapa pequeño de alias normalizados → provincia canónica, cubierto por pruebas unitarias.