Files
Antoni Nuñez Romeu d68463548f
Run Tests on Branches / Detect Changes (push) Successful in 19s
Run Tests on Branches / Backend Tests (push) Successful in 2m46s
Run Tests on Branches / PIP Platform Tests (push) Has been skipped
Run Tests on Branches / Frontend Tests (push) Successful in 2m24s
Run Tests on Branches / Frontend Mobile Tests (push) Has been skipped
Run Tests on Branches / Parapharmacy API Tests (push) Has been skipped
Patentes internacionales + Forgot Pwd
2026-08-18 11:01:54 +02:00

22 KiB
Raw Permalink Blame History

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)

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):

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:

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:

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)

  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

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"):
    <button
      className={`open-now-toggle ${openNow ? 'active' : ''}`}
      onClick={() => setOpenNow(o => !o)}
      aria-pressed={openNow}
    >
      {openNow ? '🟢 Abiertas ahora' : '⏱ Mostrar solo abiertas ahora'}
    </button>
    
  3. Mover la lógica de displayedPharmacies al nuevo hook usePharmacyFilters(pharmacies, { sortByDistance, userPosition, openNow }).
  4. Pasar displayedPharmacies a <PharmacyMap /> y <PharmacyList /> 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):

<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 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:
    '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

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)

  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=<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.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.