From a1e52aa910fb8241c34fed9b3f4e1407a4510cca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Tue, 19 May 2026 18:27:16 +0200 Subject: [PATCH] Plan.md for later --- PLAN.md | 363 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 363 insertions(+) create mode 100644 PLAN.md diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..ab6af64 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,363 @@ +# FarmaFinder — Feature Plan + +## Overview + +Three features in recommended implementation order. Each is self-contained and can be shipped independently. + +--- + +## Step 1 — "Near Me" Sorting (~2 hrs) + +### Goal +Sort pharmacy results by distance from user's current location. Works in browser today, same logic reusable in React Native later. + +### Backend changes +None. `latitude` and `longitude` already returned by `GET /api/medicines/:nregistro/pharmacies`. + +### Frontend changes + +**`frontend/src/utils/geo.js`** (new) +```js +export function haversineKm(lat1, lon1, lat2, lon2) { + const R = 6371; + const dLat = ((lat2 - lat1) * Math.PI) / 180; + const dLon = ((lon2 - lon1) * Math.PI) / 180; + const a = + Math.sin(dLat / 2) ** 2 + + Math.cos((lat1 * Math.PI) / 180) * + Math.cos((lat2 * Math.PI) / 180) * + Math.sin(dLon / 2) ** 2; + return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); +} + +export function getUserPosition() { + return new Promise((resolve, reject) => { + if (!navigator.geolocation) return reject(new Error('Geolocation not supported')); + navigator.geolocation.getCurrentPosition( + pos => resolve({ lat: pos.coords.latitude, lon: pos.coords.longitude }), + reject, + { timeout: 8000 } + ); + }); +} +``` + +**`frontend/src/views/PublicView.jsx`** +- Add state: `userPosition` (null | { lat, lon }), `sortByDistance` (bool) +- Add "Sort by distance" button — on click calls `getUserPosition()`, sets `userPosition`, sets `sortByDistance = true` +- Before rendering pharmacies, if `sortByDistance && userPosition`: + ```js + const sorted = [...pharmacies].sort((a, b) => { + if (!a.latitude) return 1; + if (!b.latitude) return -1; + return haversineKm(userPosition.lat, userPosition.lon, a.latitude, a.longitude) + - haversineKm(userPosition.lat, userPosition.lon, b.latitude, b.longitude); + }); + ``` +- Pass `sorted` (or original `pharmacies`) to `PharmacyList` and `PharmacyMap` + +**`frontend/src/components/PharmacyList.jsx`** +- Accept optional `userPosition` prop +- When present and pharmacy has lat/lng, show distance badge: `1.2 km` next to pharmacy name + +### Verification +```bash +# Start app, search a medicine, click "Sort by distance", allow geolocation +# Pharmacies reorder, distance badges appear +``` + +--- + +## Step 2 — Pharmacy Hours (~3 hrs) + +### Goal +Store structured opening hours per pharmacy. Show "Open now" / "Closed" / "Opens at HH:MM" badge on pharmacy cards. + +### Data model + +Store hours as JSON in a single column — structured enough to parse, simple enough to edit manually in admin: + +```json +{ + "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 +} +``` + +`null` = closed that day. 24h format strings. Only one time range per day (no split shifts) — extend later if needed. + +### Backend changes + +**`backend/server.js`** — `initDatabase()` +```sql +ALTER TABLE pharmacies ADD COLUMN opening_hours TEXT; +``` +Wrap in try/catch — `ALTER TABLE` throws if column already exists. + +**`GET /api/medicines/:nregistro/pharmacies`** — already returns `SELECT *`, no change needed. + +**`POST /api/admin/pharmacies`** and **`PUT /api/admin/pharmacies/:id`** +- Accept `opening_hours` in request body (JSON string or object) +- Serialize to string before INSERT/UPDATE: `JSON.stringify(opening_hours) || null` + +### Frontend changes + +**`frontend/src/utils/hours.js`** (new) +```js +const DAYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']; + +export function getOpenStatus(openingHoursJson) { + if (!openingHoursJson) return null; + let hours; + try { hours = typeof openingHoursJson === 'string' ? JSON.parse(openingHoursJson) : openingHoursJson; } + catch { return null; } + + const now = new Date(); + const day = DAYS[now.getDay()]; + const range = hours[day]; + + if (!range) return { status: 'closed', label: 'Closed today' }; + + const [openStr, closeStr] = range; + const [oh, om] = openStr.split(':').map(Number); + const [ch, cm] = closeStr.split(':').map(Number); + const nowMins = now.getHours() * 60 + now.getMinutes(); + const openMins = oh * 60 + om; + const closeMins = ch * 60 + cm; + + if (nowMins < openMins) return { status: 'closed', label: `Opens at ${openStr}` }; + if (nowMins >= closeMins) return { status: 'closed', label: `Closed · Opens ${getNextOpen(hours, now)}` }; + return { status: 'open', label: `Open · Closes at ${closeStr}` }; +} +``` + +**`frontend/src/components/PharmacyList.jsx`** +- Import `getOpenStatus`, render status badge per pharmacy card +- CSS: `.badge-open { color: green }` / `.badge-closed { color: #999 }` + +**`frontend/src/components/admin/PharmacyManagement.jsx`** +- Add hours editor: 7 rows (Mon–Sun), each with two time inputs (open/close) + "Closed" checkbox +- Serialize to JSON on save + +### Verification +```bash +# Add pharmacy with hours via admin panel +# Search medicine linked to that pharmacy +# Card shows "Open · Closes at 21:00" or "Closed · Opens at 09:00" +# Change system clock or test at different times +``` + +--- + +## Step 3 — PWA + Push Notifications / Medicine Alerts (~1 day) + +### Goal +User subscribes to a medicine. When any pharmacy links that medicine (via admin panel), the user receives a push notification in the browser. Foundation reusable for React Native (swap `web-push` for FCM). + +### Architecture + +``` +User visits app + → service worker registered (PWA) + → user searches medicine, clicks "Notify me" + → browser prompts for notification permission + → browser generates push subscription (endpoint + keys) + → POST /api/notifications/subscribe { medicine_nregistro, subscription } + → stored in DB + +Admin links medicine to pharmacy (POST /api/admin/pharmacy-medicines) + → backend queries notifications table for medicine_nregistro + → fires web-push to each subscription endpoint + → user receives notification: "Ibuprofeno 400mg now available at Farmacia Sol" +``` + +### Backend changes + +**Dependencies** +```bash +npm install web-push +``` + +Generate VAPID keys (one-time, store in `.env`): +```bash +node -e "const wp = require('web-push'); const k = wp.generateVAPIDKeys(); console.log(k)" +``` +Add to `.env.example`: +``` +VAPID_PUBLIC_KEY= +VAPID_PRIVATE_KEY= +VAPID_EMAIL=mailto:admin@example.com +``` + +**`backend/server.js`** — `initDatabase()` +```sql +CREATE TABLE IF NOT EXISTS push_subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + medicine_nregistro TEXT NOT NULL, + endpoint TEXT NOT NULL, + p256dh TEXT NOT NULL, + auth TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(medicine_nregistro, endpoint) +); +``` + +**New routes** +``` +POST /api/notifications/subscribe + body: { medicine_nregistro, medicine_name, subscription: { endpoint, keys: { p256dh, auth } } } + → upsert into push_subscriptions + → 201 + +DELETE /api/notifications/unsubscribe + body: { medicine_nregistro, endpoint } + → delete from push_subscriptions + → 204 + +GET /api/notifications/vapid-public-key + → returns { publicKey: process.env.VAPID_PUBLIC_KEY } +``` + +**Hook into `POST /api/admin/pharmacy-medicines`** +After successful insert: +```js +const subs = await dbAll( + 'SELECT * FROM push_subscriptions WHERE medicine_nregistro = ?', + [medicine_nregistro] +); +for (const sub of subs) { + await webpush.sendNotification( + { endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } }, + JSON.stringify({ + title: 'Medicine available', + body: `${medicine_name} now available at ${pharmacyName}`, + url: '/' + }) + ).catch(() => { + // subscription expired — delete it + dbRun('DELETE FROM push_subscriptions WHERE id = ?', [sub.id]); + }); +} +``` + +### Frontend changes + +**Dependencies** +```bash +npm install vite-plugin-pwa +``` + +**`frontend/vite.config.js`** +```js +import { VitePWA } from 'vite-plugin-pwa'; + +// Add to plugins array: +VitePWA({ + registerType: 'autoUpdate', + manifest: { + name: 'FarmaFinder', + short_name: 'FarmaFinder', + theme_color: '#2563eb', + icons: [{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' }], + }, + workbox: { + globPatterns: ['**/*.{js,css,html,ico,png,svg}'], + }, +}) +``` + +**`frontend/src/utils/notifications.js`** (new) +```js +export async function getVapidKey() { + const res = await fetch('/api/notifications/vapid-public-key'); + const { publicKey } = await res.json(); + return publicKey; +} + +export function urlBase64ToUint8Array(base64String) { + const padding = '='.repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); + return Uint8Array.from(atob(base64), c => c.charCodeAt(0)); +} + +export async function subscribeToPush(medicineNregistro, medicineName) { + const reg = await navigator.serviceWorker.ready; + const publicKey = await getVapidKey(); + const subscription = await reg.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(publicKey), + }); + await fetch('/api/notifications/subscribe', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + medicine_nregistro: medicineNregistro, + medicine_name: medicineName, + subscription, + }), + }); +} +``` + +**`frontend/src/components/MedicineResults.jsx`** +- Add "Notify me" bell button per medicine result +- On click: call `subscribeToPush(medicine.id, medicine.name)` +- Toggle state: subscribed / not subscribed (persist in `localStorage`) + +**Service worker push handler** — `vite-plugin-pwa` injects the SW; add a custom handler: +```js +// frontend/src/sw.js (custom SW additions via injectManifest mode) +self.addEventListener('push', event => { + const data = event.data.json(); + event.waitUntil( + self.registration.showNotification(data.title, { + body: data.body, + icon: '/icon-192.png', + data: { url: data.url }, + }) + ); +}); + +self.addEventListener('notificationclick', event => { + event.notification.close(); + event.waitUntil(clients.openWindow(event.notification.data.url)); +}); +``` + +### React Native path (future) +- Replace `web-push` with Firebase Admin SDK (`firebase-admin`) +- Replace `push_subscriptions.endpoint/p256dh/auth` with `fcm_token` +- Frontend subscription logic moves to Expo `registerForPushNotificationsAsync()` +- Backend notification dispatch abstracted into `notify(medicineNregistro, message)` function — same interface, different transport + +### Verification +```bash +# Start app over HTTPS (required for push — use ngrok or deploy) +# Search medicine, click bell icon +# Browser prompts notification permission — allow +# In admin: link that medicine to a pharmacy +# Browser notification fires: "Ibuprofeno 400mg now available at Farmacia Sol" +``` + +--- + +## Recommended Order + +| Step | Time | Depends on | +|------|------|-----------| +| 1. Near me sorting | ~2 hrs | Nothing | +| 2. Pharmacy hours | ~3 hrs | Nothing (can run parallel with step 1) | +| 3. PWA + alerts | ~1 day | Steps 1 and 2 done (better UX in notification: "open now, 1.2 km away") | + +--- + +## Notes + +- Push notifications require HTTPS in production. In development use ngrok or set `CHROME_FLAGS=--unsafely-treat-insecure-origin-as-secure=http://localhost:3000` for testing. +- VAPID keys are per-environment — generate once, never rotate unless necessary (rotation invalidates all existing subscriptions). +- The `UNIQUE(medicine_nregistro, endpoint)` constraint on `push_subscriptions` prevents duplicate alerts if user clicks "Notify me" multiple times.