Files
FarmaFinder/PLAN.md
T
Antoni Nuñez Romeu 5114c0bc57
Run Tests on Branches / Backend Tests (push) Successful in 3m29s
Run Tests on Branches / Frontend Tests (push) Successful in 3m25s
Cleanup modified & misisng files
2026-07-02 20:41:50 +02:00

11 KiB
Raw Blame History

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(), sets userPosition, sets sortByDistance = 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 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

# 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.jsinitDatabase()

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)

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 (MonSun), 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.jsinitDatabase()

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 handlervite-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-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

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

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.