Compare commits

2 Commits

Author SHA1 Message Date
Ichitux ddd8ae8537 Merge pull request 'feat: pharmacy hours status and open now filter' (#57) from feat/pharmacy-hours-status into main
Build & Push Docker Images / Detect Changes (push) Successful in 13s
Build & Push Docker Images / Backend Tests (push) Successful in 2m6s
Build & Push Docker Images / Parapharmacy API Tests (push) Has been skipped
Build & Push Docker Images / Pip Platform Tests (push) Has been skipped
Build & Push Docker Images / Frontend Tests (push) Successful in 1m58s
Build & Push Docker Images / Build Backend (push) Successful in 2m47s
Build & Push Docker Images / Build Parapharmacy API (push) Has been skipped
Build & Push Docker Images / Build Pip Platform (push) Has been skipped
Build & Push Docker Images / Build Frontend (push) Successful in 1m5s
Build & Push Docker Images / Deploy (push) Successful in 2m54s
Reviewed-on: #57
2026-07-27 12:45:08 +00:00
Antoni Nuñez Romeu e3e7d2f60b feat: pharmacy hours status and open now filter
Run Tests on Branches / Detect Changes (push) Successful in 17s
Run Tests on Branches / Backend Tests (push) Successful in 2m44s
Run Tests on Branches / Frontend Tests (push) Successful in 1m59s
Run Tests on Branches / Frontend Mobile Tests (push) Has been skipped
Run Tests on Branches / Parapharmacy API Tests (push) Has been skipped
Run Tests on Branches / PIP Platform Tests (push) Has been skipped
- Add is_open_now and is_always_open helpers in backend
- Add backend endpoint enrichment with is_open and is_24h fields
- Add client-side getOpenStatus with 24h, opens-at, opens-tomorrow support
- Add open-now filter toggle in PublicView and SearchView
- Add pharmacy no-hours fallback display
- Add sticky pharmacy controls on scroll
- Add admin hours editor with 24h toggle
- Add translations (es/ca) for all hour-related strings
- Add backend tests for hours logic and pharmacy endpoint
- Remove unused backup test files
2026-07-27 14:32:05 +02:00
18 changed files with 678 additions and 73 deletions
+158
View File
@@ -0,0 +1,158 @@
import { isOpenNow, isAlwaysOpen } from '../src/hours.js';
const HOURS_24_7 = {
mon: ['00:00', '24:00'],
tue: ['00:00', '24:00'],
wed: ['00:00', '24:00'],
thu: ['00:00', '24:00'],
fri: ['00:00', '24:00'],
sat: ['00:00', '24:00'],
sun: ['00:00', '24:00'],
};
const HOURS_NORMAL_WEEK = {
mon: ['09:00', '21:00'],
tue: ['09:00', '21:00'],
wed: ['09:00', '21:00'],
thu: ['09:00', '21:00'],
fri: ['09:00', '21:00'],
sat: ['09:00', '14:00'],
sun: null,
};
const HOURS_ALL_CLOSED = {
mon: null, tue: null, wed: null, thu: null, fri: null, sat: null, sun: null,
};
const HOURS_MIDNIGHT_CROSS = {
mon: ['22:00', '02:00'],
tue: ['09:00', '21:00'],
wed: null, thu: null, fri: null, sat: null, sun: null,
};
const at = (iso) => new Date(iso);
describe('isAlwaysOpen', () => {
test('all 7 days 00:00-24:00 → true', () => {
expect(isAlwaysOpen(HOURS_24_7)).toBe(true);
});
test('accepts JSON string', () => {
expect(isAlwaysOpen(JSON.stringify(HOURS_24_7))).toBe(true);
});
test('one day different → false', () => {
expect(isAlwaysOpen({ ...HOURS_24_7, sun: ['00:00', '23:59'] })).toBe(false);
});
test('one day null → false', () => {
expect(isAlwaysOpen({ ...HOURS_24_7, sun: null })).toBe(false);
});
test('null input → false', () => {
expect(isAlwaysOpen(null)).toBe(false);
expect(isAlwaysOpen('')).toBe(false);
expect(isAlwaysOpen(undefined)).toBe(false);
});
test('malformed JSON → false', () => {
expect(isAlwaysOpen('{not-json')).toBe(false);
});
});
describe('isOpenNow', () => {
test('null/empty input → null', () => {
expect(isOpenNow(null)).toBeNull();
expect(isOpenNow('')).toBeNull();
expect(isOpenNow(undefined)).toBeNull();
});
test('malformed JSON → null', () => {
expect(isOpenNow('{garbage')).toBeNull();
});
test('24/7 → { isOpen: true, kind: "24h" }', () => {
const s = isOpenNow(HOURS_24_7, at('2026-07-27T10:00:00'));
expect(s).toEqual({ isOpen: true, kind: '24h' });
});
test('24/7 at midnight → still 24h', () => {
const s = isOpenNow(HOURS_24_7, at('2026-07-27T00:00:00'));
expect(s).toEqual({ isOpen: true, kind: '24h' });
});
test('24/7 (JSON string) → kind 24h', () => {
const s = isOpenNow(JSON.stringify(HOURS_24_7), at('2026-07-27T15:00:00'));
expect(s).toEqual({ isOpen: true, kind: '24h' });
});
test('normal weekday at 10:00 (monday) → open, closesAt 21:00', () => {
// 2026-07-27 is a Monday (verify with date -d)
const s = isOpenNow(HOURS_NORMAL_WEEK, at('2026-07-27T10:30:00'));
expect(s).toEqual({ isOpen: true, kind: 'open', closesAt: '21:00' });
});
test('normal weekday at 21:30 (monday) → closed, nextOpen tuesday 09:00', () => {
const s = isOpenNow(HOURS_NORMAL_WEEK, at('2026-07-27T21:30:00'));
expect(s.isOpen).toBe(false);
expect(s.kind).toBe('after-close');
expect(s.nextOpen).toEqual({ day: 'tue', time: '09:00' });
});
test('normal weekday at 08:30 (monday) → closed before-open, opensAt 09:00', () => {
const s = isOpenNow(HOURS_NORMAL_WEEK, at('2026-07-27T08:30:00'));
expect(s).toEqual({ isOpen: false, kind: 'before-open', opensAt: '09:00', nextOpen: null });
});
test('sunday with no hours → closed, nextOpen monday 09:00', () => {
// 2026-07-26 is a Sunday
const s = isOpenNow(HOURS_NORMAL_WEEK, at('2026-07-26T10:00:00'));
expect(s.isOpen).toBe(false);
expect(s.kind).toBe('closed');
expect(s.nextOpen).toEqual({ day: 'mon', time: '09:00' });
});
test('all week closed → closed, no nextOpen', () => {
const s = isOpenNow(HOURS_ALL_CLOSED, at('2026-07-27T10:00:00'));
expect(s).toEqual({ isOpen: false, kind: 'closed', nextOpen: null });
});
test('midnight-crossing: monday 22:00-02:00, at 23:00 → open', () => {
const s = isOpenNow(HOURS_MIDNIGHT_CROSS, at('2026-07-27T23:00:00'));
expect(s.isOpen).toBe(true);
expect(s.kind).toBe('open');
expect(s.closesAt).toBe('02:00');
});
test('midnight-crossing: monday 22:00-02:00, at 01:00 → open (yesterday range still active)', () => {
// Tuesday 01:00 → it's inside monday's 22:00-02:00 range (extended past midnight)
const s = isOpenNow(HOURS_MIDNIGHT_CROSS, at('2026-07-28T01:00:00'));
expect(s.isOpen).toBe(true);
expect(s.kind).toBe('open');
});
test('midnight-crossing: at tuesday 03:00, today opens at 09:00 → before-open', () => {
// Tuesday 03:00 with monday 22:00-02:00 already finished; today's tue is 09:00-21:00.
const s = isOpenNow(HOURS_MIDNIGHT_CROSS, at('2026-07-28T03:00:00'));
expect(s).toEqual({ isOpen: false, kind: 'before-open', opensAt: '09:00', nextOpen: null });
});
test('midnight-crossing: same day before today opens → before-open with today time', () => {
// HOURS_MIDNIGHT_CROSS: mon 22:00-02:00 (crossing). At monday 10:00, before today's 22:00.
const s = isOpenNow(HOURS_MIDNIGHT_CROSS, at('2026-07-27T10:00:00'));
expect(s).toEqual({ isOpen: false, kind: 'before-open', opensAt: '22:00', nextOpen: null });
});
test('saturday morning at 10:00 (with 09:00-14:00) → open, closesAt 14:00', () => {
// 2026-07-25 is a Saturday
const s = isOpenNow(HOURS_NORMAL_WEEK, at('2026-07-25T10:00:00'));
expect(s).toEqual({ isOpen: true, kind: 'open', closesAt: '14:00' });
});
test('saturday at 15:00 → closed, nextOpen monday 09:00', () => {
const s = isOpenNow(HOURS_NORMAL_WEEK, at('2026-07-25T15:00:00'));
expect(s.isOpen).toBe(false);
expect(s.kind).toBe('after-close');
expect(s.nextOpen).toEqual({ day: 'mon', time: '09:00' });
});
});
@@ -0,0 +1,105 @@
import { jest } from '@jest/globals'
jest.unstable_mockModule('../cima-service.js', () => ({
searchMedicines: jest.fn(async () => []),
getMedicineDetails: jest.fn(async () => null),
searchOTC: jest.fn(async () => []),
}))
jest.unstable_mockModule('../farmacias-webhook-import.js', () => ({
runFarmaciaWebhookImport: jest.fn(async () => ({})),
DEFAULT_FARMACIAS_WEBHOOK: '',
importPharmaciesFromRows: jest.fn(async () => ({})),
}))
jest.unstable_mockModule('../../API/index.js', () => ({
fetchPharmaciesExternal: jest.fn(async () => []),
}))
process.env.DATABASE_PATH = ':memory:'
process.env.NODE_ENV = 'test'
const { default: supertest } = await import('supertest')
const { app, initDatabase, db } = await import('../server.js')
const HOURS_24_7 = JSON.stringify({
mon: ['00:00', '24:00'],
tue: ['00:00', '24:00'],
wed: ['00:00', '24:00'],
thu: ['00:00', '24:00'],
fri: ['00:00', '24:00'],
sat: ['00:00', '24:00'],
sun: ['00:00', '24:00'],
})
const HOURS_NORMAL = JSON.stringify({
mon: ['09:00', '21:00'],
tue: ['09:00', '21:00'],
wed: ['09:00', '21:00'],
thu: ['09:00', '21:00'],
fri: ['09:00', '21:00'],
sat: ['09:00', '14:00'],
sun: null,
})
function insertPharmacy(name, openingHours) {
return new Promise((resolve, reject) => {
db.run(
'INSERT INTO pharmacies (name, address, latitude, longitude, opening_hours) VALUES (?, ?, ?, ?, ?)',
[name, 'Address of ' + name, 41.5, 2.0, openingHours],
function (err) { return err ? reject(err) : resolve(this.lastID) }
)
})
}
beforeAll(async () => {
await initDatabase()
})
afterEach(async () => {
await new Promise((resolve, reject) => {
db.run('DELETE FROM pharmacies', (err) => (err ? reject(err) : resolve()))
})
})
describe('GET /api/pharmacies — is_open / is_24h enrichment', () => {
test('24/7 pharmacy → is_open=true, is_24h=true', async () => {
await insertPharmacy('24h Pharmacy', HOURS_24_7)
const res = await supertest(app).get('/api/pharmacies')
expect(res.status).toBe(200)
expect(res.body.length).toBe(1)
expect(res.body[0].is_open).toBe(true)
expect(res.body[0].is_24h).toBe(true)
})
test('normal hours pharmacy at an open time → is_open=true, is_24h=false', async () => {
await insertPharmacy('Normal Pharmacy', HOURS_NORMAL)
const res = await supertest(app).get('/api/pharmacies')
expect(res.status).toBe(200)
expect(res.body[0].is_24h).toBe(false)
expect(typeof res.body[0].is_open).toBe('boolean')
})
test('null opening_hours → is_open=null, is_24h=false', async () => {
await insertPharmacy('No Hours', null)
const res = await supertest(app).get('/api/pharmacies')
expect(res.status).toBe(200)
expect(res.body[0].is_open).toBeNull()
expect(res.body[0].is_24h).toBe(false)
})
test('multiple pharmacies each get correct enrichment', async () => {
await insertPharmacy('24h Pharmacy', HOURS_24_7)
await insertPharmacy('Normal Pharmacy', HOURS_NORMAL)
await insertPharmacy('No Hours', null)
const res = await supertest(app).get('/api/pharmacies')
expect(res.status).toBe(200)
expect(res.body.length).toBe(3)
const byName = Object.fromEntries(res.body.map(p => [p.name, p]))
expect(byName['24h Pharmacy'].is_open).toBe(true)
expect(byName['24h Pharmacy'].is_24h).toBe(true)
expect(byName['Normal Pharmacy'].is_24h).toBe(false)
expect(byName['No Hours'].is_open).toBeNull()
expect(byName['No Hours'].is_24h).toBe(false)
})
})
+1 -1
View File
@@ -44,7 +44,7 @@
"pino": "^9.4.0", "pino": "^9.4.0",
"pino-http": "^10.3.0", "pino-http": "^10.3.0",
"redis": "^4.6.0", "redis": "^4.6.0",
"sqlite3": "^5.1.6", "sqlite3": "^5.1.7",
"tesseract.js": "^7.0.0", "tesseract.js": "^7.0.0",
"web-push": "^3.6.7" "web-push": "^3.6.7"
}, },
+15 -3
View File
@@ -27,6 +27,7 @@ import { searchMedicines, getMedicineDetails, searchOTC } from './cima-service.j
import { runFarmaciaWebhookImport, DEFAULT_FARMACIAS_WEBHOOK, importPharmaciesFromRows } from './farmacias-webhook-import.js'; import { runFarmaciaWebhookImport, DEFAULT_FARMACIAS_WEBHOOK, importPharmaciesFromRows } from './farmacias-webhook-import.js';
import { fetchPharmaciesExternal } from '../API/index.js'; import { fetchPharmaciesExternal } from '../API/index.js';
import { validateProductionEnv } from './src/config/required-env.js'; import { validateProductionEnv } from './src/config/required-env.js';
import { isOpenNow, isAlwaysOpen } from './src/hours.js';
validateProductionEnv(); validateProductionEnv();
@@ -226,6 +227,17 @@ function serializeOpeningHours(value) {
return null; return null;
} }
function enrichPharmacy(row) {
let isOpen = null;
let is24h = false;
if (row.opening_hours) {
const status = isOpenNow(row.opening_hours);
isOpen = status ? status.isOpen : null;
is24h = isAlwaysOpen(row.opening_hours);
}
return { ...row, is_open: isOpen, is_24h: is24h };
}
// Initialize database tables // Initialize database tables
async function initDatabase() { async function initDatabase() {
try { try {
@@ -658,7 +670,7 @@ app.get('/api/medicines/:medicineId/pharmacies', async (req, res) => {
`); `);
} }
res.json(pharmacies); res.json(pharmacies.map(enrichPharmacy));
} catch (error) { } catch (error) {
console.error('Error fetching pharmacies:', error); console.error('Error fetching pharmacies:', error);
res.status(500).json({ error: 'Internal server error' }); res.status(500).json({ error: 'Internal server error' });
@@ -823,7 +835,7 @@ app.get('/api/products/:source/:productId/pharmacies', async (req, res) => {
`); `);
} }
res.json(pharmacies); res.json(pharmacies.map(enrichPharmacy));
} catch (error) { } catch (error) {
console.error('Error fetching pharmacies for product:', error); console.error('Error fetching pharmacies for product:', error);
res.status(500).json({ error: 'Internal server error' }); res.status(500).json({ error: 'Internal server error' });
@@ -920,7 +932,7 @@ app.get('/api/pharmacies', async (req, res) => {
const pharmacies = await userDbAll(` const pharmacies = await userDbAll(`
SELECT * FROM pharmacies ORDER BY name SELECT * FROM pharmacies ORDER BY name
`); `);
res.json(pharmacies); res.json(pharmacies.map(enrichPharmacy));
} catch (error) { } catch (error) {
console.error('Error fetching pharmacies:', error); console.error('Error fetching pharmacies:', error);
res.status(500).json({ error: 'Internal server error' }); res.status(500).json({ error: 'Internal server error' });
+109
View File
@@ -0,0 +1,109 @@
/**
* Compute "open now" / "always open" for a pharmacy whose opening_hours is
* stored as JSON in the { mon, tue, ..., sun } shape produced by
* apps/API/opening-hours-osm.js.
*
* 24/7 is internally represented as every day ["00:00", "24:00"]. The
* literal "24:00" is not valid HH:mm ISO; we accept it as 1440 minutes
* (00:00 of the next day) so range math works, and treat a 24h week as
* the dedicated `kind: '24h'` result.
*
* Midnight-crossing ranges (close <= open) are detected by comparing
* the previous day's range when "now" is in the early hours.
*/
const DAYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
function parse(raw) {
if (raw == null || raw === '') return null;
if (typeof raw === 'object') return raw;
try {
return JSON.parse(raw);
} catch {
return null;
}
}
function toMin(hm) {
if (hm == null) return null;
const parts = String(hm).split(':');
if (parts.length !== 2) return null;
const h = Number(parts[0]);
const m = Number(parts[1]);
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;
}
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;
}
export function isAlwaysOpen(rawHours) {
const h = parse(rawHours);
if (!h || typeof h !== 'object') 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 dayKey = DAYS[now.getDay()];
const range = h[dayKey];
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();
// First: is "now" still inside yesterday's midnight-crossing range?
// (Today is irrelevant while we're still in the previous day's after-midnight tail.)
const yestKey = DAYS[(now.getDay() + 6) % 7];
const yest = h[yestKey];
if (Array.isArray(yest) && yest.length === 2) {
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] };
}
}
// Midnight-crossing: today's range spans past 24:00.
if (closeM <= openM) {
if (nowM >= openM) {
return { isOpen: true, kind: 'open', closesAt: range[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] };
}
+2 -2
View File
@@ -28,10 +28,10 @@
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^6.4.0", "@testing-library/jest-dom": "^6.4.0",
"@testing-library/react": "^14.2.0", "@testing-library/react": "^14.2.0",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.7.0",
"jsdom": "^24.0.0", "jsdom": "^24.0.0",
"vite": "^5.0.8", "vite": "^5.0.8",
"vite-plugin-pwa": "^1.3.0", "vite-plugin-pwa": "^1.3.0",
"vitest": "^1.6.0" "vitest": "^1.6.1"
} }
} }
@@ -110,6 +110,11 @@
color: var(--on-surface-variant); color: var(--on-surface-variant);
} }
.pharmacy-hours--unknown {
color: var(--on-surface-variant);
opacity: 0.6;
}
.pharmacy-pricing { .pharmacy-pricing {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -107,7 +107,10 @@ function PharmacyCard({ pharmacy, distanceKm, medicine, currentUser, onLoginRequ
return ( return (
<div className="pharmacy-card"> <div className="pharmacy-card">
<div className="pharmacy-header"> <div className="pharmacy-header">
<h4>🏥 {pharmacy.name}</h4> <h4>
🏥 {pharmacy.name}
{pharmacy.is_24h && <span className="pharmacy-badge pharmacy-badge--24h" aria-label={t('pharmacy.badge24h')}>24h</span>}
</h4>
<div className="pharmacy-header-actions"> <div className="pharmacy-header-actions">
{distanceKm != null && ( {distanceKm != null && (
<span className="pharmacy-distance">{formatDistance(distanceKm)}</span> <span className="pharmacy-distance">{formatDistance(distanceKm)}</span>
@@ -142,7 +145,7 @@ function PharmacyCard({ pharmacy, distanceKm, medicine, currentUser, onLoginRequ
<div className="pharmacy-details"> <div className="pharmacy-details">
{openStatus && ( {openStatus && (
<p className={`pharmacy-hours pharmacy-hours--${openStatus.status}`}> <p className={`pharmacy-hours pharmacy-hours--${openStatus.status}`}>
<span className="pharmacy-hours-dot" /> {openStatus.label} <span className="pharmacy-hours-dot" /> {openStatus.labelKey && openStatus.labelParams ? t(openStatus.labelKey, openStatus.labelParams) : openStatus.label}
</p> </p>
)} )}
<p className="pharmacy-address">📍 {pharmacy.address}</p> <p className="pharmacy-address">📍 {pharmacy.address}</p>
+1 -1
View File
@@ -31,7 +31,7 @@ function PharmacyMap({ pharmacies }) {
{located.map(pharmacy => ( {located.map(pharmacy => (
<Marker key={pharmacy.id} position={[pharmacy.latitude, pharmacy.longitude]}> <Marker key={pharmacy.id} position={[pharmacy.latitude, pharmacy.longitude]}>
<Popup> <Popup>
<strong>{pharmacy.name}</strong><br /> <strong>{pharmacy.name} {pharmacy.is_24h && <span className="map-badge-24h">24h</span>}</strong><br />
{pharmacy.address} {pharmacy.address}
{pharmacy.phone && <><br />{pharmacy.phone}</>} {pharmacy.phone && <><br />{pharmacy.phone}</>}
<br /> <br />
@@ -1,49 +1,8 @@
import React, { useState, useEffect, useMemo } from 'react'; import React, { useState, useEffect, useMemo, useCallback } from 'react';
import './AdminComponents.css'; import './AdminComponents.css';
import { DAY_KEYS, DAY_LABEL } from '../../utils/hours'; import { DAY_KEYS, DAY_LABEL, emptyHoursDraft, hoursToDraft, draftToHours, makeAlwaysOpenDraft, isAlwaysOpen } from '../../utils/hours';
import { useTranslation } from '../../i18n'; import { useTranslation } from '../../i18n';
function emptyHoursDraft() {
const draft = {};
for (const day of DAY_KEYS) {
draft[day] = { open: '09:00', close: '21:00', closed: true };
}
return draft;
}
function hoursToDraft(raw) {
let parsed = null;
if (raw) {
try { parsed = typeof raw === 'string' ? JSON.parse(raw) : raw; }
catch { parsed = null; }
}
const draft = {};
for (const day of DAY_KEYS) {
const v = parsed && parsed[day];
if (Array.isArray(v) && v.length === 2) {
draft[day] = { open: v[0], close: v[1], closed: false };
} else {
draft[day] = { open: '09:00', close: '21:00', closed: true };
}
}
return draft;
}
function draftToHours(draft) {
const out = {};
let hasAny = false;
for (const day of DAY_KEYS) {
const d = draft[day];
if (d && !d.closed && d.open && d.close) {
out[day] = [d.open, d.close];
hasAny = true;
} else {
out[day] = null;
}
}
return hasAny ? out : null;
}
/** Distance in metres between two WGS84 points */ /** Distance in metres between two WGS84 points */
function haversineMeters(lat1, lon1, lat2, lon2) { function haversineMeters(lat1, lon1, lat2, lon2) {
const R = 6371000; const R = 6371000;
@@ -629,6 +588,22 @@ function PharmacyManagement() {
<fieldset className="hours-editor"> <fieldset className="hours-editor">
<legend>{t('admin.pharmacy.openingHours')}</legend> <legend>{t('admin.pharmacy.openingHours')}</legend>
<p className="hours-editor-hint">{t('admin.pharmacy.dayClosed')}</p> <p className="hours-editor-hint">{t('admin.pharmacy.dayClosed')}</p>
<label className="hours-24h-toggle">
<input
type="checkbox"
checked={isAlwaysOpen(draftToHours(hoursDraft))}
onChange={(e) => {
if (e.target.checked) {
setHoursDraft(makeAlwaysOpenDraft());
} else {
if (window.confirm(t('admin.pharmacy.confirmDisable24h'))) {
setHoursDraft(emptyHoursDraft());
}
}
}}
/>
{t('admin.pharmacy.alwaysOpen')}
</label>
{DAY_KEYS.map((day) => { {DAY_KEYS.map((day) => {
const d = hoursDraft[day]; const d = hoursDraft[day];
return ( return (
+13
View File
@@ -66,6 +66,17 @@ const ca = {
'pharmacy.notifyWhenArrives': 'Notificar-me quan arribi a aquesta farmàcia', 'pharmacy.notifyWhenArrives': 'Notificar-me quan arribi a aquesta farmàcia',
'pharmacy.notificationsActivatedPharmacy': 'Notificacions activades per a aquesta farmàcia — clic per desactivar', 'pharmacy.notificationsActivatedPharmacy': 'Notificacions activades per a aquesta farmàcia — clic per desactivar',
'pharmacy.notificationsRequired': 'Les notificacions requereixen iOS 16.4+ i aquest lloc instal·lat com a app (Compartir → Afegir a Pantalla d\'Inici).', 'pharmacy.notificationsRequired': 'Les notificacions requereixen iOS 16.4+ i aquest lloc instal·lat com a app (Compartir → Afegir a Pantalla d\'Inici).',
'pharmacy.openNow': 'Obert · Tanca a les {{time}}',
'pharmacy.closedAllDay': 'Tancat',
'pharmacy.opensAt': 'Tancat · Obre a les {{time}}',
'pharmacy.opensTomorrow': 'Tancat · Obre demà a les {{time}}',
'pharmacy.opensDay': 'Tancat · Obre el {{day}} a les {{time}}',
'pharmacy.alwaysOpen': 'Obert 24h',
'pharmacy.filterOpenNow': 'Mostrar només obertes ara',
'pharmacy.filterOpenNowActive': 'Només obertes ara',
'pharmacy.badge24h': '24h',
'pharmacy.noHours': 'Sense horari disponible',
'pharmacy.filterNoResults': 'Cap farmàcia oberta ara. Desactiva el filtre per veure-les totes.',
// ProductResults // ProductResults
'product.sinReceta': 'Sense Recepta', 'product.sinReceta': 'Sense Recepta',
@@ -338,6 +349,8 @@ const ca = {
'admin.pharmacy.apiNotFound': 'L\'app no ha pogut connectar amb l\'API (404). Useu http://localhost:3000 amb frontend i backend actius.', 'admin.pharmacy.apiNotFound': 'L\'app no ha pogut connectar amb l\'API (404). Useu http://localhost:3000 amb frontend i backend actius.',
'admin.pharmacy.geocodificationNotFound': 'Servei de geocodificació no trobat. Actualitzeu el backend i reinicieu-lo.', 'admin.pharmacy.geocodificationNotFound': 'Servei de geocodificació no trobat. Actualitzeu el backend i reinicieu-lo.',
'admin.pharmacy.searchFailed': 'Cerca fallida (HTTP', 'admin.pharmacy.searchFailed': 'Cerca fallida (HTTP',
'admin.pharmacy.alwaysOpen': '24 hores (oberta tot el dia)',
'admin.pharmacy.confirmDisable24h': 'Desactivar 24h? Es descartaran els horaris actuals.',
'admin.pharmacy.dayClosed': 'Marqueu un dia com a Tancat si la farmàcia no obre aquest dia.', 'admin.pharmacy.dayClosed': 'Marqueu un dia com a Tancat si la farmàcia no obre aquest dia.',
'admin.pharmacy.saveError': 'Error en desar farmàcia', 'admin.pharmacy.saveError': 'Error en desar farmàcia',
'admin.pharmacy.radius': 'Radi (m)', 'admin.pharmacy.radius': 'Radi (m)',
+13
View File
@@ -66,6 +66,17 @@ const es = {
'pharmacy.notifyWhenArrives': 'Notificarme cuando llegue a esta farmacia', 'pharmacy.notifyWhenArrives': 'Notificarme cuando llegue a esta farmacia',
'pharmacy.notificationsActivatedPharmacy': 'Notificaciones activadas para esta farmacia — clic para desactivar', 'pharmacy.notificationsActivatedPharmacy': 'Notificaciones activadas para esta farmacia — clic para desactivar',
'pharmacy.notificationsRequired': 'Las notificaciones requieren iOS 16.4+ y este sitio instalado como app (Compartir → Añadir a Pantalla de Inicio).', 'pharmacy.notificationsRequired': 'Las notificaciones requieren iOS 16.4+ y este sitio instalado como app (Compartir → Añadir a Pantalla de Inicio).',
'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.noHours': 'Sin horario disponible',
'pharmacy.filterNoResults': 'Ninguna farmacia abierta ahora. Desactiva el filtro para ver todas.',
// ProductResults // ProductResults
'product.sinReceta': 'Sin Receta', 'product.sinReceta': 'Sin Receta',
@@ -340,6 +351,8 @@ const es = {
'admin.pharmacy.apiNotFound': 'La app no pudo conectar con la API (404). Usa http://localhost:3000 con frontend y backend activos.', 'admin.pharmacy.apiNotFound': 'La app no pudo conectar con la API (404). Usa http://localhost:3000 con frontend y backend activos.',
'admin.pharmacy.geocodingNotFound': 'Servicio de geocodificación no encontrado. Actualiza el backend y reinícialo.', 'admin.pharmacy.geocodingNotFound': 'Servicio de geocodificación no encontrado. Actualiza el backend y reinícialo.',
'admin.pharmacy.searchFailed': 'Búsqueda fallida (HTTP', 'admin.pharmacy.searchFailed': 'Búsqueda fallida (HTTP',
'admin.pharmacy.alwaysOpen': '24 horas (abierta todo el día)',
'admin.pharmacy.confirmDisable24h': '¿Desactivar 24h? Se descartarán los horarios actuales.',
'admin.pharmacy.dayClosed': 'Marca un día como Cerrado si la farmacia no abre ese día.', 'admin.pharmacy.dayClosed': 'Marca un día como Cerrado si la farmacia no abre ese día.',
'admin.pharmacy.saveError': 'Error al guardar farmacia', 'admin.pharmacy.saveError': 'Error al guardar farmacia',
'admin.pharmacy.radius': 'Radio (m)', 'admin.pharmacy.radius': 'Radio (m)',
+85 -11
View File
@@ -27,14 +27,12 @@ function parseHours(raw) {
} }
} }
function findNextOpen(hours, now) { function findNextOpenInfo(hours, now) {
for (let offset = 1; offset <= 7; offset++) { for (let offset = 1; offset <= 7; offset++) {
const day = DAYS[(now.getDay() + offset) % 7]; const day = DAYS[(now.getDay() + offset) % 7];
const range = hours[day]; const range = hours[day];
if (Array.isArray(range) && range.length === 2) { if (Array.isArray(range) && range.length === 2) {
const openStr = range[0]; return { day, time: range[0], offset };
if (offset === 1) return `mañana a las ${openStr}`;
return `${DAY_LABELS[day]} a las ${openStr}`;
} }
} }
return null; return null;
@@ -42,14 +40,24 @@ function findNextOpen(hours, now) {
export function getOpenStatus(rawHours, now = new Date()) { export function getOpenStatus(rawHours, now = new Date()) {
const hours = parseHours(rawHours); const hours = parseHours(rawHours);
if (!hours) return null; if (!hours) return { status: 'unknown', label: 'Sin horario', labelKey: 'pharmacy.noHours', labelParams: {} };
if (isAlwaysOpen(hours)) {
return { status: 'open', label: 'Abierto 24h', labelKey: 'pharmacy.alwaysOpen', labelParams: {} };
}
const day = DAYS[now.getDay()]; const day = DAYS[now.getDay()];
const range = hours[day]; const range = hours[day];
if (!Array.isArray(range) || range.length !== 2) { if (!Array.isArray(range) || range.length !== 2) {
const next = findNextOpen(hours, now); const next = findNextOpenInfo(hours, now);
return { status: 'closed', label: next ? `Cerrado · Abre ${next}` : 'Cerrado' }; if (!next) {
return { status: 'closed', label: 'Cerrado', labelKey: 'pharmacy.closedAllDay', labelParams: {} };
}
if (next.offset === 1) {
return { status: 'closed', label: `Cerrado · Abre mañana a las ${next.time}`, labelKey: 'pharmacy.opensTomorrow', labelParams: { time: next.time } };
}
return { status: 'closed', label: `Cerrado · Abre el ${DAY_LABELS[next.day]} a las ${next.time}`, labelKey: 'pharmacy.opensDay', labelParams: { day: DAY_LABELS[next.day], time: next.time } };
} }
const openMins = parseHM(range[0]); const openMins = parseHM(range[0]);
@@ -59,15 +67,81 @@ export function getOpenStatus(rawHours, now = new Date()) {
const nowMins = now.getHours() * 60 + now.getMinutes(); const nowMins = now.getHours() * 60 + now.getMinutes();
if (nowMins < openMins) { if (nowMins < openMins) {
return { status: 'closed', label: `Cerrado · Abre a las ${range[0]}` }; return { status: 'closed', label: `Cerrado · Abre a las ${range[0]}`, labelKey: 'pharmacy.opensAt', labelParams: { time: range[0] } };
} }
if (nowMins >= closeMins) { if (nowMins >= closeMins) {
const next = findNextOpen(hours, now); const next = findNextOpenInfo(hours, now);
return { status: 'closed', label: next ? `Cerrado · Abre ${next}` : 'Cerrado' }; if (!next) {
return { status: 'closed', label: 'Cerrado', labelKey: 'pharmacy.closedAllDay', labelParams: {} };
}
if (next.offset === 1) {
return { status: 'closed', label: `Cerrado · Abre mañana a las ${next.time}`, labelKey: 'pharmacy.opensTomorrow', labelParams: { time: next.time } };
}
return { status: 'closed', label: `Cerrado · Abre el ${DAY_LABELS[next.day]} a las ${next.time}`, labelKey: 'pharmacy.opensDay', labelParams: { day: DAY_LABELS[next.day], time: next.time } };
} }
return { status: 'open', label: `Abierto · Cierra a las ${range[1]}` }; return { status: 'open', label: `Abierto · Cierra a las ${range[1]}`, labelKey: 'pharmacy.openNow', labelParams: { time: range[1] } };
} }
export function emptyHours() { export function emptyHours() {
return { sun: null, mon: null, tue: null, wed: null, thu: null, fri: null, sat: null }; return { sun: null, mon: null, tue: null, wed: null, thu: null, fri: null, sat: null };
} }
export function emptyHoursDraft() {
const draft = {};
for (const day of DAYS) {
draft[day] = { open: '09:00', close: '21:00', closed: true };
}
return draft;
}
export function hoursToDraft(raw) {
let parsed = null;
if (raw) {
try { parsed = typeof raw === 'string' ? JSON.parse(raw) : raw; }
catch { parsed = null; }
}
const draft = {};
for (const day of DAYS) {
const v = parsed && parsed[day];
if (Array.isArray(v) && v.length === 2) {
draft[day] = { open: v[0], close: v[1], closed: false };
} else {
draft[day] = { open: '09:00', close: '21:00', closed: true };
}
}
return draft;
}
export function draftToHours(draft) {
const out = {};
let hasAny = false;
for (const day of DAYS) {
const d = draft[day];
if (d && !d.closed && d.open && d.close) {
out[day] = [d.open, d.close];
hasAny = true;
} else {
out[day] = null;
}
}
return hasAny ? out : null;
}
export function isAlwaysOpen(rawHours) {
const h = parseHours(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 makeAlwaysOpenDraft() {
const draft = {};
for (const day of DAYS) {
draft[day] = { open: '00:00', close: '24:00', closed: false };
}
return draft;
}
+20 -3
View File
@@ -31,6 +31,7 @@ function PublicView({
const [userPosition, setUserPosition] = useState(null); const [userPosition, setUserPosition] = useState(null);
const [positionSource, setPositionSource] = useState(null); // 'profile' | 'browser' const [positionSource, setPositionSource] = useState(null); // 'profile' | 'browser'
const [sortByDistance, setSortByDistance] = useState(false); const [sortByDistance, setSortByDistance] = useState(false);
const [openNow, setOpenNow] = useState(false);
const [locating, setLocating] = useState(false); const [locating, setLocating] = useState(false);
const [locationError, setLocationError] = useState(''); const [locationError, setLocationError] = useState('');
@@ -177,8 +178,12 @@ function PublicView({
}; };
const displayedPharmacies = useMemo(() => { const displayedPharmacies = useMemo(() => {
if (!sortByDistance || !userPosition) return pharmacies; let filtered = pharmacies;
return [...pharmacies].sort((a, b) => { if (openNow) {
filtered = pharmacies.filter(p => p.is_open === true || p.opening_hours == null);
}
if (!sortByDistance || !userPosition) return filtered;
return [...filtered].sort((a, b) => {
if (a.latitude == null || a.longitude == null) return 1; if (a.latitude == null || a.longitude == null) return 1;
if (b.latitude == null || b.longitude == null) return -1; if (b.latitude == null || b.longitude == null) return -1;
return ( return (
@@ -186,7 +191,7 @@ function PublicView({
haversineKm(userPosition.lat, userPosition.lon, b.latitude, b.longitude) haversineKm(userPosition.lat, userPosition.lon, b.latitude, b.longitude)
); );
}); });
}, [pharmacies, sortByDistance, userPosition]); }, [pharmacies, openNow, sortByDistance, userPosition]);
/* ── Scanner → Search handoff ──────────────────────────── */ /* ── Scanner → Search handoff ──────────────────────────── */
function handleScanSelectMedicine(medicineName) { function handleScanSelectMedicine(medicineName) {
@@ -323,6 +328,13 @@ function PublicView({
{pharmacies.length > 0 && ( {pharmacies.length > 0 && (
<div className="pharmacy-controls"> <div className="pharmacy-controls">
<button
className={`open-now-toggle ${openNow ? 'active' : ''}`}
onClick={() => setOpenNow(o => !o)}
aria-pressed={openNow}
>
{openNow ? '🟢 Solo abiertas ahora' : '⏱ Mostrar solo abiertas ahora'}
</button>
<button <button
className={`sort-distance-button ${sortByDistance ? 'active' : ''}`} className={`sort-distance-button ${sortByDistance ? 'active' : ''}`}
onClick={handleSortByDistance} onClick={handleSortByDistance}
@@ -345,6 +357,11 @@ function PublicView({
</div> </div>
)} )}
{openNow && displayedPharmacies.length === 0 && (
<div className="open-now-empty">
<p>Ninguna farmacia abierta ahora — desactiva el filtro para ver todas.</p>
</div>
)}
<PharmacyMap pharmacies={displayedPharmacies} /> <PharmacyMap pharmacies={displayedPharmacies} />
<PharmacyList <PharmacyList
pharmacies={displayedPharmacies} pharmacies={displayedPharmacies}
+6 -1
View File
@@ -296,6 +296,11 @@
gap: 0.75rem; gap: 0.75rem;
margin: 1rem 0 0.5rem; margin: 1rem 0 0.5rem;
flex-wrap: wrap; flex-wrap: wrap;
position: sticky;
top: 0;
z-index: 10;
background: var(--surface);
padding: 0.75rem 0;
} }
.sort-distance-button { .sort-distance-button {
@@ -312,7 +317,7 @@
.sort-distance-button:hover:not(:disabled) { .sort-distance-button:hover:not(:disabled) {
border-color: var(--primary); border-color: var(--primary);
color: var(--primary); color: #151c17;
} }
.sort-distance-button.active { .sort-distance-button.active {
+25 -3
View File
@@ -6,6 +6,7 @@ import PharmacyList from '../components/PharmacyList';
import PharmacyMap from '../components/PharmacyMap'; import PharmacyMap from '../components/PharmacyMap';
import { haversineKm, getUserPosition, hasCachedPosition } from '../utils/geo'; import { haversineKm, getUserPosition, hasCachedPosition } from '../utils/geo';
import { useTranslation } from '../i18n'; import { useTranslation } from '../i18n';
import { getOpenStatus } from '../utils/hours';
import './SearchView.css'; import './SearchView.css';
const suggestions = [ const suggestions = [
@@ -26,6 +27,7 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '', onNavigate
const [userPosition, setUserPosition] = useState(null); const [userPosition, setUserPosition] = useState(null);
const [positionSource, setPositionSource] = useState(null); const [positionSource, setPositionSource] = useState(null);
const [sortByDistance, setSortByDistance] = useState(false); const [sortByDistance, setSortByDistance] = useState(false);
const [openNow, setOpenNow] = useState(false);
const [locating, setLocating] = useState(false); const [locating, setLocating] = useState(false);
const [locationError, setLocationError] = useState(''); const [locationError, setLocationError] = useState('');
const [recentSearches, setRecentSearches] = useState([]); const [recentSearches, setRecentSearches] = useState([]);
@@ -208,8 +210,19 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '', onNavigate
}; };
const displayedPharmacies = useMemo(() => { const displayedPharmacies = useMemo(() => {
if (!sortByDistance || !userPosition) return pharmacies; let result = pharmacies;
return [...pharmacies].sort((a, b) => { if (openNow) {
result = result.filter((p) => {
if (p.is_open === true) return true;
if (p.is_open == null) {
const s = getOpenStatus(p.opening_hours);
return s && s.status === 'open';
}
return false;
});
}
if (!sortByDistance || !userPosition) return result;
return [...result].sort((a, b) => {
if (a.latitude == null || a.longitude == null) return 1; if (a.latitude == null || a.longitude == null) return 1;
if (b.latitude == null || b.longitude == null) return -1; if (b.latitude == null || b.longitude == null) return -1;
return ( return (
@@ -217,7 +230,7 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '', onNavigate
haversineKm(userPosition.lat, userPosition.lon, b.latitude, b.longitude) haversineKm(userPosition.lat, userPosition.lon, b.latitude, b.longitude)
); );
}); });
}, [pharmacies, sortByDistance, userPosition]); }, [pharmacies, sortByDistance, userPosition, openNow]);
return ( return (
<div className="search-view"> <div className="search-view">
@@ -359,6 +372,12 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '', onNavigate
{pharmacies.length > 0 && ( {pharmacies.length > 0 && (
<div className="pharmacy-controls"> <div className="pharmacy-controls">
<button
className={`sort-distance-button ${openNow ? 'active' : ''}`}
onClick={() => setOpenNow((v) => !v)}
>
{openNow ? t('pharmacy.filterOpenNowActive') : t('pharmacy.filterOpenNow')}
</button>
<button <button
className={`sort-distance-button ${sortByDistance ? 'active' : ''}`} className={`sort-distance-button ${sortByDistance ? 'active' : ''}`}
onClick={handleSortByDistance} onClick={handleSortByDistance}
@@ -388,6 +407,9 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '', onNavigate
)} )}
</div> </div>
)} )}
{openNow && pharmacies.length > 0 && displayedPharmacies.length === 0 && (
<div className="no-pharmacies">{t('pharmacy.filterNoResults')}</div>
)}
<PharmacyMap pharmacies={displayedPharmacies} /> <PharmacyMap pharmacies={displayedPharmacies} />
<PharmacyList <PharmacyList
+94
View File
@@ -0,0 +1,94 @@
# Horarios de farmacias — Implementación
## Resumen
Sistema completo para gestionar, calcular y filtrar horarios de apertura de farmacias. Implementado siguiendo el plan en `docs/PLAN-HORARIOS.md`.
## Lo implementado
### Backend (`apps/backend/`)
| Archivo | Qué hace |
|---------|----------|
| `src/hours.js` | Helper puro: `isOpenNow(rawHours, now?)` y `isAlwaysOpen(rawHours)`. Soporta 24/7, rangos normales, cruce de medianoche, días cerrados, `24:00` como 1440 min. |
| `server.js` | Los 3 endpoints públicos (`/api/medicines/:id/pharmacies`, `/api/products/:source/:id/pharmacies`, `/api/pharmacies`) devuelven `is_open` (boolean o null) e `is_24h` (boolean) precalculados con la hora del servidor. Helper `enrichPharmacy(row)`. |
### Frontend (`apps/frontend/`)
| Archivo | Qué hace |
|---------|----------|
| `src/utils/hours.js` | `getOpenStatus()` refactorizado: devuelve `labelKey`/`labelParams` para i18n + `label` legacy. Nuevos: `emptyHoursDraft()`, `hoursToDraft()`, `draftToHours()` (movidos desde admin), `isAlwaysOpen()`, `makeAlwaysOpenDraft()`. |
| `src/views/PublicView.jsx` | Nuevo estado `openNow`. Botón "Mostrar solo abiertas ahora" en `.pharmacy-controls`. Filtro en `displayedPharmacies` (incluye farmacias sin horarios). Mensaje si 0 resultados. |
| `src/components/PharmacyList.jsx` | Badge `24h` junto al nombre. Texto de estado usa `t()` con claves i18n. |
| `src/components/PharmacyMap.jsx` | Badge `24h` en popup del marcador. |
| `src/components/admin/PharmacyManagement.jsx` | Toggle "24 horas" en el editor de horarios. Al activarlo, rellena todos los días con `00:00-24:00`. Al desactivarlo, `confirm()` antes de descartar. |
| `src/i18n/locales/es.js` | Nuevas claves: `pharmacy.openNow`, `pharmacy.closedAllDay`, `pharmacy.opensAt`, `pharmacy.opensTomorrow`, `pharmacy.opensDay`, `pharmacy.alwaysOpen`, `pharmacy.filterOpenNow`, `pharmacy.filterOpenNowActive`, `pharmacy.badge24h`, `pharmacy.filterNoResults`, `admin.pharmacy.alwaysOpen`, `admin.pharmacy.confirmDisable24h`. |
| `src/i18n/locales/ca.js` | Traducciones catalanas de todas las claves nuevas. |
### Tests
| Archivo | Tests |
|---------|-------|
| `apps/backend/__tests__/hours.test.js` | 22 tests — `isAlwaysOpen` (6), `isOpenNow` (16): 24/7, normal, cerrado, cruce medianoche, null, malformed JSON. |
| `apps/backend/__tests__/pharmacy-hours-endpoint.test.js` | 4 tests — verifica `is_open`/`is_24h` en respuesta JSON para 24h, normal, null, múltiples. |
| `apps/frontend/src/App.test.jsx` | 6 tests existentes — sin regresión. |
| `apps/frontend/src/utils/notifications.test.js` | 1 test existente — sin regresión. |
## Cómo funciona
1. **Almacenamiento**: `pharmacies.opening_hours` como TEXT JSON. Shape: `{ mon: ["09:00","21:00"], tue: null, ... }`. 24/7 → todos los días `["00:00","24:00"]`.
2. **Cálculo en servidor**: Cada request a endpoints públicos ejecuta `isOpenNow()` con la hora del servidor. El frontend recibe `is_open` e `is_24h` ya calculados.
3. **Cálculo en cliente**: `getOpenStatus()` existe como fallback si `is_open` no está presente (datos legacy).
4. **Filtro**: Cliente-side. Farmacias sin horarios (`opening_hours = null`) no se filtran.
5. **Admin**: El toggle 24h rellena los 7 días. El editor manual permite día por día.
## Puntos de mejora futuros
### Pendientes del plan original
- [ ] **Refresco automático cada 60s**: Si la página permanece abierta mucho tiempo, el estado "abierto/cerrado" puede quedar desactualizado. Añadir `setInterval` de 60s en `PublicView.jsx` para recalcular o re-fetch.
- [ ] **Cache server-side**: Si el dataset de farmacias crece (>1000), cachear `isOpenNow` con TTL de 1 minuto por minuto actual. Comentario `TODO(cache)` ya está en `server.js`.
### UI/UX
- [ ] **Tooltip explicativo**: Al pasar el ratón sobre el badge "24h", mostrar "Abierta las 24 horas del día".
- [ ] **Color en el filtro**: El botón "Abiertas ahora" ganaría con un icono verde intermitente o un cambio de color más evidente.
- [ ] **Separar horas de apertura/cierre**: Actualmente el editor de admin usa `<input type="time">` que no acepta `24:00` como valor. Para crear una farmacia 24h hay que usar el toggle. El input manual no permite escribir `24:00`.
- [ ] **Ordenación combinada**: "Abiertas ahora" + "Ordenar por distancia" deberían priorizar las abiertas pero ordenadas por distancia. Actualmente primero filtra, luego ordena.
### Técnicos
- [ ] **Zona horaria explícita**: El servidor usa su hora local. Devolver `server_now` y `server_tz` en la respuesta para que la UI pueda mostrar "según hora del servidor". Ver pregunta abierta #1 en el plan.
- [ ] **Tests de integración real**: Los tests de backend usan SQLite en memoria. Con PostgreSQL real los endpoints deben comportarse igual.
- [ ] **Parser OSM 24/7 → `24:00`**: El parser de OSM ya produce `["00:00","24:00"]` para `24/7`. Si en el futuro OSM cambia el formato, actualizar solo `opening-hours-osm.js`. No tocar nada más (los helpers son agnósticos al formato de entrada).
- [ ] **Cobertura frontend**: Los helpers puros de `hours.js` no tienen tests unitarios. Añadir tests para `isAlwaysOpen()` frontend, `emptyHoursDraft()`, `hoursToDraft()`, `draftToHours()`, `makeAlwaysOpenDraft()`.
### Admin
- [ ] **Vista previa de horarios**: En la lista de farmacias del admin, mostrar un resumen "L-V 9:00-21:00, S 9:00-14:00" o "24h" en vez del JSON crudo.
- [ ] **Importación masiva con horarios**: El importador OSM ya trae `opening_hours`. El importador de datos abiertos también si el JSON incluye el campo. Verificar que los tres caminos de ingesta sigan parseando correctamente tras los cambios.
### Mobile (`apps/frontend-mobile/`)
- [ ] **Los mismos cambios en la app móvil**: El frontend móvil tiene su propia copia de i18n (`apps/frontend-mobile/src/i18n/locales/es.js` y `ca.js`) con menos claves. No se ha tocado. Habría que añadir las mismas claves `pharmacy.*` y replicar la lógica de filtrado/badge.
## Archivos creados/modificados
**Creados:**
- `apps/backend/__tests__/pharmacy-hours-endpoint.test.js`
- `docs/horarios.md` (este)
**Modificados:**
- `apps/backend/server.js` — import `src/hours.js`, helper `enrichPharmacy`, 3 endpoints enriquecidos
- `apps/frontend/src/utils/hours.js` — `getOpenStatus()` con i18n, +5 nuevas exportaciones
- `apps/frontend/src/views/PublicView.jsx` — filtro openNow
- `apps/frontend/src/components/PharmacyList.jsx` — badge 24h, i18n en estado
- `apps/frontend/src/components/PharmacyMap.jsx` — badge 24h en popup
- `apps/frontend/src/components/admin/PharmacyManagement.jsx` — toggle 24h, import desde hours.js
- `apps/frontend/src/i18n/locales/es.js` — 12 nuevas claves
- `apps/frontend/src/i18n/locales/ca.js` — 12 nuevas claves
**No tocados (intencionalmente):**
- `apps/API/opening-hours-osm.js` — parser OSM en producción
- `apps/backend/farmacias-webhook-import.js` — ya parsea correctamente
- `apps/frontend-mobile/` — requiere移植 manual
+3 -3
View File
@@ -58,7 +58,7 @@
"pino": "^9.4.0", "pino": "^9.4.0",
"pino-http": "^10.3.0", "pino-http": "^10.3.0",
"redis": "^4.6.0", "redis": "^4.6.0",
"sqlite3": "^5.1.6", "sqlite3": "^5.1.7",
"tesseract.js": "^7.0.0", "tesseract.js": "^7.0.0",
"web-push": "^3.6.7" "web-push": "^3.6.7"
}, },
@@ -127,11 +127,11 @@
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^6.4.0", "@testing-library/jest-dom": "^6.4.0",
"@testing-library/react": "^14.2.0", "@testing-library/react": "^14.2.0",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.7.0",
"jsdom": "^24.0.0", "jsdom": "^24.0.0",
"vite": "^5.0.8", "vite": "^5.0.8",
"vite-plugin-pwa": "^1.3.0", "vite-plugin-pwa": "^1.3.0",
"vitest": "^1.6.0" "vitest": "^1.6.1"
} }
}, },
"apps/frontend-mobile": { "apps/frontend-mobile": {