Files
localesp/openspec/changes/pasarela-proponer-local/design.md
T
edgar.friendly 003b1e209f
deploy-branch / deploy (push) Failing after 6s
deploy-branch / teardown (push) Skipped
feat(propuesta): pasarela paso a paso con autodetección de ubicación y FAB
- Campo universal de ubicación (Google Maps/Waze/OSM/coordenadas/dirección)
  sobre proveedores extensibles (src/utils/ubicacion) con resolver e inferencia
  inversa Nominatim (nombre/provincia pre-rellenados, alias cooficiales)
- FAB flotante abajo a la derecha que abre la pasarela en un modal accesible
  (Escape, foco contenido, responsive); sin descripción ni puntuación;
  avisos de duplicado y filtro de palabras dentro del modal
- Aviso PWA reposicionado: tarjeta abajo a la izquierda en móvil,
  botón compacto arriba a la izquierda en PC (no tapa el FAB)
- Suite de pruebas: 88 unitarias/componente (vitest + RTL, offline) y
  10 de integración (supertest + SQLite :memory: y Nominatim real opt-in)
- CI: workflow de Gitea Actions con npm test + build en push/PR e
  integración manual
2026-08-16 01:35:02 +02:00

117 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 (`contienepalabrasProhibidas``comprobarDuplicado``guardarPropuesta`), 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.