diff --git a/apps/backend/__tests__/hours.test.js b/apps/backend/__tests__/hours.test.js
new file mode 100644
index 0000000..5ebcef3
--- /dev/null
+++ b/apps/backend/__tests__/hours.test.js
@@ -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' });
+ });
+});
diff --git a/apps/backend/__tests__/pharmacy-hours-endpoint.test.js b/apps/backend/__tests__/pharmacy-hours-endpoint.test.js
new file mode 100644
index 0000000..6c8b964
--- /dev/null
+++ b/apps/backend/__tests__/pharmacy-hours-endpoint.test.js
@@ -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)
+ })
+})
diff --git a/apps/backend/package.json b/apps/backend/package.json
index e7cd4c7..e8dbdee 100644
--- a/apps/backend/package.json
+++ b/apps/backend/package.json
@@ -44,7 +44,7 @@
"pino": "^9.4.0",
"pino-http": "^10.3.0",
"redis": "^4.6.0",
- "sqlite3": "^5.1.6",
+ "sqlite3": "^5.1.7",
"tesseract.js": "^7.0.0",
"web-push": "^3.6.7"
},
diff --git a/apps/backend/server.js b/apps/backend/server.js
index 1d890e6..771015f 100644
--- a/apps/backend/server.js
+++ b/apps/backend/server.js
@@ -27,6 +27,7 @@ import { searchMedicines, getMedicineDetails, searchOTC } from './cima-service.j
import { runFarmaciaWebhookImport, DEFAULT_FARMACIAS_WEBHOOK, importPharmaciesFromRows } from './farmacias-webhook-import.js';
import { fetchPharmaciesExternal } from '../API/index.js';
import { validateProductionEnv } from './src/config/required-env.js';
+import { isOpenNow, isAlwaysOpen } from './src/hours.js';
validateProductionEnv();
@@ -226,6 +227,17 @@ function serializeOpeningHours(value) {
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
async function initDatabase() {
try {
@@ -658,7 +670,7 @@ app.get('/api/medicines/:medicineId/pharmacies', async (req, res) => {
`);
}
- res.json(pharmacies);
+ res.json(pharmacies.map(enrichPharmacy));
} catch (error) {
console.error('Error fetching pharmacies:', 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) {
console.error('Error fetching pharmacies for product:', error);
res.status(500).json({ error: 'Internal server error' });
@@ -920,7 +932,7 @@ app.get('/api/pharmacies', async (req, res) => {
const pharmacies = await userDbAll(`
SELECT * FROM pharmacies ORDER BY name
`);
- res.json(pharmacies);
+ res.json(pharmacies.map(enrichPharmacy));
} catch (error) {
console.error('Error fetching pharmacies:', error);
res.status(500).json({ error: 'Internal server error' });
diff --git a/apps/backend/src/hours.js b/apps/backend/src/hours.js
new file mode 100644
index 0000000..c1a1450
--- /dev/null
+++ b/apps/backend/src/hours.js
@@ -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] };
+}
diff --git a/apps/frontend/package.json b/apps/frontend/package.json
index 5b0462f..8a092c8 100644
--- a/apps/frontend/package.json
+++ b/apps/frontend/package.json
@@ -28,10 +28,10 @@
"devDependencies": {
"@testing-library/jest-dom": "^6.4.0",
"@testing-library/react": "^14.2.0",
- "@vitejs/plugin-react": "^4.2.1",
+ "@vitejs/plugin-react": "^4.7.0",
"jsdom": "^24.0.0",
"vite": "^5.0.8",
"vite-plugin-pwa": "^1.3.0",
- "vitest": "^1.6.0"
+ "vitest": "^1.6.1"
}
}
diff --git a/apps/frontend/src/components/PharmacyList.css b/apps/frontend/src/components/PharmacyList.css
index 1176a2c..ff5c411 100644
--- a/apps/frontend/src/components/PharmacyList.css
+++ b/apps/frontend/src/components/PharmacyList.css
@@ -110,6 +110,11 @@
color: var(--on-surface-variant);
}
+.pharmacy-hours--unknown {
+ color: var(--on-surface-variant);
+ opacity: 0.6;
+}
+
.pharmacy-pricing {
display: flex;
justify-content: space-between;
diff --git a/apps/frontend/src/components/PharmacyList.jsx b/apps/frontend/src/components/PharmacyList.jsx
index 54c883e..8728dd1 100644
--- a/apps/frontend/src/components/PharmacyList.jsx
+++ b/apps/frontend/src/components/PharmacyList.jsx
@@ -107,7 +107,10 @@ function PharmacyCard({ pharmacy, distanceKm, medicine, currentUser, onLoginRequ
return (
-
🏥 {pharmacy.name}
+
+ 🏥 {pharmacy.name}
+ {pharmacy.is_24h && 24h}
+
{distanceKm != null && (
{formatDistance(distanceKm)}
@@ -142,7 +145,7 @@ function PharmacyCard({ pharmacy, distanceKm, medicine, currentUser, onLoginRequ
{openStatus && (
- {openStatus.label}
+ {openStatus.labelKey && openStatus.labelParams ? t(openStatus.labelKey, openStatus.labelParams) : openStatus.label}
)}
📍 {pharmacy.address}
diff --git a/apps/frontend/src/components/PharmacyMap.jsx b/apps/frontend/src/components/PharmacyMap.jsx
index 7182157..7538138 100644
--- a/apps/frontend/src/components/PharmacyMap.jsx
+++ b/apps/frontend/src/components/PharmacyMap.jsx
@@ -31,7 +31,7 @@ function PharmacyMap({ pharmacies }) {
{located.map(pharmacy => (
- {pharmacy.name}
+ {pharmacy.name} {pharmacy.is_24h && 24h}
{pharmacy.address}
{pharmacy.phone && <>
{pharmacy.phone}>}
diff --git a/apps/frontend/src/components/admin/PharmacyManagement.jsx b/apps/frontend/src/components/admin/PharmacyManagement.jsx
index 9cd7b1f..9d24b3c 100644
--- a/apps/frontend/src/components/admin/PharmacyManagement.jsx
+++ b/apps/frontend/src/components/admin/PharmacyManagement.jsx
@@ -1,49 +1,8 @@
-import React, { useState, useEffect, useMemo } from 'react';
+import React, { useState, useEffect, useMemo, useCallback } from 'react';
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';
-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 */
function haversineMeters(lat1, lon1, lat2, lon2) {
const R = 6371000;
@@ -629,6 +588,22 @@ function PharmacyManagement() {