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)
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
# 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 (52–56px) 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.
|
||||
Reference in New Issue
Block a user