- 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
13 KiB
Diseño — Pasarela para proponer locales
Context
src/App.jsxes la única app en producción (PC y móvil):main.jsxsolo montaAppyAdminPanel;src/AppMovil.jsxes código muerto no referenciado. Por tanto "funcionar para PC y móvil" se resuelve haciendo el FAB y el modal responsive dentro deApp.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 contraserver/index.js. - El backend ya tolera la ausencia de
descripcion(columna nullable) ypuntuacion(?? 0al insertar), así que eliminarlos del cliente no exige cambios de API ni migraciones. - La extracción de coordenadas hoy es una única función
parsearEnlaceGoogleMapsensrc/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 ensrc/components/, utilidades puras ensrc/utils/, sin dependencias de UI externas. El patrón de modal existente esModalPrivacidad.jsx(overlayfixed+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
ProveedorUbicaciondefine el contrato documentado —id,detecta(entrada) → boolean,extrae(entrada) → Promise<ResultadoUbicacion|null>(la base lanzaError("sin implementar"), como una abstracta) — y helpers comunes: cabeceras/UA para Nominatim,normalizar()de textos yemparejarProvincia()contraPROVINCIAS(insensible a acentos/mayúsculas, reutilizando la lógica deAutocompletarLocal). - 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 arrayPROVEEDORESen orden de prioridad (coordenadas → Google → Waze → OSM → Nominatim texto) y devuelve el resultado del primero cuyodetectaacepte. 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 aNominatim 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/parsearEnlaceGoogleMapsdegeo.jsse reubican como implementación interna de los proveedores (se mantienegeo.jsexportando lo que usanLocalCardy el mapa: enlaces y distancias). - Errores de red: todo
fetchde proveedor va envuelto para devolvernull/{ 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) yz-indexinferior al modal; se prueba en viewport estrecho. - [Teclado móvil sube y tapa el modal] → modal con
heightflexible yoverflow-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
- Implementación puramente frontend (rama/PR normal). Sin cambios de esquema ni de API: desplegar y listo.
- 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.