Files
localesp/openspec/changes/pasarela-proponer-local/tasks.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

11 KiB

1. Proveedores de ubicación (núcleo extensible)

  • 1.1 Crear src/utils/ubicacion/ProveedorUbicacion.js: clase base con el contrato documentado (id, detecta(entrada), extrae(entrada) que lanza "sin implementar") y helpers compartidos (normalizar(), emparejarProvincia() contra PROVINCIAS, fetch a Nominatim con cabeceras y manejo de errores que devuelve null en fallo)
  • 1.2 Implementar proveedorCoordenadas.js: detecta pares "lat, lng" (coma o punto, signo, espacios) y devuelve { proveedor, lat, lng }
  • 1.3 Implementar proveedorGoogleMaps.js: detecta URLs de Google Maps y extrae coordenadas de los formatos @lat,lng, !3d…!4d… y ?q=lat,lng (migrando parsearEnlaceGoogleMaps de geo.js)
  • 1.4 Implementar proveedorWaze.js: detecta waze.com y extrae de ll=lat,lng; si solo hay q=texto, delega la resolución en la búsqueda de Nominatim marcando el proveedor como waze
  • 1.5 Implementar proveedorOSM.js: detecta openstreetmap.org y extrae de mlat/mlon y del fragmento #map=zoom/lat/lng
  • 1.6 Implementar proveedorNominatim.js: búsqueda por texto (/search, migrando geocodificarDireccion) como fallback de dirección, y método reverse(lat, lng) (/reverse con addressdetails=1&namedetails=1) que devuelve { nombre, provincia, direccion } con provincia normalizada al listado canónimo (vacía si no casa)
  • 1.7 Crear src/utils/ubicacion/index.js: array PROVEEDORES ordenado por prioridad (coordenadas → Google → Waze → OSM → Nominatim), función resolverUbicacion(entrada) que usa el primer detecta que acepte, y inferirDesdeCoords(lat, lng) que envuelve el reverse de Nominatim con manejo de errores
  • 1.8 Ajustar src/utils/geo.js: conservar exportaciones usadas por LocalCard/mapa (enlaceGoogleMapsDesdeLocal, enlaceWazeDesdeLocal, distanciaKm, formatearDistancia, buscarLugares) y reubicar lo migrado en los proveedores, sin romper imports existentes

2. Pantallas de la pasarela

  • 2.1 Crear PasoUbicacion.jsx: input universal con debounce (~600ms) que llama a resolverUbicacion; al resolver muestra chip con el proveedor detectado y coordenadas; llama a inferirDesdeCoords y pre-rellena nombre/provincia/direccion en el form; muestra avisos de entrada no reconocida o error de red; "Siguiente" solo habilitado con resolución válida
  • 2.2 Crear PasoNombre.jsx y PasoProvincia.jsx: inputs simples pre-rellenados con lo inferido (editables); provincia como select del listado PROVINCIAS; vacíos si la inferencia falló
  • 2.3 Crear PasoCategoria.jsx y PasoSubcategoria.jsx: selects con CATEGORIAS/NOMBRES_CATEGORIAS; subcategoría opcional (permite continuar sin elegir), solo accesible tras elegir categoría
  • 2.4 Crear PasoResumen.jsx: resumen legible de todos los datos, checkbox de aceptación de privacidad (enlace al ModalPrivacidad existente) y botón "Enviar propuesta" deshabilitado sin aceptación; renderiza avisos de palabra prohibida y de duplicado (con "Enviar de todos modos"/"Revisar datos") dentro del modal

3. Modal de la pasarela y FAB

  • 3.1 Crear ModalPasarela.jsx: overlay siguiendo el patrón de ModalPrivacidad (role="dialog", aria-modal, cierre por fondo/✕/Escape), cabecera con título e indicador de progreso, cuerpo con la pantalla activa y pie con botones Atrás/Siguiente; en móvil panel a ancho completo y casi todo el alto (hook useEsMovil con matchMedia), en escritorio centrado maxWidth≈480px; foco inicial en el primer control de cada pantalla
  • 3.2 Crear BotonFlotante.jsx: FAB fijo abajo a la derecha (z-index < modal, padding-bottom: env(safe-area-inset-bottom)), con tooltip propio al hover/foco ("Añade un nuevo local al directorio") posicionado a la izquierda y aria-label en el botón
  • 3.3 Integrar en App.jsx: eliminar el botón "Proponer local" de la cabecera y el bloque de formulario en línea; renderizar BotonFlotante (abre el modal) y ModalPasarela; sustituir mostrarForm por estado pasarelaAbierta; actualizar el texto del pie "Proponlo arriba" para referirse al botón flotante

4. Envío de la propuesta

  • 4.1 Actualizar FORM_VACIO y el flujo de envío en App.jsx: quitar descripcion y puntuacion del formulario y del payload de enviarPropuesta; formValido sin requisito de puntuación; conservar filtro de palabras, comprobarDuplicado, fallback de coordenadas por provincia y confirmación enviado tras cerrar el modal; estados "Comprobando…/Enviando…" dentro del modal

5. Verificación

  • 5.1 Probar manualmente los formatos de entrada del campo universal: enlace Google Maps (@/, !3d!4d, ?q=), Waze (ll= y q=), OSM (mlat/mlon y #map=), coordenadas sueltas y dirección en texto; verificar aviso con entrada no reconocida

    Verificado a nivel de módulo contra los proveedores reales (con red): los 17 casos pasan, incluidos Waze q= delegando en Nominatim y entradas no reconocidas devolviendo null (dispara el aviso del paso). Falta el clic-through visual en navegador.

  • 5.2 Verificar inferencia: nombre y provincia pre-rellenados desde coordenadas de varias comunidades (normalización de acentos), y flujo completo de propuesta sin comentario ni puntuación con aviso de duplicado y de palabra prohibida dentro del modal

    Verificado npm test (82/82: 51 unitarias + 31 componente) y npm run test:integration (10/10 con red real). Además se cubrió programáticamente gran parte del contenido: avisos de duplicado/palabra prohibida en PasoResumen.spec.jsx, flujo de propuesta sin comentario/puntuación contra el backend real en apiLocal.spec.js, y normalización multi-comunidad en nominatim.spec.js. Pendiente solo el clic-through visual en navegador con backend levantado.

  • 5.3 Verificar responsive y accesibilidad: FAB visible sin tapar contenido en viewport estrecho y con barra PWA; tooltip en hover y foco; modal navegable con teclado, Escape cierra, foco contenido; enviar aceptación de privacidad obligatoria

    Cobertura programática añadida: tooltip en foco/hover (BotonFlotante.spec.jsx), Escape/✕/foco contenido/Tab wrap (ModalPasarela.spec.jsx), privacidad obligatoria (PasoResumen.spec.jsx). Pendiente solo la verificación visual en dispositivo/viewport estrecho y barra PWA (safe-area).

  • 5.4 Verificar no-regresión: tarjetas, mapa, favoritos, reportes y panel admin siguen funcionando (imports de geo.js intactos); npm run build sin errores

    npm run build OK (65 módulos); imports de geo.js intactos en LocalCard, AdminPanel, App y AutocompletarLocal; sin referencias muertas (mostrarForm/CampoUbicacion/formValido).

6. Infraestructura de pruebas

  • 6.1 Añadir dependencias y configuración de pruebas: vitest + jsdom + @testing-library/react (+ @testing-library/dom) + supertest como devDeps; vitest.config.mjs (unitarias/componente, offline) y vitest.config.integration.mjs; scripts npm test, npm run test:watch, npm run test:integration
  • 6.2 Hacer el servidor comprobable sin cambiar su comportamiento: ruta de BD configurable (process.env.LOCALESP_DB, admite :memory:) y server/index.js exportando app escuchando solo al ejecutarse como script (supertest sin puerto)

7. Pruebas unitarias (proveedores de ubicación)

  • 7.1 ProveedorUbicacion (base): normalizar(), emparejarProvincia() (acentos, mayúsculas, artículos, alias cooficiales, sin coincidencia → ""), extrae() de la base lanza "sin implementar", detecta() base no acepta nada, fetchNominatim devuelve null si fetch lanza o la respuesta no es ok
  • 7.2 proveedorCoordenadas: pares con coma y punto decimal, separador ,/;/espacio, signo, espacios; latitud fuera de rango → null; texto que no es un par → detecta false
  • 7.3 proveedorGoogleMaps: formatos @lat,lng, !3d…!4d…, ?q=lat,lng; prioridad @ sobre !3d!4d (comportamiento migrado de parsearEnlaceGoogleMaps); enlace acortado detecta pero extraenull; URL ajena → detecta false
  • 7.4 proveedorWaze: ll=lat,lng ok; ll malformado → null; q=texto delega en la búsqueda Nominatim (fetch simulado) marcando proveedor: "waze"; q sin resultados → null
  • 7.5 proveedorOSM: prioridad mlat/mlon sobre #map=; #map=zoom/lat/lng; sin ninguno → null
  • 7.6 proveedorNominatim: detecta rechaza URLs y textos cortos y acepta direcciones; desdeResultado normaliza provincia (Bizkaia→Vizcaya, A Coruña→La Coruña, desconocida→ ""); extrae sin resultados → null
  • 7.7 resolverUbicacion/inferirDesdeCoords: orden de prioridad de PROVEEDORES, fallback de texto, proveedor que lanza → se degrada y prueba el siguiente, entrada vacía → null; ETIQUETAS_PROVEEDOR cubre todos los ids registrados

8. Pruebas de componente (pasarela)

  • 8.1 PasoUbicacion: debounce ~600ms (fake timers, no resuelve antes), chip con proveedor detectado y coordenadas, aviso de entrada no reconocida, pre-relleno del form (nombre/provincia/dirección/lat/lng/enlace según proveedor), aviso cuando la inferencia inversa falla
  • 8.2 PasoNombre/PasoProvincia/PasoCategoria/PasoSubcategoria: pre-rellenados con lo inferido y editables; provincia como select de PROVINCIAS; subcategoría deshabilitada sin categoría y opcional (se puede continuar)
  • 8.3 PasoResumen: resumen con los datos del form; botón "Enviar propuesta" deshabilitado sin aceptar privacidad y habilitado al marcar; abre ModalPrivacidad vía enlace; avisos de palabra prohibida y de duplicado con "Enviar de todos modos"/"Revisar datos"; estados "Comprobando…/Enviando…"
  • 8.4 ModalPasarela: arranca en Ubicación; "Siguiente" deshabilitado sin dato válido y habilitado con él; "Atrás" conserva datos; Escape y ✕ cierran (onCerrar); foco inicial en el primer control de cada pantalla y contención de Tab dentro del modal
  • 8.5 BotonFlotante: aria-label descriptivo, tooltip oculto por defecto y visible al enfocar (y oculto al desenfocar), onClick al pulsar

9. Pruebas de integración (backends reales)

  • 9.1 API local con supertest + BD :memory:: POST /api/propuestas sin descripcion/puntuacion → 201 con defaults (puntuacion 0, estado pendiente); POST /api/check-duplicado detecta por nombre normalizado (acentos/mayúsculas) + misma provincia; nombre igual en otra provincia y lejos → no duplicado; GET /api/locales arranca limpio
  • 9.2 Nominatim real (suite opt-in npm run test:integration, auto-skip sin red, ≤1 req/s): búsqueda por dirección → coordenadas + provincia canónica; reverse de Madrid y Bilbao → provincia canónica; reverse fuera de España → provincia vacía