diff --git a/.gitea/workflows/docker.yaml b/.gitea/workflows/docker.yaml index 2ccf5a6..6a72615 100644 --- a/.gitea/workflows/docker.yaml +++ b/.gitea/workflows/docker.yaml @@ -254,30 +254,16 @@ jobs: set -euo pipefail git pull - - name: Sync .env files from .env.example + - name: Inject .env files from Gitea variables working-directory: /docker/FarmaFinder + env: + GITEA_TOKEN: ${{ vars.GITEA_TOKEN }} + GITEA_OWNER: Ichitux + GITEA_REPO: FarmaFinder + WORK_DIR: /docker/FarmaFinder run: | set -euo pipefail - while IFS= read -r env_example; do - dir=$(dirname "$env_example") - env_file="$dir/.env" - - if [ ! -f "$env_file" ]; then - cp "$env_example" "$env_file" - echo "[env-sync] Created $env_file from $env_example" - else - while IFS= read -r line || [ -n "$line" ]; do - case "$line" in - ''|\#*) continue ;; - esac - key=$(echo "$line" | cut -d'=' -f1) - if ! grep -qF "$key=" "$env_file"; then - echo "$line" >> "$env_file" - echo "[env-sync] Added missing key '$key' to $env_file" - fi - done < "$env_example" - fi - done < <(find apps -name ".env.example" -type f) + bash scripts/deploy-env.sh - name: Deploy containers working-directory: /docker/FarmaFinder diff --git a/.mimocode/.cron-lock b/.mimocode/.cron-lock index e609bc4..6ca6b2d 100644 --- a/.mimocode/.cron-lock +++ b/.mimocode/.cron-lock @@ -1 +1 @@ -{"pid":568607,"startedAt":1784046408782} \ No newline at end of file +{"pid":3854791,"startedAt":1784736547988} \ No newline at end of file diff --git a/README.md b/README.md index d9069f0..cf84d1b 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,44 @@ cp .env.example .env docker compose up --build ``` +### Production Environment Variables (IMPORTANT) + +The root `.env` file is the **source of truth** for all Docker Compose services. The `docker-compose.yml` uses `${VAR:?...}` syntax which reads from this file. **Do not use placeholder values in production** — the backend validates them on startup and will crash. + +Required variables to set with real secrets: + +```env +# PostgreSQL password (used by postgres, backend, n8n, exporters) +PG_PASSWORD= + +# Backend session secret (required, non-placeholder) +SESSION_SECRET= + +# Backend CORS origin (must be your real domain, not localhost) +CORS_ORIGIN=https://farmacias.hacecalor.net + +# N8N admin password +N8N_PASSWORD= + +# Parapharmacy API keys (required for product ingestion) +INGEST_API_KEY= +ADMIN_API_KEY= +``` + +Generate secrets with: +```bash +node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +``` + +**After changing `PG_PASSWORD`**, you must reset the PostgreSQL volume: +```bash +docker compose down +docker volume rm farmafinder_postgres_data +docker compose up -d +``` + +Then re-seed data (see "First Run" below). + ### Services | Service | URL | Description | @@ -201,10 +239,27 @@ docker compose up --build docker compose exec backend node create-admin.js # Default: admin / admin123 -# Seed sample pharmacies +# Seed sample pharmacies (SQLite — for local dev) docker compose exec backend node seed.js + +# Seed parapharmacy products (requires INGEST_API_KEY) +# The n8n-init container handles this automatically on first run. +# To re-seed manually after a PostgreSQL reset: +docker compose exec parapharmacy-api node -e " +const http = require('http'); +const fs = require('fs'); +const seed = JSON.parse(fs.readFileSync('/home/node/seed.json','utf8')); +const body = JSON.stringify(seed); +const req = http.request('http://localhost:3002/api/products/bulk', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-service-key': process.env.INGEST_API_KEY, 'Content-Length': Buffer.byteLength(body) } +}, res => { let d=''; res.on('data',c=>d+=c); res.on('end',()=>console.log(d)); }); +req.write(body); req.end(); +" ``` +After a PostgreSQL volume reset, the n8n-init container will re-import workflows automatically. The backend re-creates its PG tables on startup (`initDatabase()` in `server.js`). + ### N8N Setup N8N auto-creates an admin account on first start: @@ -225,6 +280,16 @@ docker compose down docker compose down -v ``` +### Reset PostgreSQL Only (keep other data) + +```bash +docker compose down +docker volume rm farmafinder_postgres_data +docker compose up -d +# n8n-init re-imports workflows; backend re-creates tables on startup +# Re-seed parapharmacy products (see First Run above) +``` + ## Manual Setup ### 1. Install Redis @@ -325,13 +390,27 @@ See [Parapharmacy Documentation](docs/parapharmacy.md) for details. ## Database Schema -### SQLite Tables +In production (Docker), the backend uses **PostgreSQL**. In local dev without PG, it falls back to **SQLite**. -**pharmacies**: `id`, `name`, `address`, `phone`, `latitude`, `longitude` +### PostgreSQL Tables (Production) + +**pharmacies**: `id`, `name`, `address`, `phone`, `latitude`, `longitude`, `opening_hours` **pharmacy_medicines**: `id`, `pharmacy_id`, `medicine_nregistro`, `medicine_name`, `price`, `stock` -**users**: `id`, `username`, `password_hash`, `created_at` +**users**: `id`, `username`, `password_hash`, `is_admin`, `address`, `latitude`, `longitude`, `created_at` + +**user_alerts**: `id`, `user_id`, `type`, `medicine_nregistro`, `title`, `detail`, `schedule`, `created_at`, `updated_at` + +**push_subscriptions**: `id`, `user_id`, `medicine_nregistro`, `medicine_name`, `endpoint`, `p256dh`, `auth`, `created_at` + +**push_subscriptions_pharmacy**: `id`, `user_id`, `medicine_nregistro`, `medicine_name`, `pharmacy_id`, `endpoint`, `p256dh`, `auth`, `created_at` + +**expo_push_tokens**: `id`, `user_id`, `expo_token`, `created_at` + +### SQLite Tables (Local Dev Fallback) + +Same schema as above minus foreign key constraints and PostgreSQL-specific types. ### Redis Cache 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 7bbd577..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(); @@ -88,7 +89,7 @@ const sessionConfig = { resave: false, saveUninitialized: false, cookie: { - secure: process.env.NODE_ENV === 'production' || process.env.COOKIE_SECURE === 'true', + secure: process.env.COOKIE_SECURE !== 'false' && (process.env.NODE_ENV === 'production' || process.env.COOKIE_SECURE === 'true'), sameSite: 'lax', httpOnly: true, maxAge: 24 * 60 * 60 * 1000 // 24 hours @@ -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() {
{t('admin.pharmacy.openingHours')}

{t('admin.pharmacy.dayClosed')}

+ {DAY_KEYS.map((day) => { const d = hoursDraft[day]; return ( diff --git a/apps/frontend/src/i18n/locales/ca.js b/apps/frontend/src/i18n/locales/ca.js index d092d6d..d204f79 100644 --- a/apps/frontend/src/i18n/locales/ca.js +++ b/apps/frontend/src/i18n/locales/ca.js @@ -66,6 +66,17 @@ const ca = { 'pharmacy.notifyWhenArrives': 'Notificar-me quan arribi a aquesta farmàcia', '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.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 '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.geocodificationNotFound': 'Servei de geocodificació no trobat. Actualitzeu el backend i reinicieu-lo.', '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.saveError': 'Error en desar farmàcia', 'admin.pharmacy.radius': 'Radi (m)', diff --git a/apps/frontend/src/i18n/locales/es.js b/apps/frontend/src/i18n/locales/es.js index e683a57..860ca6c 100644 --- a/apps/frontend/src/i18n/locales/es.js +++ b/apps/frontend/src/i18n/locales/es.js @@ -66,6 +66,17 @@ const es = { 'pharmacy.notifyWhenArrives': 'Notificarme cuando llegue a esta farmacia', '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.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 '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.geocodingNotFound': 'Servicio de geocodificación no encontrado. Actualiza el backend y reinícialo.', '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.saveError': 'Error al guardar farmacia', 'admin.pharmacy.radius': 'Radio (m)', diff --git a/apps/frontend/src/utils/hours.js b/apps/frontend/src/utils/hours.js index 15bfbc8..9ac8c4f 100644 --- a/apps/frontend/src/utils/hours.js +++ b/apps/frontend/src/utils/hours.js @@ -27,14 +27,12 @@ function parseHours(raw) { } } -function findNextOpen(hours, now) { +function findNextOpenInfo(hours, now) { for (let offset = 1; offset <= 7; offset++) { const day = DAYS[(now.getDay() + offset) % 7]; const range = hours[day]; if (Array.isArray(range) && range.length === 2) { - const openStr = range[0]; - if (offset === 1) return `mañana a las ${openStr}`; - return `${DAY_LABELS[day]} a las ${openStr}`; + return { day, time: range[0], offset }; } } return null; @@ -42,14 +40,24 @@ function findNextOpen(hours, now) { export function getOpenStatus(rawHours, now = new Date()) { 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 range = hours[day]; if (!Array.isArray(range) || range.length !== 2) { - const next = findNextOpen(hours, now); - return { status: 'closed', label: next ? `Cerrado · Abre ${next}` : 'Cerrado' }; + const next = findNextOpenInfo(hours, now); + 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]); @@ -59,15 +67,81 @@ export function getOpenStatus(rawHours, now = new Date()) { const nowMins = now.getHours() * 60 + now.getMinutes(); 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) { - const next = findNextOpen(hours, now); - return { status: 'closed', label: next ? `Cerrado · Abre ${next}` : 'Cerrado' }; + const next = findNextOpenInfo(hours, now); + 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() { 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; +} diff --git a/apps/frontend/src/views/PublicView.jsx b/apps/frontend/src/views/PublicView.jsx index 6be6bc5..998cdac 100644 --- a/apps/frontend/src/views/PublicView.jsx +++ b/apps/frontend/src/views/PublicView.jsx @@ -31,6 +31,7 @@ function PublicView({ const [userPosition, setUserPosition] = useState(null); const [positionSource, setPositionSource] = useState(null); // 'profile' | 'browser' const [sortByDistance, setSortByDistance] = useState(false); + const [openNow, setOpenNow] = useState(false); const [locating, setLocating] = useState(false); const [locationError, setLocationError] = useState(''); @@ -177,8 +178,12 @@ function PublicView({ }; const displayedPharmacies = useMemo(() => { - if (!sortByDistance || !userPosition) return pharmacies; - return [...pharmacies].sort((a, b) => { + let filtered = pharmacies; + 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 (b.latitude == null || b.longitude == null) return -1; return ( @@ -186,7 +191,7 @@ function PublicView({ haversineKm(userPosition.lat, userPosition.lon, b.latitude, b.longitude) ); }); - }, [pharmacies, sortByDistance, userPosition]); + }, [pharmacies, openNow, sortByDistance, userPosition]); /* ── Scanner → Search handoff ──────────────────────────── */ function handleScanSelectMedicine(medicineName) { @@ -323,6 +328,13 @@ function PublicView({ {pharmacies.length > 0 && (
+