22 KiB
Plan — Horarios de farmacias, filtro "Abiertas Ahora" y farmacias 24h
TL;DR
- El parser OSM ya existe (
apps/API/opening-hours-osm.js) y cubre24/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 bien24: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_hoursse 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:
parseOsmOpeningHours('24/7')produce["00:00", "24:00"]para todos los días.getOpenStatus()en frontend llamaparseHM("24:00")→24*60+0 = 1440. ComonowMins < 1440casi siempre estrue, nunca se evalúa el caso 24h. Necesita un caso explícito.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.opening_hoursse devuelve crudo en/api/medicines/:nregistro/pharmaciesy/api/pharmacies. El frontend debe parsearlo cada vez (ya lo hace, pero sin tests frontend).
Enfoque general
- 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. - 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.
- 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. - i18n primero: mover los strings hardcoded de
hours.jsyPharmacyList.jsxa 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.jsconisOpenNow(rawHours, now)yisAlwaysOpen(rawHours)que sea la fuente de verdad. Tests Jest con casos explícitos para24: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)
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
findNextOpencomoexporty reusarlo enapps/frontend/src/utils/hours.js(vía copia local por ahora — son proyectos independientes). No mover alapps/API/para no introducir regresiones en el parser OSM, que ya está en producción. - Añadir
coverageconfig aljest.config.jspara exigir 100% ensrc/hours.js(es un módulo puro, sin dependencias).
Verificación (regla de evidence):
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/pharmaciesy/api/pharmaciesdevuelvanis_openyis_24hprecalculados, basados en la hora del servidor. Esto fija una única fuente de verdad y elimina el bug del frontend con24: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:
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
isOpenNowcon 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:
npm test --workspace=farma-clic-backend
# Esperado: tests existentes verdes + los nuevos verdes
# Manual: curl a /api/medicines/<nregistro>/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)
- Mover
emptyHoursDraft,hoursToDraft,draftToHoursaapps/frontend/src/utils/hours.js(ya existe) y exportarlos. - En
PharmacyManagement.jsx, importar desde el helper. Esto los hace testables sin renderizar el componente. - Añadir toggle "24 horas" encima del fieldset de horarios. Estado:
is24h. Cuando se active, poblarhoursDraftcon00:00/24:00en todos los días yclosed: false. Cuando se desactive, volver aemptyHoursDraft()(pregunteconfirm()antes de descartar). - 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
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(ois_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
- Nuevo state:
const [openNow, setOpenNow] = useState(false); - Nuevo botón en
.pharmacy-controls(donde está "Ordenar por distancia"):<button className={`open-now-toggle ${openNow ? 'active' : ''}`} onClick={() => setOpenNow(o => !o)} aria-pressed={openNow} > {openNow ? '🟢 Abiertas ahora' : '⏱ Mostrar solo abiertas ahora'} </button> - Mover la lógica de
displayedPharmaciesal nuevo hookusePharmacyFilters(pharmacies, { sortByDistance, userPosition, openNow }). - Pasar
displayedPharmaciesa<PharmacyMap />y<PharmacyList />igual que ahora. - 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):
<span className="pharmacy-badge pharmacy-badge--24h" aria-label="Abierta 24 horas">24h</span>
al lado del nombre, antes de la distancia.
En el popup de PharmacyMap.jsx (apps/frontend/src/components/PharmacyMap.jsx):
{pharmacy.is_24h && <span className="map-badge-24h">24h</span>}
4.4 Verificación
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 localeses.jsyca.js. Idem para el nuevo "Abiertas ahora".
5.1 Tests / Tareas
- Añadir claves en
apps/frontend/src/i18n/locales/es.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.' - Replicar en
ca.jscon las traducciones catalanas correspondientes. - Refactor de
getOpenStatus()para que devuelva{ status, labelKey, labelParams }en vez de strings listos, y quePharmacyList.jsxlos pase port(). Mantener compatibilidad: si no se puede traducir, devolver el string por defecto (defensivo).
5.2 Verificación
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
# 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
# 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)
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)
- Abrir
http://localhost:4000, buscar "ibuprofeno", seleccionar. - Verificar: las 3 farmacias aparecen con su badge de estado correcto.
- Pulsar "Mostrar solo abiertas ahora" → solo las abiertas.
- Pulsar "Ordenar por distancia" → reordena, badge 24h visible.
- 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.jsxactual: siis_24hesundefined(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=<iso> 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.jsapps/backend/__tests__/hours.test.jsapps/backend/__tests__/pharmacy-hours-endpoint.test.jsapps/frontend/src/views/PublicView.test.jsx(ousePharmacyFilters.test.jssi 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— enriquecerpharmaciesen 3 endpoints; importarsrc/hours.jsapps/frontend/src/views/PublicView.jsx— stateopenNow, hook de filtros, botón en.pharmacy-controlsapps/frontend/src/components/PharmacyList.jsx— badge 24h, pasar por i18napps/frontend/src/components/PharmacyMap.jsx— badge 24h en popupapps/frontend/src/components/admin/PharmacyManagement.jsx— toggle 24h, importar helpersapps/frontend/src/utils/hours.js— moveremptyHoursDraft/hoursToDraft/draftToHoursdesde admin; añadirisAlwaysOpen; cambiargetOpenStatuspara devolver labelKey/paramsapps/frontend/src/i18n/locales/es.js— nuevas clavesapps/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)
- ¿Soportar farmacias que cruzan medianoche? (ej.
mon ["22:00","02:00"]). El shape actual lo permite perogetOpenStatusactual no lo trata. Propuesta: sí, implementar el caso en el helper del backend (tests ya incluidos en 1.1). - ¿Borrar
getOpenStatusdel frontend o mantenerlo como fallback? Propuesta: mantenerlo, pero hacer que la UI usepharmacy.is_openypharmacy.is_24hcuando estén presentes (del backend) y solo caiga al cálculo local si faltan. Sin duplicación de lógica. - ¿Mostrar farmacia sin horarios cuando el filtro "abiertas ahora" está activo? Propuesta: sí, mostrarlas (asumimos que no sabemos = no filtramos). Documentarlo en la UI.
- ¿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
setIntervalde 60s para recalcular.
Cuando confirmes el plan (o pidas ajustes) lo paso a implementación siguiendo TDD estricto.