# 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.