# Plan — Horarios de farmacias, filtro "Abiertas Ahora" y farmacias 24h > **TL;DR** > - **El parser OSM ya existe** (`apps/API/opening-hours-osm.js`) y cubre `24/7`, rangos, splits, wrap-around y días cerrados. Su output (`["00:00", "24:00"]` para 24/7) ya entra en BD; lo que falta es **interpretar bien `24:00`**, centralizar el cálculo "abierto/24h" en el backend, exponer un endpoint "isOpenNow" y añadir el filtro en la UI. > - Tres pasos incrementales, cada uno verificable de forma aislada. Ninguno rompe lo que ya hay: el campo `opening_hours` se conserva como JSON string y el admin ya lo edita. > - **TDD obligatorio** (red-green-refactor, ≥80% cobertura en lo que se añada) por la regla global de testing. --- ## Estado actual (lo que ya tenemos) | Pieza | Ubicación | Estado | |-------|-----------|--------| | Parser OSM → JSON `{day:[open,close] \| null}` | `apps/API/opening-hours-osm.js` | Hecho + tests en `apps/backend/__tests__/opening-hours-osm.test.js` | | Almacenamiento `pharmacies.opening_hours TEXT` | `apps/backend/server.js` (`initDatabase`, `serializeOpeningHours`) | Hecho (PG + SQLite con migración) | | Ingestión desde OSM, n8n webhook y URL openData | `apps/API/*`, `apps/backend/farmacias-webhook-import.js` | Hecho — los tres caminos ya parsean horarios | | `getOpenStatus()` frontend | `apps/frontend/src/utils/hours.js` | Parcial: NO trata `24:00` como 24h (lo trata como "medianoche", farmacia cerrada); usa idioma hardcoded (es) en labels | | Editor de horarios en admin | `apps/frontend/src/components/admin/PharmacyManagement.jsx` | Hecho (filas Lun–Dom, open/close, toggle "Cerrado") | | Badge "Abierto / Cerrado" en lista | `apps/frontend/src/components/PharmacyList.jsx` | Hecho vía `getOpenStatus()` | | Filtro "Abiertas Ahora" | — | **No existe** | | Etiqueta "24h" en lista/mapa | — | **No existe** | | Endpoint que devuelva estado calculado en servidor | — | **No existe** (todo se calcula en cliente) | **Diagnóstico clave:** 1. `parseOsmOpeningHours('24/7')` produce `["00:00", "24:00"]` para todos los días. `getOpenStatus()` en frontend llama `parseHM("24:00")` → `24*60+0 = 1440`. Como `nowMins < 1440` casi siempre es `true`, nunca se evalúa el caso 24h. Necesita un caso explícito. 2. `getOpenStatus()` calcula "ahora" en el navegador. Si el usuario cambia de zona horaria o el servidor de test no está en hora local, el resultado puede no coincidir con el esperado. 3. `opening_hours` se devuelve crudo en `/api/medicines/:nregistro/pharmacies` y `/api/pharmacies`. El frontend debe parsearlo cada vez (ya lo hace, pero sin tests frontend). --- ## Enfoque general 1. **Mover el cálculo de "está abierta / es 24h" al backend**, exponerlo en un único helper puro y testable. Devolver ese dato precalculado en los endpoints públicos para que el frontend no tenga que duplicar la lógica ni parchear el caso `24:00`. 2. **Filtrar en cliente** (no en servidor): el dataset de farmacias por búsqueda es pequeño (decenas, no miles), y el filtro debe combinarse con "ordenar por distancia". Mantener la lógica en un solo reducer/hook. 3. **24h como estado derivado** del JSON, no como columna nueva: si todos los días son `["00:00", "24:00"]`, la farmacia es 24h. Evita migración, evita sincronización. 4. **i18n primero**: mover los strings hardcoded de `hours.js` y `PharmacyList.jsx` a los locales (`es.js`, `ca.js`). --- ## Paso 1 — Helper de estado abierto/24h en el backend (TDD) > **Objetivo:** un solo módulo `apps/backend/src/hours.js` con `isOpenNow(rawHours, now)` y `isAlwaysOpen(rawHours)` que sea la fuente de verdad. Tests Jest con casos explícitos para `24:00`, medianoche cruzando, splits (no usados en almacenamiento, pero defensivo), días cerrados. ### 1.1 Tests primero (RED) **`apps/backend/__tests__/hours.test.js`** (nuevo) ``` describe('isOpenNow') ✓ returns null when raw is null/empty ✓ returns null when JSON is malformed ✓ open window covers now → { isOpen: true, kind: 'open', closesAt: '21:00' } ✓ before opening today → { isOpen: false, kind: 'before-open', opensAt: '09:00' } ✓ after closing today but opens tomorrow → { isOpen: false, kind: 'after-close', nextOpen: { day: 'mon', time: '09:00' } } ✓ closed all week → { isOpen: false, kind: 'closed', nextOpen: null } ✓ 24/7 (["00:00","24:00"]) → { isOpen: true, kind: '24h' } ✓ 24:00 close at 23:59:59 → open; at 24:00:00 → close, nextOpen: tomorrow ✓ midnight-crossing range: mon ["22:00","02:00"] — at 01:00 monday → open (special-case: subtract 1 day from now, check sunday range) — implement: if closeMins <= openMins, treat the range as [open, close+24h]; check nowMins against that ✓ all days closed → kind 'closed' describe('isAlwaysOpen') ✓ all 7 days ["00:00","24:00"] → true ✓ one day different → false ✓ one day null → false ✓ null input → false ``` **Aceptación:** `npm test --workspace=farma-clic-backend` corre los nuevos tests y todos pasan **antes** de tocar otros archivos. ### 1.2 Implementación (GREEN) **`apps/backend/src/hours.js`** (nuevo) ```js const DAYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']; function parse(raw) { if (!raw) return null; if (typeof raw === 'object') return raw; try { return JSON.parse(raw); } catch { return null; } } function toMin(hm) { // accept "24:00" as 1440 only when minutes == 0 const [h, m] = String(hm).split(':').map(Number); if (!Number.isFinite(h) || !Number.isFinite(m)) return null; if (h === 24 && m === 0) return 1440; if (h < 0 || h > 24 || m < 0 || m >= 60) return null; return h * 60 + m; } export function isAlwaysOpen(rawHours) { const h = parse(rawHours); if (!h) return false; for (const d of DAYS) { const r = h[d]; if (!Array.isArray(r) || r.length !== 2) return false; if (r[0] !== '00:00' || r[1] !== '24:00') return false; } return true; } export function isOpenNow(rawHours, now = new Date()) { const h = parse(rawHours); if (!h) return null; if (isAlwaysOpen(h)) { return { isOpen: true, kind: '24h' }; } const day = DAYS[now.getDay()]; const range = h[day]; if (!Array.isArray(range) || range.length !== 2) { return { isOpen: false, kind: 'closed', nextOpen: findNextOpen(h, now) }; } const openM = toMin(range[0]); const closeM = toMin(range[1]); if (openM == null || closeM == null) return null; const nowM = now.getHours() * 60 + now.getMinutes(); // Midnight-crossing: close <= open means the range spans past 24:00. if (closeM <= openM) { if (nowM >= openM) return { isOpen: true, kind: 'open', closesAt: range[1] }; // before open today — maybe it's open from yesterday's range const yestKey = DAYS[(now.getDay() + 6) % 7]; const yest = h[yestKey]; if (Array.isArray(yest)) { const yOpen = toMin(yest[0]); const yClose = toMin(yest[1]); if (yOpen != null && yClose != null && yClose <= yOpen && nowM < yClose) { return { isOpen: true, kind: 'open', closesAt: yest[1] }; } } return { isOpen: false, kind: 'before-open', opensAt: range[0], nextOpen: null }; } if (nowM < openM) { return { isOpen: false, kind: 'before-open', opensAt: range[0], nextOpen: null }; } if (nowM >= closeM) { return { isOpen: false, kind: 'after-close', nextOpen: findNextOpen(h, now) }; } return { isOpen: true, kind: 'open', closesAt: range[1] }; } function findNextOpen(h, now) { for (let off = 1; off <= 7; off++) { const key = DAYS[(now.getDay() + off) % 7]; const r = h[key]; if (Array.isArray(r) && r.length === 2) { return { day: key, time: r[0] }; } } return null; } ``` ### 1.3 Refactor (IMPROVE) - Extraer `findNextOpen` como `export` y reusarlo en `apps/frontend/src/utils/hours.js` (vía copia local por ahora — son proyectos independientes). No mover al `apps/API/` para no introducir regresiones en el parser OSM, que ya está en producción. - Añadir `coverage` config al `jest.config.js` para exigir 100% en `src/hours.js` (es un módulo puro, sin dependencias). **Verificación (regla de evidence):** ```bash npm test --workspace=farma-clic-backend -- --testPathPattern=hours # Esperado: 10/10 tests pasados, coverage del nuevo módulo = 100% ``` --- ## Paso 2 — Exponer el estado calculado en los endpoints públicos > **Objetivo:** que `/api/medicines/:nregistro/pharmacies` y `/api/pharmacies` devuelvan `is_open` y `is_24h` precalculados, basados en la hora del **servidor**. Esto fija una única fuente de verdad y elimina el bug del frontend con `24:00`. ### 2.1 Tests primero (RED) **Ampliar `apps/backend/__tests__/server.test.js`** (o crear `pharmacy-hours-endpoint.test.js`): ``` GET /api/medicines/:nregistro/pharmacies ✓ when DB has pharmacy with opening_hours = 24/7 JSON → response[0].is_open === true, is_24h === true ✓ when DB has pharmacy with closed day → response[0].is_open === false, is_24h === false ✓ when DB has pharmacy with null opening_hours → response[0].is_open === null, is_24h === false ``` Mockear `userDbAll` o sembrar una BD en memoria antes de cada test (ya se hace en `server.test.js` — seguir el mismo patrón). ### 2.2 Implementación (GREEN) En `apps/backend/server.js`, en ambos endpoints (`/api/medicines/:id/pharmacies` y `/api/products/:source/:id/pharmacies` y `/api/pharmacies`), mapear las filas para añadir dos campos: ```js import { isOpenNow, isAlwaysOpen } from './src/hours.js'; // tras obtener `pharmacies`: const enriched = pharmacies.map(p => { let isOpen = null; let is24h = false; if (p.opening_hours) { const status = isOpenNow(p.opening_hours); isOpen = status ? status.isOpen : null; is24h = isAlwaysOpen(p.opening_hours); } return { ...p, is_open: isOpen, is_24h: is24h }; }); res.json(enriched); ``` Crear un helper local `enrichPharmacy(row)` para no duplicarlo tres veces. ### 2.3 Refactor (IMPROVE) - Si el rendimiento fuera problema (no lo es con <1000 filas), cachear `isOpenNow` con un TTL de 60s por minuto actual. Por ahora se calcula en cada request; dejar un comentario `// TODO(cache): si el dataset crece, cache por minuto`. **Verificación:** ```bash npm test --workspace=farma-clic-backend # Esperado: tests existentes verdes + los nuevos verdes # Manual: curl a /api/medicines//pharmacies y verificar is_open en JSON ``` --- ## Paso 3 — Soporte nativo de farmacias 24h en admin y selector de horarios > **Objetivo:** que el admin pueda marcar una farmacia como "24h" con un toggle que rellene los 7 días con `00:00–24:00`, y que el editor manual respete el shape exacto del parser OSM. ### 3.1 Tests primero (RED) **`apps/frontend/src/components/admin/PharmacyManagement.test.jsx`** (nuevo, con `@testing-library/react` si está disponible, si no: tests de unidad sobre los helpers puros) Tests del helper puro `hoursToDraft(raw)` y `draftToHours(draft)` (extraídos de `PharmacyManagement.jsx`): ``` hoursToDraft ✓ 24/7 JSON → all 7 days with closed: false, open: '00:00', close: '24:00' ✓ normal hours → mirror ✓ null → all days with closed: true, open: '09:00', close: '21:00' (default) draftToHours ✓ 24/7 draft (all days closed:false, 00:00, 24:00) → all 7 days ["00:00","24:00"] ✓ mixed → matches input ✓ all closed → null ``` ### 3.2 Refactor del admin (IMPROVE para testabilidad) 1. Mover `emptyHoursDraft`, `hoursToDraft`, `draftToHours` a `apps/frontend/src/utils/hours.js` (ya existe) y exportarlos. 2. En `PharmacyManagement.jsx`, importar desde el helper. Esto los hace testables sin renderizar el componente. 3. Añadir **toggle "24 horas"** encima del fieldset de horarios. Estado: `is24h`. Cuando se active, poblar `hoursDraft` con `00:00/24:00` en todos los días y `closed: false`. Cuando se desactive, volver a `emptyHoursDraft()` (pregunte `confirm()` antes de descartar). 4. CSS: el toggle debe ser visible y mostrar el estado actual. Estilo coherente con el resto del admin (`.hours-row--always-open`). ### 3.3 Verificación ```bash npm test --workspace=farma-clic-frontend # Esperado: tests verdes del helper + sin regresión en PharmacyList # Manual: crear farmacia 24h desde admin → ver en /api/pharmacies que is_24h=true ``` --- ## Paso 4 — Filtro "Abiertas Ahora" en la vista pública > **Objetivo:** un toggle justo al lado del botón "Ordenar por distancia". Cuando esté activo, la lista y el mapa solo muestran farmacias con `is_open === true` (o `is_24h === true`). Si una farmacia no tiene horarios, se muestra siempre (no se penaliza al admin por no haber editado). ### 4.1 Tests primero (RED) **`apps/frontend/src/views/PublicView.test.jsx`** (nuevo si no existe) o tests sobre un hook puro extraído. Refactor sugerido: extraer un hook `usePharmacyFilters(pharmacies, { sortByDistance, userPosition, openNow })` que devuelva la lista filtrada y ordenada. Eso permite testearlo sin renderizar. ``` usePharmacyFilters ✓ openNow=false → todas las farmacias ✓ openNow=true → solo las con is_open === true ✓ openNow=true + pharmacy con opening_hours=null → incluida (no se filtra) ✓ openNow=true + is_24h=true → incluida ✓ sortByDistance=true → orden por km ascendente ✓ sortByDistance=true sin userPosition → orden estable (no rompe) ✓ sortByDistance=true + pharmacy sin lat/lng → al final ``` ### 4.2 Implementación (GREEN) **`apps/frontend/src/views/PublicView.jsx`** 1. Nuevo state: `const [openNow, setOpenNow] = useState(false);` 2. Nuevo botón en `.pharmacy-controls` (donde está "Ordenar por distancia"): ```jsx ``` 3. Mover la lógica de `displayedPharmacies` al nuevo hook `usePharmacyFilters(pharmacies, { sortByDistance, userPosition, openNow })`. 4. Pasar `displayedPharmacies` a `` y `` igual que ahora. 5. Si el filtro deja 0 resultados: mostrar mensaje "Ninguna farmacia abierta ahora — desactiva el filtro para ver todas". ### 4.3 Visualización del badge 24h En `apps/frontend/src/components/PharmacyList.jsx`, dentro de `PharmacyCard`, añadir (cuando `pharmacy.is_24h === true`): ```jsx 24h ``` al lado del nombre, antes de la distancia. En el popup de `PharmacyMap.jsx` (`apps/frontend/src/components/PharmacyMap.jsx`): ```jsx {pharmacy.is_24h && 24h} ``` ### 4.4 Verificación ```bash npm test --workspace=farma-clic-frontend # Manual: # 1. Crear 2 farmacias: una 9-14h (cerrada ahora si son las 17h), otra 24h # 2. Buscar un medicamento enlazado a las dos # 3. Pulsar "Abiertas ahora" → solo aparece la 24h # 4. Desactivar filtro → aparecen las dos ``` --- ## Paso 5 — i18n de los labels de horario > **Objetivo:** mover los strings hardcoded de `apps/frontend/src/utils/hours.js` ("Abierto · Cierra a las X", "Cerrado · Abre mañana…") a los locales `es.js` y `ca.js`. Idem para el nuevo "Abiertas ahora". ### 5.1 Tests / Tareas 1. Añadir claves en `apps/frontend/src/i18n/locales/es.js`: ```js 'pharmacy.openNow': 'Abierto · Cierra a las {{time}}', 'pharmacy.closedAllDay': 'Cerrado', 'pharmacy.opensAt': 'Cerrado · Abre a las {{time}}', 'pharmacy.opensTomorrow': 'Cerrado · Abre mañana a las {{time}}', 'pharmacy.opensDay': 'Cerrado · Abre el {{day}} a las {{time}}', 'pharmacy.alwaysOpen': 'Abierto 24h', 'pharmacy.filterOpenNow': 'Mostrar solo abiertas ahora', 'pharmacy.filterOpenNowActive': 'Solo abiertas ahora', 'pharmacy.badge24h': '24h', 'pharmacy.filterNoResults': 'Ninguna farmacia abierta ahora. Desactiva el filtro para ver todas.' ``` 2. Replicar en `ca.js` con las traducciones catalanas correspondientes. 3. Refactor de `getOpenStatus()` para que devuelva `{ status, labelKey, labelParams }` en vez de strings listos, y que `PharmacyList.jsx` los pase por `t()`. Mantener compatibilidad: si no se puede traducir, devolver el string por defecto (defensivo). ### 5.2 Verificación ```bash npm test --workspace=farma-clic-frontend # Manual: cambiar idioma a catalán (ca) y verificar badges ``` --- ## Paso 6 — Verificación end-to-end (cierre del plan) > Antes de declarar el plan completo, ejecutar el set de verificaciones del repo (regla "evidence-based completion" — sin evidencia, no se afirma éxito). ### 6.1 Backend ```bash # Linter cd apps/backend && npx eslint . # Tests npm test --workspace=farma-clic-backend # Cobertura ≥ 80% en modules nuevos: # apps/backend/src/hours.js → 100% # apps/backend/__tests__/hours.test.js → 100% # endpoints pharmacy hours tests → ≥ 80% ``` ### 6.2 Frontend ```bash # Linter cd apps/frontend && npx eslint . # Tests npm test --workspace=farma-clic-frontend # Cobertura ≥ 80% en: # apps/frontend/src/utils/hours.js (tras mover helpers de admin) → 100% # apps/frontend/src/views/PublicView.jsx → ≥ 80% # apps/frontend/src/components/PharmacyList.jsx → ≥ 80% # apps/frontend/src/components/admin/PharmacyManagement.jsx → ≥ 80% ``` ### 6.3 Integración real (Docker) ```bash docker compose up --build -d docker compose exec backend npm run seed # crea 3 farmacias: 24h, 9-21h, fines de semana curl -s http://localhost:3001/api/pharmacies | jq '.[0] | {name, is_open, is_24h}' # Esperado: 24h farmacia con is_open=true, is_24h=true ``` ### 6.4 E2E manual (smoke) 1. Abrir `http://localhost:4000`, buscar "ibuprofeno", seleccionar. 2. Verificar: las 3 farmacias aparecen con su badge de estado correcto. 3. Pulsar "Mostrar solo abiertas ahora" → solo las abiertas. 4. Pulsar "Ordenar por distancia" → reordena, badge 24h visible. 5. Abrir el popup del mapa de la 24h → etiqueta "24h" visible. ### 6.5 Regresión - `apps/backend/__tests__/opening-hours-osm.test.js` → sigue verde (no tocamos el parser). - `apps/backend/__tests__/server.test.js` → sigue verde; los nuevos endpoints se añaden sin romper los existentes. - `apps/frontend/src/components/PharmacyList.jsx` actual: si `is_24h` es `undefined` (respuesta legacy de la BD sin enriquecer), debe renderizarse igual que antes (compatibilidad). --- ## Orden de implementación recomendado | Paso | Tiempo estimado | Bloquea | |------|----------------|---------| | 1. Helper backend `src/hours.js` (TDD) | 1.5 h | Pasos 2 y 4 | | 2. Enriquecer endpoints públicos (TDD) | 1 h | Paso 4 (frontend necesita el campo) | | 3. Admin 24h (refactor + test) | 1 h | — (paralelo a 4) | | 4. Filtro "Abiertas ahora" + badge 24h (TDD) | 2 h | — | | 5. i18n (es + ca) | 0.5 h | — | | 6. Verificación + cobertura | 0.5 h | Cierre | | **Total** | **~6.5 h** | | Pasos 1 y 3 pueden hacerse en paralelo. Paso 5 puede entramparse con 4. --- ## Riesgos y mitigaciones | Riesgo | Mitigación | |--------|-----------| | `parseOsmOpeningHours` cambia en producción y rompe el shape | Tests existentes en `opening-hours-osm.test.js` ya cubren los casos. No tocar. | | Hora del servidor difiere del navegador del usuario | Devolver `server_now` en la respuesta (`{ is_open, server_now, server_tz }`) para que la UI pueda mostrar "estado según la hora del servidor". Si en el futuro se quiere client-side, aceptar `?at=` y calcular contra ese timestamp. | | Performance: 1000 farmacias × 7 días de parseo por request | Trivial. Si crece, cachear por minuto (ver 2.3). | | `24:00` no es `HH:mm` válido ISO y rompe parsers terceros | Mantener el shape interno pero, al exportar, escribir un campo derivado `is_24h` (booleano) en el JSON público. El shape interno sigue siendo el mismo. | | i18n incompleta en ca.js | Mantener strings en `es.js` como fallback. Si una clave falta en `ca.js`, el `useTranslation` ya cae al idioma por defecto. | --- ## Archivos a crear / modificar (resumen) **Crear:** - `apps/backend/src/hours.js` - `apps/backend/__tests__/hours.test.js` - `apps/backend/__tests__/pharmacy-hours-endpoint.test.js` - `apps/frontend/src/views/PublicView.test.jsx` (o `usePharmacyFilters.test.js` si se extrae el hook) - `apps/frontend/src/components/admin/PharmacyManagement.test.jsx` (si testing-library está disponible; si no, tests sobre los helpers puros) **Modificar:** - `apps/backend/server.js` — enriquecer `pharmacies` en 3 endpoints; importar `src/hours.js` - `apps/frontend/src/views/PublicView.jsx` — state `openNow`, hook de filtros, botón en `.pharmacy-controls` - `apps/frontend/src/components/PharmacyList.jsx` — badge 24h, pasar por i18n - `apps/frontend/src/components/PharmacyMap.jsx` — badge 24h en popup - `apps/frontend/src/components/admin/PharmacyManagement.jsx` — toggle 24h, importar helpers - `apps/frontend/src/utils/hours.js` — mover `emptyHoursDraft`/`hoursToDraft`/`draftToHours` desde admin; añadir `isAlwaysOpen`; cambiar `getOpenStatus` para devolver labelKey/params - `apps/frontend/src/i18n/locales/es.js` — nuevas claves - `apps/frontend/src/i18n/locales/ca.js` — nuevas claves **No tocar:** - `apps/API/opening-hours-osm.js` (parser OSM en producción) - `apps/backend/farmacias-webhook-import.js` (ya parsea correctamente) --- ## Preguntas abiertas (a resolver antes de implementar) 1. **¿Soportar farmacias que cruzan medianoche?** (ej. `mon ["22:00","02:00"]`). El shape actual lo permite pero `getOpenStatus` actual no lo trata. **Propuesta:** sí, implementar el caso en el helper del backend (tests ya incluidos en 1.1). 2. **¿Borrar `getOpenStatus` del frontend o mantenerlo como fallback?** **Propuesta:** mantenerlo, pero hacer que la UI use `pharmacy.is_open` y `pharmacy.is_24h` cuando estén presentes (del backend) y solo caiga al cálculo local si faltan. Sin duplicación de lógica. 3. **¿Mostrar farmacia sin horarios cuando el filtro "abiertas ahora" está activo?** **Propuesta:** sí, mostrarlas (asumimos que no sabemos = no filtramos). Documentarlo en la UI. 4. **¿Refrescar el estado cada minuto?** (por si la lista queda abierta mucho tiempo y cambia el estado). **Propuesta:** no por ahora; si la lista se refresca al cambiar de medicamento, basta. Si se observa uso prolongado sin refresco, añadir `setInterval` de 60s para recalcular. Cuando confirmes el plan (o pidas ajustes) lo paso a implementación siguiendo TDD estricto.