11 KiB
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)
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(), setsuserPosition, setssortByDistance = true - Before rendering pharmacies, if
sortByDistance && userPosition: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 originalpharmacies) toPharmacyListandPharmacyMap
frontend/src/components/PharmacyList.jsx
- Accept optional
userPositionprop - When present and pharmacy has lat/lng, show distance badge:
1.2 kmnext to pharmacy name
Verification
# 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:
{
"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()
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_hoursin 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)
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
# 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
npm install web-push
Generate VAPID keys (one-time, store in .env):
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()
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:
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
npm install vite-plugin-pwa
frontend/vite.config.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)
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:
// 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-pushwith Firebase Admin SDK (firebase-admin) - Replace
push_subscriptions.endpoint/p256dh/authwithfcm_token - Frontend subscription logic moves to Expo
registerForPushNotificationsAsync() - Backend notification dispatch abstracted into
notify(medicineNregistro, message)function — same interface, different transport
Verification
# 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:3000for testing. - VAPID keys are per-environment — generate once, never rotate unless necessary (rotation invalidates all existing subscriptions).
- The
UNIQUE(medicine_nregistro, endpoint)constraint onpush_subscriptionsprevents duplicate alerts if user clicks "Notify me" multiple times.