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
This commit is contained in:
Antoni Nuñez Romeu
2026-07-27 14:32:05 +02:00
parent d43cbfa44e
commit e3e7d2f60b
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)
})
})