diff --git a/.github/workflows/backend-tests.yml b/.github/workflows/backend-tests.yml
index 6388447..1553d2f 100644
--- a/.github/workflows/backend-tests.yml
+++ b/.github/workflows/backend-tests.yml
@@ -27,8 +27,8 @@ jobs:
run: |
cd backend
python -m pip install --upgrade pip
- pip install -r requirements.txt
+ pip install -r requirements-dev.txt
- name: Run Tests
run: |
cd backend
- # pytest (if tests are added)
+ pytest -q
diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml
index 13c5e56..92ecdf2 100644
--- a/.github/workflows/frontend-ci.yml
+++ b/.github/workflows/frontend-ci.yml
@@ -24,7 +24,10 @@ jobs:
with:
node-version: ${{ matrix.node-version }}
- name: Install Dependencies
- run: cd frontend && npm ci
+ # npm install (not npm ci) tolerates cross-platform optional-dependency
+ # lock drift — the lock is generated across Windows/macOS/Linux dev boxes
+ # and native bindings (Tailwind oxide, rolldown) differ per platform.
+ run: cd frontend && npm install
- name: Build
run: cd frontend && npm run build
# - name: Test
diff --git a/.github/workflows/pr-guard.yml b/.github/workflows/pr-guard.yml
index 4b53465..26ca74d 100644
--- a/.github/workflows/pr-guard.yml
+++ b/.github/workflows/pr-guard.yml
@@ -9,7 +9,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- - name: Check for merge conflicts
+ - name: Fail if leftover conflict markers are present
run: |
- echo "Checking merge conflicts..."
- # Add conflict check logic here
+ echo "Scanning for unresolved merge conflict markers..."
+ if git grep -nE '^(<<<<<<<|=======|>>>>>>>)' -- . ':(exclude).github/**'; then
+ echo "::error::Unresolved merge conflict markers found. Resolve them before merging."
+ exit 1
+ fi
+ echo "No conflict markers found."
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..dfeabd0
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,53 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+serveo.log
+surge.log
+surge_redeploy.log
+tunnel.log
+
+# Dependency directories
+node_modules/
+jspm_packages/
+
+# Build outputs
+dist/
+dist-ssr/
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea/
+.DS_Store
+*.suo
+*.ntvsca*
+*.njsproj
+*.sln
+*.sw?
+
+# Environment files
+.env
+.env.local
+.env.development.local
+.env.test.local
+.env.production.local
+.env.production
+
+# Python Virtual Environments & Caches
+venv/
+.venv/
+env/
+ENV/
+__pycache__/
+*.pyc
+*.pyo
+*.pyd
+.pytest_cache/
+.mypy_cache/
+.ipynb_checkpoints/
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
new file mode 100644
index 0000000..c816c40
--- /dev/null
+++ b/.vscode/extensions.json
@@ -0,0 +1,5 @@
+{
+ "recommendations": [
+ "tomoki1207.pdf"
+ ]
+}
diff --git a/API_CONTRACT.md b/API_CONTRACT.md
index 08d0bdc..3272ce0 100644
--- a/API_CONTRACT.md
+++ b/API_CONTRACT.md
@@ -10,7 +10,7 @@ This is the single most important document for the team. Both the backend (You)
- The frontend **never** talks to the database directly. It only ever calls these endpoints.
- Blood groups are always strings: `"O+"`, `"O-"`, `"A+"`, `"A-"`, `"B+"`, `"B-"`, `"AB+"`, `"AB-"`.
- `status` for a hospital is always one of: `"pending"`, `"confirmed"`, `"declined"`.
-- Coordinates: `lat` and `lng` are decimal numbers (e.g. `26.9124`, `75.7873` for Jaipur).
+- Coordinates: `lat` and `lng` are decimal numbers (e.g. `26.9124`, `75.7873` for Jaipur). `lat` must be −90..90 and `lng` −180..180 — out-of-range coordinates (e.g. a failed browser geolocation) are rejected with **422**.
## Endpoint 1 — Trigger an emergency
`POST /emergency`
@@ -41,11 +41,14 @@ This is the single most important document for the team. Both the backend (You)
"phone": "+91xxxxxxxxxx"
}
],
- "donors_alerted": 5
+ "donors_alerted": 5,
+ "rare_group": false
}
```
- `hospitals` is an array, already sorted best-first by the backend.
- `donors_alerted` is just a count (integer) for the UI to display.
+- `rare_group` (boolean) is `true` when the needed group is Rh-negative (scarce
+ supply); the UI can warn that compatible donors may be few. Safe to ignore.
## Endpoint 2 — Poll / subscribe to emergency status
`GET /emergency/{request_id}/status`
@@ -59,9 +62,35 @@ This is the single most important document for the team. Both the backend (You)
{ "hospital_id": "h2", "name": "Fortis Jaipur", "eta_minutes": 9, "status": "pending" }
],
"donors_alerted": 5,
- "donors_responded": 2
+ "donors_responded": 2,
+ "unconfirmed_fallback": false
+}
+```
+- `unconfirmed_fallback` (boolean) becomes `true` once ~3 minutes pass with no
+ hospital confirmed. Cue for the UI to surface the nearest hospitals with a
+ 1-tap call button (the phones from the `POST /emergency` response). Safe to ignore.
+
+## Endpoint 3a — Hospital confirmation page details
+`GET /confirm/{token}`
+
+Hit when the hospital contact **opens** their link, so the page can show the real
+emergency details (instead of placeholders).
+
+**Backend returns:**
+```json
+{
+ "hospital_name": "SMS Hospital",
+ "emergency_type": "trauma",
+ "blood_group": "O+",
+ "eta_minutes": 6,
+ "already_confirmed": false,
+ "responded": false,
+ "accepted": false
}
```
+- `already_confirmed` is `true` if another hospital already took this patient.
+- `responded` is `true` if this hospital has already replied; `accepted` is its choice.
+- Unknown token → **404**.
## Endpoint 3 — Hospital taps Accept / Decline
`POST /confirm/{token}`
@@ -91,10 +120,14 @@ This is hit by the hospital confirmation page when the contact taps a button.
"blood_group": "B+",
"lat": 26.9200,
"lng": 75.8000,
- "last_donated": "2025-01-15"
+ "last_donated": "2025-01-15",
+ "sex": "female"
}
```
`last_donated` is a date string "YYYY-MM-DD", or null if never donated.
+`sex` is **optional** — `"male"` or `"female"`. It only sets the donation
+cooldown (120 days for women, 90 otherwise). Omit it and the backend defaults to
+90 days; existing forms need no change.
**Backend returns:**
```json
diff --git a/DEPLOY.md b/DEPLOY.md
new file mode 100644
index 0000000..d13eb4a
--- /dev/null
+++ b/DEPLOY.md
@@ -0,0 +1,109 @@
+# Deploying GoldenHour (backend + database)
+
+Two ways to run in production. Pick one.
+
+- **Demo mode** — deploy the backend with no Supabase variables. It serves the
+ seeded in-memory store (25 hospitals, 180 donors). Every endpoint works;
+ nothing persists. Lowest-risk path for a hackathon demo.
+- **Database mode** — back it with Supabase (PostgreSQL + PostGIS) for real
+ persistence and Supabase Realtime. Do the database steps below first.
+
+Check which mode is live any time with `GET /health` (`"mode": "supabase"` vs
+`"in-memory"`) and confirm the DB is actually reachable with `GET /ready`.
+
+---
+
+## 1. Database — Supabase (skip for demo mode)
+
+1. Create a project at [supabase.com](https://supabase.com) (remember the DB password).
+2. **SQL Editor** → run [`backend/sql/schema.sql`](backend/sql/schema.sql) — creates
+ the 4 tables, the PostGIS spatial indexes, and the `donors_nearby` RPC.
+3. **SQL Editor** → run [`backend/sql/seed.sql`](backend/sql/seed.sql) — loads the
+ 25 hospitals + 180 donors. (Runs as table owner, so it bypasses RLS — more
+ reliable than the anon-key Python seeder.)
+4. Enable **Realtime** on the `confirmation_requests` table
+ (Database → Replication, or the table's Realtime toggle). This is what makes
+ the patient's screen flip live when a hospital accepts.
+5. Collect credentials from **Project Settings**:
+ - **API** → Project URL → `SUPABASE_URL`
+ - **API** → `service_role` secret → `SUPABASE_SERVICE_KEY` (backend uses this)
+ - **API** → `anon` public key → `SUPABASE_ANON_KEY` (frontend uses this)
+ - **Database** → Connection string (URI) → `DATABASE_URL`
+
+> Alternative to step 3: seed from your machine with
+> `cd backend && python seed_supabase.py` (needs `SUPABASE_URL` +
+> `SUPABASE_SERVICE_KEY` in `.env`). Prefer the SQL editor if anon RLS blocks inserts.
+
+---
+
+## 2. Backend — Render
+
+The repo ships a blueprint at [`render.yaml`](render.yaml).
+
+1. [render.com](https://render.com) → **New → Blueprint** → connect the
+ `GoldenHour` repo. Render reads `render.yaml` automatically.
+2. Set the `sync: false` environment variables:
+
+ | Variable | Demo mode | Database mode |
+ | --- | --- | --- |
+ | `SUPABASE_URL` | *(blank)* | from Supabase |
+ | `SUPABASE_SERVICE_KEY` | *(blank)* | service_role secret |
+ | `SUPABASE_ANON_KEY` | *(blank)* | anon key (optional for backend) |
+ | `DATABASE_URL` | *(blank)* | Supabase connection URI |
+ | `GOOGLE_MAPS_API_KEY` | *(blank → estimated ETAs)* | optional, real ETAs |
+ | `MSG91_AUTH_KEY` | *(blank)* | optional, SMS |
+ | `FRONTEND_URL` | your frontend URL | your frontend URL |
+ | `CORS_ORIGINS` | `*` or frontend URL | frontend URL |
+
+3. **Create** and wait for the build. The API is live at
+ `https://goldenhour-api.onrender.com` (or your chosen name).
+
+The same image runs on Fly.io, or locally: `docker compose up --build`.
+
+---
+
+## 3. Verify
+
+```bash
+# Liveness + which mode is active
+curl https://goldenhour-api.onrender.com/health
+# {"status":"ok","version":"0.1.0","mode":"supabase"}
+
+# Readiness — actually pings the database (503 if unreachable)
+curl https://goldenhour-api.onrender.com/ready
+# {"ready":true,"backend":"supabase"}
+
+# Smoke-test the main flow
+curl -X POST https://goldenhour-api.onrender.com/emergency \
+ -H "Content-Type: application/json" \
+ -d '{"lat":26.9124,"lng":75.7873,"emergency_type":"trauma","blood_group":"O+"}'
+```
+
+Interactive API docs are at `…/docs`.
+
+---
+
+## 4. Git flow (you are the Git owner)
+
+Backend work lands on `backend-dev`; you merge to `main`:
+
+```bash
+git checkout main
+git pull origin main
+git merge backend-dev
+git push origin main
+```
+
+Your teammate works only on `frontend-dev` and pushes there; you merge it into
+`main` the same way. Their app reaches this backend via
+`VITE_API_BASE_URL=https://goldenhour-api.onrender.com`.
+
+---
+
+## Security note
+
+The backend connects with the **service-role** key (`SUPABASE_SERVICE_KEY`),
+which bypasses RLS — keep it server-side only, never in the frontend. To harden
+the database, enable RLS with read-only `anon` policies (see the commented block
+at the end of [`backend/sql/schema.sql`](backend/sql/schema.sql)); backend writes
+still succeed via the service-role key.
diff --git a/DESIGN.md b/DESIGN.md
new file mode 100644
index 0000000..f7d87a1
--- /dev/null
+++ b/DESIGN.md
@@ -0,0 +1,50 @@
+# GoldenHour Visual Identity & Design System
+
+GoldenHour's visual language balances **calm urgency**—trustworthy like a medical device, fast like a 911 dispatch console. The interface is optimized for mobile-first, one-handed, glove-friendly usage during critical moments.
+
+---
+
+## 🎨 Color Palette
+
+| Token | CSS Variable | Hex Code | Purpose |
+| :--- | :--- | :--- | :--- |
+| **bg** | `--color-bg` | `#FBFAF8` | Warm paper background for readability |
+| **surface** | `--color-surface` | `#FFFFFF` | Card backgrounds, separated by soft shadows (no harsh borders) |
+| **ink** | `--color-ink` | `#1A1714` | High-contrast readable typography |
+| **muted** | `--color-ink-muted` | `#6B6560` | Secondary metadata and labels |
+| **emergency** | `--color-emergency` | `#DC2626` | Primary CTAs, urgent warnings |
+| **pressed** | `--color-emergency-pressed` | `#B91C1C` | Active/Pressed states for emergency actions |
+| **success** | `--color-success` | `#059669` | Confirmed responses, lock status |
+| **pending** | `--color-pending` | `#9CA3AF` | Warm gray for loading, pending, inactive states |
+| **golden hour** | `--color-accent` | `#F59E0B` | Brand mark and subtle highlighting |
+
+---
+
+## 📐 Shape & Spacing
+
+* **Border Radius:**
+ * Cards: `rounded-2xl` (`1rem`)
+ * Buttons: `rounded-xl` (`0.75rem`)
+* **Touch Targets:**
+ * Primary Action Buttons: Minimum height of `56px` (`h-14`) for easy tap states.
+* **Shadows:**
+ * Soft layered shadows to create depth without borders:
+ ```css
+ box-shadow: 0 1px 3px rgba(26, 23, 20, 0.02), 0 8px 24px rgba(26, 23, 20, 0.04);
+ ```
+
+---
+
+## ✍️ Typography
+
+* **Font Family:** Inter throughout (tightly tracked headings).
+* **Headings:** Bold, tight-tracked (`tracking-tight`), text sizes `text-3xl` or `text-4xl`.
+* **Body:** Generous line heights for readability.
+
+---
+
+## 🎬 Motion
+
+* **Buttons:** Click/tap feedback scale down (`scale-95`).
+* **Transitions:** Spring transitions for status/state changes (`framer-motion`).
+* **Card Entrances:** Staggered fade and slide-up transition.
diff --git a/README.md b/README.md
index 9484b4e..a1bf279 100644
--- a/README.md
+++ b/README.md
@@ -1,19 +1,190 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
# GoldenHour
-GoldenHour is a Progressive Web Application and feature-phone SMS service that converts a single GPS-triggered emergency request into two simultaneous parallel actions:
-1. Locating and confirming a hospital with the correct department and a free bed.
-2. Alerting nearby replacement-blood donors of a compatible blood group.
+> One tap. Two lifelines. The right hospital and the right blood — before the car even moves.
+
+**GoldenHour** is a Progressive Web App + feature-phone SMS service that converts
+a single GPS-triggered emergency request into two simultaneous, parallel actions:
+
+1. **Locate and confirm a hospital** with the correct department and a free bed.
+2. **Alert nearby replacement-blood donors** of a compatible blood group.
+
+It is built for the **self-transporting family in India** — the majority of
+emergency patients who travel to hospital by private car or auto, entirely
+outside any ambulance system. The "golden hour" after a trauma is when minutes
+decide outcomes; GoldenHour spends those minutes finding a hospital that can
+actually take the patient instead of driving to one that can't.
+
+**Tags:** `Healthcare Technology` · `Social Impact` · `Emergency Response` · `Bharat Academix CodeQuest 2026` · `FastAPI` · `React` · `Supabase` · `PostGIS` · `PWA`
+
+---
+
+## How it works (end-to-end)
+
+
+
+
+
+A single `POST /emergency` fans out into both branches at once. On the **hospital**
+side, the backend ranks nearby hospitals and sends each a one-tap confirmation
+link; the **first to Accept "takes" the patient**, and later acceptances are told
+the patient is already routed. On the **blood** side, it matches compatible donors
+within range and alerts the nearest, directing them to the hospital's licensed
+blood bank. The patient's screen updates live the instant a hospital confirms.
+
+---
+
+## System architecture
+
+GoldenHour is a two-part system split cleanly by ownership, sharing one contract.
+The backend itself is a strict three-layer design with two interchangeable data
+stores behind a single `get_store()` — so the demo runs with **zero external
+services** and the production path is a true drop-in, no service-code changes:
+
+
+
+
+
+- **`API_CONTRACT.md`** is the single source of truth both sides build against.
+ The backend's `schemas.py` and the frontend's `fetch()` calls mirror it exactly.
+- **The frontend never talks to the database** — only to the API.
+- **The backend runs with zero external services in demo mode** (in-memory store
+ seeded with Jaipur hospitals + donors), and swaps to Supabase/PostGIS, Google
+ Maps, and an SMS gateway when their env vars are present — no code changes.
+
+See [`backend/README.md`](backend/README.md) for the service-layer architecture
+and the three algorithms, and [`frontend/README.md`](frontend/README.md) for the
+screens and live-update wiring.
+
+---
+
+## Repository structure
+
+```
+GoldenHour/
+├── API_CONTRACT.md # SHARED contract — exact API request/response shapes
+├── README.md # You are here
+├── LICENSE
+├── docs/ # README diagrams (banner, flow, architecture SVGs)
+├── render.yaml # Backend deploy config (Render)
+├── vercel.json # Frontend deploy config (Vercel)
+├── docker-compose.yml # One-command local backend (demo mode)
+│
+├── backend/ # FastAPI service — DB, algorithms, integration
+│ ├── main.py # Routes (thin HTTP layer)
+│ ├── middleware.py # CORS
+│ ├── config.py # Env-driven settings + demo fallbacks
+│ ├── schemas.py # Pydantic models (= API_CONTRACT.md)
+│ ├── blood.py # ABO/Rh compatibility table
+│ ├── geo.py # Haversine distance
+│ ├── store.py # Data layer (InMemoryStore + SupabaseStore)
+│ ├── seed_data.py # ~25 hospitals + ~180 donors (deterministic)
+│ ├── seed_supabase.py # Seed a real Supabase DB
+│ ├── services/ # Business logic (ranking, matching, confirm, …)
+│ ├── sql/schema.sql # PostGIS tables, indexes, RPC
+│ ├── tests/ # pytest (algorithms + contract + live flow)
+│ ├── Dockerfile
+│ └── .env.example
+│
+├── frontend/ # React + Vite + Tailwind PWA
+│ ├── index.html # Shell + meta/description/OG/PWA tags
+│ ├── src/
+│ │ ├── App.jsx # Mobile shell + routes
+│ │ └── components/ # PatientIntake, PatientResults, HospitalConfirm, DonorRegistration
+│ ├── vite.config.js
+│ └── package.json
+│
+└── .github/workflows/ # CI: backend tests, frontend CI, PR guard
+```
+
+---
+
+## Tech stack
+
+| Layer | Technology |
+| --- | --- |
+| Frontend | React 19, Vite, Tailwind CSS, React Router, Supabase JS client |
+| Backend | Python 3.12, FastAPI, Uvicorn, Pydantic v2 |
+| Database | PostgreSQL + PostGIS (via Supabase), Supabase Realtime |
+| Maps / ETA | Google Maps Distance Matrix API |
+| Messaging | Console / Telegram (demo), MSG91 / Gupshup + DLT (production) |
+| Infra | Docker, Render (API), Vercel (PWA), GitHub Actions CI |
+
+---
+
+## Quickstart
+
+### Backend (demo mode — no setup, no database)
+
+```bash
+cd backend
+python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
+pip install -r requirements.txt
+uvicorn main:app --reload --port 8000
+```
+
+Open , trigger an emergency, and watch the hospital
+confirmation links print to the console (`GET /dev/links`) and the donor alerts
+(`GET /dev/alerts`). Run the tests with `pytest`.
+
+Or, with Docker, from the repo root: `docker compose up --build`.
+
+### Frontend
+
+```bash
+cd frontend
+npm install
+npm run dev # http://localhost:5173
+```
+
+Point it at the API by creating `frontend/.env.local` with
+`VITE_API_BASE_URL=http://localhost:8000`.
+
+---
+
+## API at a glance
+
+Full shapes in [`API_CONTRACT.md`](API_CONTRACT.md).
+
+| Method & path | Purpose |
+| --- | --- |
+| `POST /emergency` | Trigger — rank hospitals, alert donors, send confirmation links (`rare_group` flag) |
+| `GET /emergency/{request_id}/status` | Live status — hospital replies + donor responses (`unconfirmed_fallback` flag) |
+| `POST /confirm/{token}` | Hospital taps Accept / Not Available |
+| `POST /donor/register` | Register a replacement-blood donor (optional `sex` for cooldown) |
+| `POST /sms/inbound` | Feature-phone SMS path (stretch) |
+| `GET /health` · `GET /ready` | Liveness (mode) · readiness (pings the DB) |
+| `GET /dev/links` · `GET /dev/alerts` | Demo: links sent to hospitals · alerts sent to donors |
+
+---
-It is built for the self-transporting family in India — the majority of emergency patients who travel to hospital by private car or auto, entirely outside any ambulance system.
+## Deployment
-## Project Structure
+| Component | Platform | Config |
+| --- | --- | --- |
+| Backend API | Render (or Fly.io) | [`render.yaml`](render.yaml) — Python runtime, `rootDir: backend` |
+| Frontend PWA | Vercel | [`vercel.json`](vercel.json) — builds `frontend/`, SPA rewrites |
+| Database | Supabase | Run [`backend/sql/schema.sql`](backend/sql/schema.sql), enable Realtime |
-- `backend/`: FastAPI backend with PostgreSQL + PostGIS (via Supabase) orchestrating routing, confirmation logic, and donor matching.
-- `frontend/`: React + Vite + Tailwind CSS PWA containing the Patient App, Hospital Confirmation Page, and Donor Registration interfaces.
+Production secrets (`SUPABASE_*`, `GOOGLE_MAPS_API_KEY`, `MSG91_AUTH_KEY`,
+`VITE_API_BASE_URL`) are set in the respective dashboards — never committed.
+See [`DEPLOY.md`](DEPLOY.md) for the full Supabase + Render runbook.
-## Setup
+---
-See `backend/README.md` and `frontend/README.md` (to be created) for specific setup instructions.
+## License
-## Tags
-Healthcare Technology, Social Impact, Bharat Academix CodeQuest 2026, FastApi, React, Supabase, PostGIS
+[MIT](LICENSE).
diff --git a/backend/.dockerignore b/backend/.dockerignore
new file mode 100644
index 0000000..a990f74
--- /dev/null
+++ b/backend/.dockerignore
@@ -0,0 +1,9 @@
+__pycache__/
+*.pyc
+*.pyo
+.pytest_cache/
+.env
+.venv/
+venv/
+tests/
+*.md
diff --git a/backend/.env.example b/backend/.env.example
new file mode 100644
index 0000000..78dd0e9
--- /dev/null
+++ b/backend/.env.example
@@ -0,0 +1,42 @@
+# GoldenHour backend configuration.
+# Copy to `.env` and fill in to enable each subsystem. With everything blank the
+# API runs in demo mode (in-memory store, estimated ETAs, console link delivery).
+
+# --- Supabase / PostgreSQL + PostGIS ---------------------------------------
+# Set SUPABASE_URL + a key to switch the data layer onto the real database.
+SUPABASE_URL=
+SUPABASE_ANON_KEY=
+# Trusted server-side key — preferred over the anon key for the backend
+# (bypasses RLS). Keep secret; never expose to the frontend.
+SUPABASE_SERVICE_KEY=
+# Direct Postgres connection (used by seed_supabase.py / migrations).
+DATABASE_URL=
+
+# --- Google Maps Distance Matrix (real drive-time ETAs) --------------------
+GOOGLE_MAPS_API_KEY=
+
+# --- SMS gateway (MSG91 / Gupshup) — stretch goal --------------------------
+MSG91_AUTH_KEY=
+
+# --- Confirmation link delivery --------------------------------------------
+# Public URL of the frontend; confirmation links are FRONTEND_URL/confirm/
+FRONTEND_URL=http://localhost:5173
+# console | telegram | msg91
+DELIVERY_CHANNEL=console
+TELEGRAM_BOT_TOKEN=
+TELEGRAM_CHAT_ID=
+
+# --- CORS ------------------------------------------------------------------
+# Comma-separated allowed origins; * for local dev.
+CORS_ORIGINS=*
+
+# --- Algorithm tuning (optional; sensible defaults baked in) ----------------
+RANKING_TOP_N=5
+ETA_MAX_MINUTES=30
+DONOR_RADIUS_METERS=5000
+DONOR_COOLDOWN_DAYS=90
+DONOR_COOLDOWN_DAYS_FEMALE=120
+DONOR_ALERT_K=10
+REL_ACTIVATION_THRESHOLD=20
+UNCONFIRMED_FALLBACK_SECONDS=180
+CITY_AVG_SPEED_KMPH=22
diff --git a/backend/.gitignore b/backend/.gitignore
new file mode 100644
index 0000000..fb0ed8c
--- /dev/null
+++ b/backend/.gitignore
@@ -0,0 +1,31 @@
+# Python
+__pycache__/
+*.py[cod]
+*$py.class
+*.egg-info/
+.eggs/
+build/
+dist/
+
+# Virtual environments
+.venv/
+venv/
+env/
+
+# Test / tooling caches
+.pytest_cache/
+.mypy_cache/
+.ruff_cache/
+.coverage
+htmlcov/
+
+# Environment / secrets — never commit real keys
+.env
+.env.*
+!.env.example
+
+# Editor / OS
+.vscode/
+.idea/
+*.swp
+.DS_Store
diff --git a/backend/Dockerfile b/backend/Dockerfile
new file mode 100644
index 0000000..e32b277
--- /dev/null
+++ b/backend/Dockerfile
@@ -0,0 +1,20 @@
+# GoldenHour API — single-stage image. One-command setup per the PRD.
+FROM python:3.12-slim
+
+ENV PYTHONUNBUFFERED=1 \
+ PYTHONDONTWRITEBYTECODE=1 \
+ PIP_NO_CACHE_DIR=1
+
+WORKDIR /app
+
+# Install deps first for better layer caching.
+COPY requirements.txt .
+RUN pip install --upgrade pip && pip install -r requirements.txt
+
+# App code.
+COPY . .
+
+EXPOSE 8000
+
+# Render/Fly inject $PORT; default to 8000 locally.
+CMD ["sh", "-c", "uvicorn main:app --host 0.0.0.0 --port ${PORT:-8000}"]
diff --git a/backend/README.md b/backend/README.md
new file mode 100644
index 0000000..628d315
--- /dev/null
+++ b/backend/README.md
@@ -0,0 +1,253 @@
+# GoldenHour — Backend
+
+FastAPI service that turns one GPS-triggered emergency into two parallel actions:
+
+1. **Find a hospital** with the right department and a free bed, ranked best-first, and ask each to confirm.
+2. **Alert nearby compatible blood donors** who are off their post-donation cooldown.
+
+It owns everything the user doesn't see: the database, the ranking and matching
+algorithms, the confirmation/messaging flow, and live updates to the frontend.
+
+> **Runs with zero setup.** With no environment variables the API boots in
+> **demo mode** — an in-memory store seeded with ~25 Jaipur hospitals and ~180
+> donors, estimated ETAs, and console link delivery. Add Supabase / Google Maps
+> / SMS credentials to switch each subsystem to the real service. No code
+> changes, no database required for the demo or for CI.
+
+---
+
+## Tech stack
+
+| Piece | Tool | Why |
+| --- | --- | --- |
+| Language | Python 3.12 | — |
+| API framework | FastAPI + Uvicorn | Async, fast, auto OpenAPI docs |
+| Validation | Pydantic v2 | Request/response shapes = the API contract |
+| Database (prod) | PostgreSQL + PostGIS via Supabase | Geography radius queries + Realtime |
+| Maps / ETA (prod) | Google Maps Distance Matrix | Real drive-time ETA |
+| Phone parsing | `phonenumbers` | Normalise donor numbers to E.164 |
+| Container | Docker | One-command setup |
+
+---
+
+## Architecture
+
+A strict three-layer design — HTTP in, services in the middle, data at the bottom:
+
+
+
+
+
+```
+ HTTP (FastAPI routes) ── main.py
+ │ validate with schemas.py, call ONE service, return a schema
+ ▼
+ Service layer ── services/
+ │ ranking · matching · confirmation · orchestration · ETA · SMS
+ ▼
+ Data layer ── store.py
+ in-memory now (seed_data.py) · Supabase-ready (sql/schema.sql)
+```
+
+**Rules that keep it clean:**
+
+- **Routes are thin.** Every handler in `main.py` validates input with a schema,
+ calls exactly one service function, and returns a schema. No business logic.
+- **Services never touch HTTP.** They take a `store` + plain values and return
+ plain dicts, so they're trivially unit-testable without a client.
+- **All data access goes through `store.py`.** Its method surface mirrors what a
+ Supabase-backed store would expose (`compatible_donors_nearby`,
+ `create_emergency`, …), so the demo store and a real DB store are
+ interchangeable behind `get_store()`.
+- **Schemas are the contract.** `schemas.py` mirrors `API_CONTRACT.md` field for
+ field. Change one, change both, tell the frontend owner.
+
+### Two interchangeable stores
+
+`store.py` ships **both** a `InMemoryStore` and a `SupabaseStore` implementing the
+identical method surface; `get_store()` picks one from configuration (Supabase
+when `SUPABASE_URL`/`SUPABASE_ANON_KEY` are set and reachable, in-memory
+otherwise — with automatic fallback if the client can't be created, so boot
+never hard-fails). The radius + scoring maths is identical whether it runs in
+Python (haversine) or PostGIS (`ST_DWithin`/`ST_Distance`), so the demo runs
+end-to-end with no external services, CI needs no database, and the production
+path (`sql/schema.sql` + `seed_supabase.py`) is a true drop-in — no service code
+changes.
+
+---
+
+## Repository structure
+
+```
+backend/
+├── main.py # FastAPI app + routes (thin HTTP layer)
+├── middleware.py # CORS (origins from settings)
+├── config.py # Settings from env; demo-mode fallbacks
+├── schemas.py # Pydantic request/response models (= API_CONTRACT.md)
+├── blood.py # ABO/Rh compatibility table
+├── geo.py # Haversine distance helpers
+├── store.py # Data layer: InMemoryStore + SupabaseStore + get_store()
+├── seed_data.py # Deterministic ~25 hospitals + ~180 donors
+├── seed_supabase.py # Push seed data into a real Supabase DB (anon/service key)
+├── build_seed_sql.py # Generate sql/seed.sql from seed_data.py
+├── services/
+│ ├── emergency_service.py # Orchestrates POST /emergency + status
+│ ├── hospital_service.py # Hospital ranking algorithm
+│ ├── donor_service.py # Blood matching + donor registration
+│ ├── confirm_service.py # Accept/decline race resolution
+│ ├── geocoder.py # ETA (Google Maps or estimate) + area geocode
+│ ├── sms_service.py # Link delivery + inbound SMS parse
+│ ├── realtime_service.py # Supabase Realtime publish (no-op in demo)
+│ ├── phone_utils.py # E.164 phone normalisation
+│ ├── cache.py # Tiny TTL cache
+│ └── rate_limiter.py # Fixed-window rate limiter
+├── sql/
+│ ├── schema.sql # PostGIS tables, indexes, donors_nearby() RPC, RLS notes
+│ └── seed.sql # Generated SQL seed (run after schema.sql)
+├── tests/ # pytest: units + endpoint/contract/flow tests
+├── requirements.txt
+├── Dockerfile
+└── .env.example
+```
+
+---
+
+## API endpoints
+
+All shapes are defined by [`../API_CONTRACT.md`](../API_CONTRACT.md). Interactive
+docs are served at `/docs` (Swagger) and `/redoc` when the app is running.
+
+| Method & path | Purpose |
+| --- | --- |
+| `POST /emergency` | Trigger an emergency — rank hospitals, alert donors, send confirmation links. |
+| `GET /emergency/{request_id}/status` | Live status — hospital replies + donor responses (poll or Supabase Realtime). |
+| `POST /confirm/{token}` | A hospital contact taps Accept (`true`) / Not Available (`false`). |
+| `POST /donor/register` | Register a replacement-blood donor. |
+| `POST /sms/inbound` | Feature-phone path (stretch): parse an SMS, reply with nearest hospitals. |
+| `GET /health` | Liveness + which mode the app is running in (cheap, no I/O). |
+| `GET /ready` | Readiness — pings the data layer; 503 if the DB is unreachable. |
+| `GET /dev/links` | Demo helper: the confirmation links recently "sent" to hospitals. |
+| `GET /dev/alerts` | Demo helper: the blood-needed alerts recently "sent" to donors. |
+
+---
+
+## The three algorithms
+
+**1. Hospital ranking** (`services/hospital_service.py`) — for the needed
+department, score each nearby hospital and return the top N:
+
+```
+P(h) = 0.5 · prox(h) + 0.3 · dept(h) + 0.2 · rel(h)
+
+prox(h) = 1 − (eta / eta_max), clamped to [0, 1] # closer is better
+dept(h) = 1 if the needed department is present else 0
+rel(h) = live acceptance rate # historically reliable
+```
+
+**Reliability cold start.** `rel(h)` only enters the score once a hospital has
+logged ≥ `REL_ACTIVATION_THRESHOLD` (default 20) confirmations — we have no
+honest reliability signal before then. Until a hospital crosses that bar the
+score degrades to `prox + dept`, renormalised to span [0, 1]. At launch (no
+confirmations yet) ranking is pure proximity + department. If nothing confirms
+within ~3 minutes the status response flags `unconfirmed_fallback` so the UI can
+surface the nearest hospitals with a 1-tap call — never a false "bed available."
+
+**2. Blood matching** (`services/donor_service.py` + `blood.py`) — compatible
+donor groups (hardcoded ABO table), available, within 5 km, and off the
+sex-aware cooldown (90 days men / 120 days women — `last_donated IS NULL OR
+last_donated < today − cooldown`), nearest first. The nearest top-K are then
+alerted (`services/sms_service.py`) with directions to the hospital's **licensed
+blood bank**, not the emergency ward. Rh-negative requests are flagged `rare_group`.
+
+**3. Confirmation flow** (`services/confirm_service.py`) — the first hospital to
+accept "takes" the patient; any later acceptance gets `already_confirmed: true`
+and does not override the winner. The accepted hospital's status flips
+`pending → confirmed`, which the frontend sees via polling or Realtime.
+
+---
+
+## Running it
+
+### Local (demo mode, no setup)
+
+```bash
+cd backend
+python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
+pip install -r requirements.txt
+uvicorn main:app --reload --port 8000
+```
+
+Open . Trigger an emergency and watch the
+confirmation links print to the console (or hit `GET /dev/links`).
+
+### Docker
+
+```bash
+# from repo root
+docker compose up --build # backend on :8000
+
+# or just the backend image
+cd backend && docker build -t goldenhour-api . && docker run -p 8000:8000 goldenhour-api
+```
+
+### With a real Supabase + Maps stack
+
+1. Create a Supabase project; run [`sql/schema.sql`](sql/schema.sql) in the SQL editor.
+2. Run [`sql/seed.sql`](sql/seed.sql) in the SQL editor to load hospitals + donors
+ (or `python seed_supabase.py` from your machine).
+3. Enable **Realtime** on the `confirmation_requests` table.
+4. Copy `.env.example` → `.env` and fill in `SUPABASE_URL`, `SUPABASE_SERVICE_KEY`
+ (preferred for the backend), `GOOGLE_MAPS_API_KEY` (and `MSG91_AUTH_KEY` for SMS).
+5. Start the app — `GET /health` reports `"mode": "supabase"`; `GET /ready`
+ confirms the DB is reachable.
+
+See [`../DEPLOY.md`](../DEPLOY.md) for the full Render + Supabase runbook.
+
+---
+
+## Configuration
+
+Everything is environment-driven (see [`.env.example`](.env.example)). Highlights:
+
+| Variable | Default | Effect |
+| --- | --- | --- |
+| `SUPABASE_URL` + a key | — | Set URL + a key → use the real database. |
+| `SUPABASE_SERVICE_KEY` | — | Preferred backend key (bypasses RLS); falls back to anon. |
+| `SUPABASE_ANON_KEY` | — | Anon key; used if no service key is set. |
+| `GOOGLE_MAPS_API_KEY` | — | Set → real drive-time ETAs (else estimated). |
+| `DELIVERY_CHANNEL` | `console` | `console` \| `telegram` \| `msg91`. |
+| `FRONTEND_URL` | `http://localhost:5173` | Base for `/confirm/` links. |
+| `CORS_ORIGINS` | `*` | Comma-separated allowed origins. |
+| `DONOR_RADIUS_METERS` | `5000` | Donor search radius. |
+| `DONOR_COOLDOWN_DAYS` | `90` | Post-donation cooldown (men / default). |
+| `DONOR_COOLDOWN_DAYS_FEMALE` | `120` | Post-donation cooldown for women. |
+| `DONOR_ALERT_K` | `10` | Nearest matched donors actually alerted per emergency. |
+| `REL_ACTIVATION_THRESHOLD` | `20` | Confirmations a hospital needs before `rel(h)` counts. |
+| `UNCONFIRMED_FALLBACK_SECONDS` | `180` | No-confirmation window before status flags the 1-tap-call fallback. |
+| `RANKING_TOP_N` | `5` | Hospitals returned per emergency. |
+
+---
+
+## Testing
+
+```bash
+cd backend
+pip install -r requirements-dev.txt
+pytest # 30 tests: algorithms + endpoint contract + live flow + Supabase path
+```
+
+The suite runs fully offline (no Supabase, no network) and verifies the exact
+`API_CONTRACT.md` response shapes, the ranking/matching algorithms, and the
+end-to-end emergency → confirm → status flip. The **`SupabaseStore` production
+path is also tested** against a fake supabase client
+(`tests/fake_supabase.py`), so column names, query chains, and key mapping are
+verified without a live database. CI runs the same suite on Python 3.11 and 3.12.
+
+---
+
+## Deployment
+
+[`../render.yaml`](../render.yaml) defines the Render web service (Python runtime,
+`rootDir: backend`, `uvicorn main:app`). Set the `SUPABASE_*`, `GOOGLE_MAPS_API_KEY`,
+and `MSG91_AUTH_KEY` env vars in the Render dashboard. The same Docker image runs
+on Fly.io.
diff --git a/backend/blood.py b/backend/blood.py
new file mode 100644
index 0000000..c3e954c
--- /dev/null
+++ b/backend/blood.py
@@ -0,0 +1,45 @@
+"""ABO/Rh blood-group compatibility.
+
+For a patient who *needs* a given blood group, which donor groups can safely
+donate? This is the hardcoded "ABO table" referenced in the backend PRD. It is
+intentionally a plain dictionary — there is no clinical edge case here worth a
+database lookup, and keeping it in code makes the donor query trivially fast.
+"""
+
+from __future__ import annotations
+
+from typing import List
+
+ALL_GROUPS: List[str] = ["O-", "O+", "A-", "A+", "B-", "B+", "AB-", "AB+"]
+
+# Rh-negative groups are scarce in India (~5% of the population). Flag them so
+# the UI can warn that compatible donors may be few and rare-group blood banks
+# should be contacted in parallel.
+RARE_GROUPS = frozenset({"O-", "A-", "B-", "AB-"})
+
+
+def is_rare_group(recipient_group: str) -> bool:
+ """True when the needed blood group is Rh-negative (scarce supply)."""
+ return recipient_group in RARE_GROUPS
+
+
+# recipient blood group -> list of donor groups that may donate to them
+_COMPATIBLE_DONORS = {
+ "O-": ["O-"],
+ "O+": ["O-", "O+"],
+ "A-": ["O-", "A-"],
+ "A+": ["O-", "O+", "A-", "A+"],
+ "B-": ["O-", "B-"],
+ "B+": ["O-", "O+", "B-", "B+"],
+ "AB-": ["O-", "A-", "B-", "AB-"],
+ "AB+": ALL_GROUPS, # universal recipient
+}
+
+
+def compatible_donor_groups(recipient_group: str) -> List[str]:
+ """Donor blood groups that can give to ``recipient_group``.
+
+ Unknown groups return an empty list so callers fail safe (alert nobody)
+ rather than alerting everybody.
+ """
+ return _COMPATIBLE_DONORS.get(recipient_group, [])
diff --git a/backend/build_seed_sql.py b/backend/build_seed_sql.py
new file mode 100644
index 0000000..3f61235
--- /dev/null
+++ b/backend/build_seed_sql.py
@@ -0,0 +1,85 @@
+"""Generate sql/seed.sql from seed_data.py.
+
+Run from the backend/ directory: python build_seed_sql.py
+
+Produces an idempotent SQL seed (INSERT ... ON CONFLICT DO NOTHING) that can be
+pasted into the Supabase SQL editor right after sql/schema.sql. Running it in the
+SQL editor executes as the table owner, so it bypasses RLS — more reliable than
+seeding over the anon key with seed_supabase.py.
+"""
+
+from __future__ import annotations
+
+import seed_data
+
+
+def _esc(text: str) -> str:
+ return text.replace("'", "''")
+
+
+def _array_literal(items) -> str:
+ inner = ", ".join("'%s'" % _esc(i) for i in items)
+ return f"ARRAY[{inner}]::text[]"
+
+
+def build() -> str:
+ out = [
+ "-- GoldenHour seed data (AUTO-GENERATED from seed_data.py — do not edit by hand).",
+ "-- Run AFTER sql/schema.sql. Idempotent: re-running is safe.",
+ "",
+ "-- Hospitals -----------------------------------------------------------------",
+ "insert into hospitals (id, name, lat, lng, departments, beds_available, "
+ "avg_response_rate, phone, contact_phone) values",
+ ]
+ rows = []
+ for h in seed_data.hospitals():
+ rows.append(
+ " ('{id}', '{name}', {lat}, {lng}, {depts}, {beds}, {rel}, '{phone}', '{contact}')".format(
+ id=h["id"],
+ name=_esc(h["name"]),
+ lat=h["lat"],
+ lng=h["lng"],
+ depts=_array_literal(h["departments"]),
+ beds=h["beds_available"],
+ rel=h["avg_response_rate"],
+ phone=h["phone"],
+ contact=h["contact_phone"],
+ )
+ )
+ out.append(",\n".join(rows))
+ out.append("on conflict (id) do nothing;")
+ out.append("")
+
+ out += [
+ "-- Donors --------------------------------------------------------------------",
+ "insert into blood_donors (id, name, phone, blood_group, lat, lng, "
+ "last_donated, sex, available) values",
+ ]
+ rows = []
+ for d in seed_data.donors():
+ last = "'%s'" % d["last_donated"] if d["last_donated"] else "null"
+ sex = "'%s'" % d["sex"] if d.get("sex") else "null"
+ rows.append(
+ " ('{id}', '{name}', '{phone}', '{bg}', {lat}, {lng}, {last}, {sex}, {av})".format(
+ id=d["id"],
+ name=_esc(d["name"]),
+ phone=d["phone"],
+ bg=d["blood_group"],
+ lat=d["lat"],
+ lng=d["lng"],
+ last=last,
+ sex=sex,
+ av=str(d["available"]).lower(),
+ )
+ )
+ out.append(",\n".join(rows))
+ out.append("on conflict (id) do nothing;")
+ out.append("")
+ return "\n".join(out)
+
+
+if __name__ == "__main__":
+ sql = build()
+ with open("sql/seed.sql", "w", encoding="utf-8", newline="\n") as f:
+ f.write(sql)
+ print("Wrote sql/seed.sql")
diff --git a/backend/config.py b/backend/config.py
new file mode 100644
index 0000000..01e4789
--- /dev/null
+++ b/backend/config.py
@@ -0,0 +1,112 @@
+"""Application configuration.
+
+All runtime configuration is read from environment variables so the same image
+runs locally, in CI, and in production (Render/Fly) without code changes.
+
+Nothing here is required: with an empty environment the app boots in
+**demo mode** — an in-memory store seeded with Jaipur hospitals and donors, a
+haversine ETA estimator, and console link delivery. Set the Supabase / Google
+Maps / MSG91 variables to switch each subsystem over to the real service.
+"""
+
+from __future__ import annotations
+
+import os
+from dataclasses import dataclass, field
+from typing import List
+
+from dotenv import load_dotenv
+
+load_dotenv() # loads backend/.env into os.environ before Settings reads it
+
+
+def _env_list(name: str, default: str) -> List[str]:
+ raw = os.getenv(name, default)
+ return [item.strip() for item in raw.split(",") if item.strip()]
+
+
+@dataclass(frozen=True)
+class Settings:
+ # --- Core ---------------------------------------------------------------
+ app_name: str = "GoldenHour API"
+ version: str = "0.1.0"
+
+ # --- Supabase / PostgreSQL + PostGIS -----------------------------------
+ # When a URL + a key are present the app talks to the real database;
+ # otherwise it falls back to the in-memory store (demo mode).
+ supabase_url: str = os.getenv("SUPABASE_URL", "")
+ supabase_key: str = os.getenv("SUPABASE_ANON_KEY", "")
+ # Trusted server-side key. Preferred over the anon key for the backend's
+ # reads/writes (bypasses RLS); keep it secret — never ship it to a client.
+ supabase_service_key: str = os.getenv("SUPABASE_SERVICE_KEY", "")
+ database_url: str = os.getenv("DATABASE_URL", "")
+
+ # --- Google Maps Distance Matrix (ETA + geocoding) ---------------------
+ google_maps_api_key: str = os.getenv("GOOGLE_MAPS_API_KEY", "")
+
+ # --- SMS gateway (MSG91 / Gupshup) — stretch goal ----------------------
+ msg91_auth_key: str = os.getenv("MSG91_AUTH_KEY", "")
+
+ # --- Algorithm tuning ---------------------------------------------------
+ ranking_top_n: int = int(os.getenv("RANKING_TOP_N", "5"))
+ eta_max_minutes: float = float(os.getenv("ETA_MAX_MINUTES", "30"))
+ donor_radius_meters: float = float(os.getenv("DONOR_RADIUS_METERS", "5000"))
+ # Post-donation cooldown, differentiated by sex per national blood-donation
+ # guidelines: 90 days (men) / 120 days (women).
+ donor_cooldown_days: int = int(os.getenv("DONOR_COOLDOWN_DAYS", "90"))
+ donor_cooldown_days_female: int = int(
+ os.getenv("DONOR_COOLDOWN_DAYS_FEMALE", "120")
+ )
+ # Nearest matched donors actually alerted per emergency (top-K).
+ donor_alert_k: int = int(os.getenv("DONOR_ALERT_K", "10"))
+ # rel(h) enters the hospital score only after a hospital has logged this many
+ # confirmations; until then ranking degrades to proximity + department.
+ rel_activation_threshold: int = int(os.getenv("REL_ACTIVATION_THRESHOLD", "20"))
+ # Seconds with no confirmation before status flags the 1-tap-call fallback.
+ unconfirmed_fallback_seconds: int = int(
+ os.getenv("UNCONFIRMED_FALLBACK_SECONDS", "180")
+ )
+ city_avg_speed_kmph: float = float(os.getenv("CITY_AVG_SPEED_KMPH", "22"))
+
+ # --- OSM startup pre-warm ----------------------------------------------
+ # Semicolon-separated "lat,lng" pairs fetched from Overpass at boot (demo
+ # mode only). Pre-warms the in-memory hospital store so the first emergency
+ # in those areas is instant instead of waiting on an on-demand OSM fetch.
+ # Example: OSM_SEED_COORDS=26.14,91.81;27.47,94.91
+ osm_seed_coords: str = os.getenv("OSM_SEED_COORDS", "")
+
+ # --- Link / message delivery -------------------------------------------
+ # Where the hospital confirmation link points. The frontend serves
+ # /confirm/, so this is the public site URL.
+ frontend_url: str = os.getenv("FRONTEND_URL", "http://localhost:5173")
+ # "console" (print the link), "telegram", or "msg91".
+ delivery_channel: str = os.getenv("DELIVERY_CHANNEL", "console")
+ telegram_bot_token: str = os.getenv("TELEGRAM_BOT_TOKEN", "")
+ telegram_chat_id: str = os.getenv("TELEGRAM_CHAT_ID", "")
+
+ # --- CORS ---------------------------------------------------------------
+ cors_origins: List[str] = field(
+ default_factory=lambda: _env_list("CORS_ORIGINS", "*")
+ )
+
+ @property
+ def supabase_active_key(self) -> str:
+ """Key the backend connects with — service-role preferred, anon fallback."""
+ return self.supabase_service_key or self.supabase_key
+
+ @property
+ def use_supabase(self) -> bool:
+ """True when a real Supabase backend is configured."""
+ return bool(self.supabase_url and self.supabase_active_key)
+
+ @property
+ def use_google_maps(self) -> bool:
+ return bool(self.google_maps_api_key)
+
+ def summary(self) -> str:
+ mode = "supabase" if self.use_supabase else "in-memory (demo)"
+ eta = "google-maps" if self.use_google_maps else "haversine-estimate"
+ return f"store={mode} eta={eta} delivery={self.delivery_channel}"
+
+
+settings = Settings()
diff --git a/backend/geo.py b/backend/geo.py
new file mode 100644
index 0000000..26fc3ed
--- /dev/null
+++ b/backend/geo.py
@@ -0,0 +1,29 @@
+"""Geospatial helpers.
+
+In production the radius queries run inside PostGIS (``ST_DWithin`` /
+``ST_Distance``). In demo mode we reproduce the same maths in Python with the
+haversine formula so the app behaves identically without a database.
+"""
+
+from __future__ import annotations
+
+import math
+
+EARTH_RADIUS_M = 6_371_000.0
+
+
+def haversine_meters(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
+ """Great-circle distance between two lat/lng points, in metres."""
+ phi1, phi2 = math.radians(lat1), math.radians(lat2)
+ d_phi = math.radians(lat2 - lat1)
+ d_lambda = math.radians(lng2 - lng1)
+
+ a = (
+ math.sin(d_phi / 2) ** 2
+ + math.cos(phi1) * math.cos(phi2) * math.sin(d_lambda / 2) ** 2
+ )
+ return 2 * EARTH_RADIUS_M * math.asin(math.sqrt(a))
+
+
+def haversine_km(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
+ return haversine_meters(lat1, lng1, lat2, lng2) / 1000.0
diff --git a/backend/main.py b/backend/main.py
index e2b2bc0..6cdbade 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -1,65 +1,265 @@
-from fastapi import FastAPI
-from pydantic import BaseModel
-from typing import List, Optional
+"""GoldenHour API — FastAPI application entrypoint.
+
+Thin HTTP layer: each route validates input with a schema, calls one service,
+and returns a schema. All business logic lives in ``services/``; all data access
+lives in ``store.py``. Endpoint shapes match ``API_CONTRACT.md`` exactly.
+"""
+
+import asyncio
+import logging
+from contextlib import asynccontextmanager
+
+from fastapi import FastAPI, HTTPException
+
+from config import settings
from middleware import add_cors_middleware
+from schemas import (
+ DonorRegisterRequest,
+ DonorRegisterResponse,
+ EmergencyRequest,
+ EmergencyResponse,
+ EmergencyStatusResponse,
+ HealthResponse,
+ HospitalConfirmDetailsResponse,
+ HospitalConfirmRequest,
+ HospitalConfirmResponse,
+ SmsInboundRequest,
+ SmsInboundResponse,
+)
+from store import get_store
+
+from services import confirm_service, donor_service, emergency_service, sms_service
+
+logging.basicConfig(level=logging.INFO)
+logging.getLogger("httpx").setLevel(logging.WARNING) # suppress URLs (contain API key)
+logger = logging.getLogger("goldenhour")
+
+
+async def _prewarm_osm() -> None:
+ """Fetch hospitals from Overpass at startup for OSM_SEED_COORDS locations.
-app = FastAPI(title="GoldenHour API")
+ Runs as a background task so the API is immediately ready — the pre-warm
+ completes in the background. Only fires in in-memory (demo) mode; Supabase
+ already has real data.
+ """
+ from services.hospital_service import _fetch_hospitals_nearby as _fetch_from_osm
+
+ store = get_store()
+ if not hasattr(store, "bulk_add_hospitals"):
+ return # Supabase store — skip
+ if not settings.osm_seed_coords:
+ return
+
+ for pair in settings.osm_seed_coords.split(";"):
+ pair = pair.strip()
+ if not pair:
+ continue
+ try:
+ lat_s, lng_s = pair.split(",")
+ lat, lng = float(lat_s.strip()), float(lng_s.strip())
+ except ValueError:
+ logger.warning(
+ "OSM_SEED_COORDS: invalid pair %r — expected 'lat,lng'", pair
+ )
+ continue
+ if store.is_region_fetched(lat, lng):
+ continue
+ hospitals = await _fetch_from_osm(lat, lng)
+ if hospitals:
+ store.bulk_add_hospitals(hospitals)
+ store.mark_region_fetched(lat, lng)
+
+
+@asynccontextmanager
+async def lifespan(_app: FastAPI):
+ get_store() # warm the store (seed in-memory, or connect Supabase)
+ logger.info("GoldenHour API ready - %s", settings.summary())
+ asyncio.create_task(_prewarm_osm())
+ yield
+
+
+# OpenAPI tag groups — describe each section shown in /docs and /redoc.
+TAGS_METADATA = [
+ {
+ "name": "emergency",
+ "description": "Trigger an emergency and poll its live status. One request "
+ "fans out into hospital ranking + blood-donor matching.",
+ },
+ {
+ "name": "confirmation",
+ "description": "A hospital contact accepts or declines a patient via a "
+ "one-tap link. First to accept wins; later accepts are told it is taken.",
+ },
+ {
+ "name": "donors",
+ "description": "Register replacement-blood donors (sex sets the cooldown).",
+ },
+ {
+ "name": "sms",
+ "description": "Feature-phone fallback — parse an inbound SMS, reply with "
+ "the nearest hospitals.",
+ },
+ {
+ "name": "meta",
+ "description": "Health, readiness, and demo helpers (`/dev/links`, "
+ "`/dev/alerts`).",
+ },
+]
+
+app = FastAPI(
+ title=settings.app_name,
+ version=settings.version,
+ summary="Emergency hospital routing + replacement-blood donor alerting.",
+ description=(
+ "One GPS-triggered request finds a hospital with the right department and "
+ "a free bed **and** alerts nearby compatible blood donors — two parallel "
+ "actions inside the golden hour.\n\n"
+ "Runs in **demo mode** with zero external services (seeded in-memory store); "
+ "set the Supabase / Google Maps / SMS env vars to switch to the real stack."
+ ),
+ contact={
+ "name": "GoldenHour — Bharat Academix CodeQuest 2026",
+ "url": "https://github.com/SarmaHighOnCode/GoldenHour",
+ },
+ license_info={
+ "name": "MIT",
+ "url": "https://github.com/SarmaHighOnCode/GoldenHour/blob/main/LICENSE",
+ },
+ openapi_tags=TAGS_METADATA,
+ lifespan=lifespan,
+)
add_cors_middleware(app)
-class EmergencyRequest(BaseModel):
- lat: float
- lng: float
- emergency_type: str
- blood_group: str
-class HospitalConfirmRequest(BaseModel):
- accepted: bool
+@app.get("/", tags=["meta"], summary="Service info")
+async def root():
+ return {"service": settings.app_name, "version": settings.version, "docs": "/docs"}
-class DonorRegisterRequest(BaseModel):
- name: str
- phone: str
- blood_group: str
- lat: float
- lng: float
- last_donated: Optional[str] = None
-class SmsInboundRequest(BaseModel):
- from_number: str
- body: str
+@app.get(
+ "/health", response_model=HealthResponse, tags=["meta"], summary="Liveness probe"
+)
+async def health_check():
+ """Liveness — cheap, no I/O (safe for frequent platform health checks)."""
+ mode = "supabase" if settings.use_supabase else "in-memory"
+ return HealthResponse(status="ok", version=settings.version, mode=mode)
-@app.post("/emergency")
+
+@app.get("/ready", tags=["meta"], summary="Readiness probe (pings the data layer)")
+async def readiness_check():
+ """Readiness — verifies the data layer actually answers (pings the DB).
+
+ Use this after a deploy to confirm the API is really connected to Supabase
+ and not silently running in demo mode. Returns 503 if the store is unreachable.
+ """
+ store = get_store()
+ if not store.ping():
+ raise HTTPException(status_code=503, detail="data layer unreachable")
+ return {"ready": True, "backend": store.backend}
+
+
+@app.post(
+ "/emergency",
+ response_model=EmergencyResponse,
+ tags=["emergency"],
+ summary="Trigger an emergency",
+)
async def trigger_emergency(request: EmergencyRequest):
- return {
- "request_id": "abc123",
- "hospitals": [],
- "donors_alerted": 0
- }
+ """Trigger an emergency: rank hospitals, alert donors, send confirmations."""
+ store = get_store()
+ return await emergency_service.trigger_emergency(
+ store,
+ lat=request.lat,
+ lng=request.lng,
+ emergency_type=request.emergency_type,
+ blood_group=request.blood_group,
+ )
-@app.get("/emergency/{request_id}/status")
+
+@app.get(
+ "/emergency/{request_id}/status",
+ response_model=EmergencyStatusResponse,
+ tags=["emergency"],
+ summary="Live emergency status",
+)
async def get_emergency_status(request_id: str):
- return {
- "request_id": request_id,
- "hospitals": [],
- "donors_alerted": 0,
- "donors_responded": 0
- }
-
-@app.post("/confirm/{token}")
+ """Poll/subscribe target: live hospital replies + donor responses."""
+ try:
+ return emergency_service.get_status(get_store(), request_id)
+ except LookupError:
+ raise HTTPException(status_code=404, detail="Unknown request_id")
+
+
+@app.get(
+ "/confirm/{token}",
+ response_model=HospitalConfirmDetailsResponse,
+ tags=["confirmation"],
+ summary="Confirmation details for the hospital page",
+)
+async def get_confirmation(token: str):
+ """The emergency details a hospital sees when it opens its one-tap link."""
+ try:
+ return confirm_service.get_confirmation_details(get_store(), token)
+ except confirm_service.ConfirmationNotFound:
+ raise HTTPException(status_code=404, detail="Unknown confirmation token")
+
+
+@app.post(
+ "/confirm/{token}",
+ response_model=HospitalConfirmResponse,
+ tags=["confirmation"],
+ summary="Hospital accept / decline",
+)
async def confirm_hospital(token: str, request: HospitalConfirmRequest):
- return {
- "ok": True,
- "hospital_name": "Test Hospital",
- "already_confirmed": False
- }
+ """A hospital contact taps Accept (true) or Not Available (false)."""
+ try:
+ return confirm_service.handle_confirmation(get_store(), token, request.accepted)
+ except confirm_service.ConfirmationNotFound:
+ raise HTTPException(status_code=404, detail="Unknown confirmation token")
+
-@app.post("/donor/register")
+@app.post(
+ "/donor/register",
+ response_model=DonorRegisterResponse,
+ tags=["donors"],
+ summary="Register a blood donor",
+)
async def register_donor(request: DonorRegisterRequest):
- return {"ok": True, "donor_id": "d123"}
+ """Register a replacement-blood donor."""
+ donor_id = donor_service.register_donor(
+ get_store(),
+ name=request.name,
+ phone=request.phone,
+ blood_group=request.blood_group,
+ lat=request.lat,
+ lng=request.lng,
+ last_donated=request.last_donated,
+ sex=request.sex,
+ )
+ return DonorRegisterResponse(ok=True, donor_id=donor_id)
-@app.post("/sms/inbound")
+
+@app.post(
+ "/sms/inbound",
+ response_model=SmsInboundResponse,
+ tags=["sms"],
+ summary="Inbound SMS (feature phone)",
+)
async def handle_sms_inbound(request: SmsInboundRequest):
- return {"reply": "Nearest hospitals: ..."}
+ """Feature-phone path (stretch): parse an inbound SMS and reply."""
+ reply = sms_service.handle_inbound(
+ get_store(), from_number=request.from_number, body=request.body
+ )
+ return SmsInboundResponse(reply=reply)
-@app.get("/health")
-async def health_check():
- return {"status": "ok", "version": "0.1.0"}
+
+@app.get("/dev/links", tags=["meta"], summary="Recent hospital links (demo)")
+async def dev_links():
+ """Demo helper: the confirmation links recently 'sent' to hospitals."""
+ return {"links": sms_service.recent_links}
+
+
+@app.get("/dev/alerts", tags=["meta"], summary="Recent donor alerts (demo)")
+async def dev_alerts():
+ """Demo helper: the blood-needed alerts recently 'sent' to donors."""
+ return {"alerts": sms_service.recent_alerts}
diff --git a/backend/middleware.py b/backend/middleware.py
index a8f5188..fddc90b 100644
--- a/backend/middleware.py
+++ b/backend/middleware.py
@@ -1,11 +1,23 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
-def add_cors_middleware(app: FastAPI):
+from config import settings
+
+
+def add_cors_middleware(app: FastAPI) -> None:
+ """Allow the frontend (served from a different origin) to call the API.
+
+ Origins come from the ``CORS_ORIGINS`` env var (comma-separated), defaulting
+ to ``*`` for local development. Credentials are only enabled when an
+ explicit origin list is configured, since browsers reject
+ ``allow_credentials`` together with the ``*`` wildcard.
+ """
+ origins = settings.cors_origins
+ wildcard = origins == ["*"]
app.add_middleware(
CORSMiddleware,
- allow_origins=["*"],
- allow_credentials=True,
+ allow_origins=origins,
+ allow_credentials=not wildcard,
allow_methods=["*"],
allow_headers=["*"],
)
diff --git a/backend/pytest.ini b/backend/pytest.ini
new file mode 100644
index 0000000..555b5b9
--- /dev/null
+++ b/backend/pytest.ini
@@ -0,0 +1,8 @@
+[pytest]
+# Tests import top-level modules (main, store, services...) directly, so run
+# from the backend/ directory. `pythonpath = .` makes that work from anywhere.
+pythonpath = .
+testpaths = tests
+addopts = -q
+filterwarnings =
+ ignore::DeprecationWarning
diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt
new file mode 100644
index 0000000..4c0d345
--- /dev/null
+++ b/backend/requirements-dev.txt
@@ -0,0 +1,3 @@
+# Test/dev-only dependencies (kept out of the runtime image).
+-r requirements.txt
+pytest==8.3.3
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 7875867..0a7bcb5 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -4,3 +4,4 @@ pydantic==2.8.2
httpx==0.27.0
phonenumbers==8.13.40
supabase==2.5.1
+python-dotenv==1.0.1
diff --git a/backend/schemas.py b/backend/schemas.py
new file mode 100644
index 0000000..e471f7a
--- /dev/null
+++ b/backend/schemas.py
@@ -0,0 +1,131 @@
+"""Pydantic request/response models.
+
+These mirror ``API_CONTRACT.md`` **exactly** — the field names and shapes here
+are the contract the frontend builds against. Do not rename a field without
+updating ``API_CONTRACT.md`` and telling the frontend owner first.
+"""
+
+from __future__ import annotations
+
+from typing import Annotated, List, Literal, Optional
+
+from pydantic import BaseModel, ConfigDict, Field
+
+EmergencyType = Literal["trauma", "cardiac", "obstetric", "general"]
+BloodGroup = Literal["O+", "O-", "A+", "A-", "B+", "B-", "AB+", "AB-"]
+HospitalStatus = Literal["pending", "confirmed", "declined"]
+
+# GPS coordinates, range-checked so a failed/garbage browser geolocation is
+# rejected with 422 rather than producing nonsense distances.
+Latitude = Annotated[float, Field(ge=-90.0, le=90.0)]
+Longitude = Annotated[float, Field(ge=-180.0, le=180.0)]
+
+
+# --- POST /emergency -------------------------------------------------------
+class EmergencyRequest(BaseModel):
+ lat: Latitude
+ lng: Longitude
+ emergency_type: EmergencyType
+ blood_group: BloodGroup
+
+
+class HospitalCard(BaseModel):
+ hospital_id: str
+ name: str
+ eta_minutes: int
+ department_match: bool
+ distance_km: float
+ status: HospitalStatus
+ phone: str
+
+
+class EmergencyResponse(BaseModel):
+ request_id: str
+ hospitals: List[HospitalCard]
+ donors_alerted: int
+ # True when the needed group is Rh-negative (scarce supply) — the UI warns
+ # that compatible donors may be few and rare-group banks should be contacted.
+ rare_group: bool = False
+
+
+# --- GET /emergency/{request_id}/status ------------------------------------
+class HospitalStatusCard(BaseModel):
+ hospital_id: str
+ name: str
+ eta_minutes: int
+ status: HospitalStatus
+
+
+class EmergencyStatusResponse(BaseModel):
+ request_id: str
+ hospitals: List[HospitalStatusCard]
+ donors_alerted: int
+ donors_responded: int
+ # True after the no-confirmation window elapses with nothing confirmed — cue
+ # for the UI to surface the nearest-hospitals 1-tap-call fallback.
+ unconfirmed_fallback: bool = False
+
+
+# --- POST /confirm/{token} -------------------------------------------------
+class HospitalConfirmRequest(BaseModel):
+ accepted: bool
+
+
+class HospitalConfirmResponse(BaseModel):
+ ok: bool
+ hospital_name: str
+ already_confirmed: bool
+
+
+# --- GET /confirm/{token} --------------------------------------------------
+class HospitalConfirmDetailsResponse(BaseModel):
+ """What the hospital sees when it opens its one-tap link (before replying)."""
+
+ hospital_name: str
+ emergency_type: str
+ blood_group: str
+ eta_minutes: int
+ # True when another hospital has already taken this patient.
+ already_confirmed: bool
+ # Whether this hospital has already replied, and if so what it chose.
+ responded: bool
+ accepted: bool
+
+
+# --- POST /donor/register --------------------------------------------------
+class DonorRegisterRequest(BaseModel):
+ name: str
+ phone: str
+ blood_group: BloodGroup
+ lat: Latitude
+ lng: Longitude
+ last_donated: Optional[str] = None # "YYYY-MM-DD" or null
+ # Optional. Drives the post-donation cooldown: 120 days for "female",
+ # 90 days otherwise. Omitting it defaults to the 90-day window.
+ sex: Optional[Literal["male", "female"]] = None
+
+
+class DonorRegisterResponse(BaseModel):
+ ok: bool
+ donor_id: str
+
+
+# --- POST /sms/inbound (stretch) -------------------------------------------
+class SmsInboundRequest(BaseModel):
+ # The contract sends {"from": ..., "body": ...}; "from" is a reserved
+ # word in Python, so we expose it as ``from_number`` with an alias.
+ model_config = ConfigDict(populate_by_name=True)
+
+ from_number: str = Field(alias="from")
+ body: str
+
+
+class SmsInboundResponse(BaseModel):
+ reply: str
+
+
+# --- GET /health -----------------------------------------------------------
+class HealthResponse(BaseModel):
+ status: str
+ version: str
+ mode: str
diff --git a/backend/seed_data.py b/backend/seed_data.py
new file mode 100644
index 0000000..6582484
--- /dev/null
+++ b/backend/seed_data.py
@@ -0,0 +1,215 @@
+"""Deterministic seed data for demo mode and for the SQL seed script.
+
+~25 real Jaipur hospitals with approximate coordinates and varied departments,
+plus ~180 donors spread across all 8 blood groups and varied distances. The
+``last_donated`` dates are deliberately spread so the sex-aware cooldown filter
+(90 days men / 120 days women) visibly excludes some donors (the eligibility
+logic actually does something in the demo).
+
+Generation is seeded, so every boot and every run of ``seed_supabase.py``
+produces the same dataset — reproducible demos, stable tests.
+"""
+
+from __future__ import annotations
+
+import random
+from datetime import date, timedelta
+from typing import Dict, List
+
+from blood import ALL_GROUPS
+
+# Jaipur city centre — donors and hospitals scatter around this point.
+JAIPUR_LAT = 26.9124
+JAIPUR_LNG = 75.7873
+
+DEPARTMENTS = ["trauma", "cardiac", "obstetric", "general"]
+
+# name, lat, lng, departments, beds_available, avg_response_rate
+_HOSPITALS = [
+ ("SMS Hospital", 26.9036, 75.8147, ["trauma", "cardiac", "general"], 12, 0.92),
+ ("Fortis Escorts Jaipur", 26.8569, 75.8064, ["cardiac", "general"], 8, 0.88),
+ (
+ "Manipal Hospital Jaipur",
+ 26.8853,
+ 75.7470,
+ ["trauma", "cardiac", "obstetric", "general"],
+ 15,
+ 0.90,
+ ),
+ ("Narayana Multispeciality", 26.8312, 75.8040, ["cardiac", "general"], 6, 0.81),
+ ("Eternal Hospital", 26.8665, 75.8101, ["cardiac", "trauma", "general"], 9, 0.86),
+ (
+ "CK Birla Hospital (RBH)",
+ 26.8901,
+ 75.7686,
+ ["trauma", "obstetric", "general"],
+ 11,
+ 0.89,
+ ),
+ ("Mahatma Gandhi Hospital", 26.7700, 75.8550, ["trauma", "general"], 7, 0.78),
+ (
+ "Apex Hospital Malviya Nagar",
+ 26.8541,
+ 75.8136,
+ ["general", "obstetric"],
+ 5,
+ 0.74,
+ ),
+ (
+ "Santokba Durlabhji Memorial",
+ 26.9000,
+ 75.8060,
+ ["trauma", "cardiac", "general"],
+ 10,
+ 0.83,
+ ),
+ ("Jaipur Golden Hospital", 26.9200, 75.8000, ["general"], 4, 0.70),
+ ("Bhandari Hospital", 26.8950, 75.7400, ["trauma", "general"], 6, 0.76),
+ (
+ "Soni Manipal Hospital",
+ 26.8650,
+ 75.7560,
+ ["cardiac", "obstetric", "general"],
+ 8,
+ 0.84,
+ ),
+ ("Cocoon Hospital", 26.8780, 75.7510, ["obstetric", "general"], 5, 0.79),
+ ("EHCC Hospital", 26.8492, 75.8024, ["cardiac", "trauma", "general"], 9, 0.85),
+ ("Rukmani Birla Hospital", 26.8895, 75.7690, ["obstetric", "general"], 7, 0.82),
+ ("Tagore Hospital", 26.8350, 75.7900, ["trauma", "general"], 6, 0.75),
+ ("Monilek Hospital", 26.8880, 75.8060, ["general", "obstetric"], 4, 0.72),
+ ("Imperial Hospital", 26.9300, 75.7700, ["general"], 3, 0.68),
+ ("Mital Hospital", 26.9080, 75.7990, ["general", "trauma"], 5, 0.73),
+ ("Getwell Hospital", 26.8700, 75.8200, ["general"], 4, 0.71),
+ (
+ "Shalby Hospital Jaipur",
+ 26.8480,
+ 75.7720,
+ ["trauma", "cardiac", "general"],
+ 8,
+ 0.80,
+ ),
+ ("Dhanwantari Hospital", 26.9150, 75.8120, ["general", "obstetric"], 5, 0.74),
+ ("Pratap Memorial Hospital", 26.9250, 75.8200, ["general"], 3, 0.69),
+ (
+ "Jeevan Rekha Hospital",
+ 26.8600,
+ 75.7980,
+ ["trauma", "cardiac", "general"],
+ 7,
+ 0.81,
+ ),
+ ("Marudhar Hospital", 26.9040, 75.8290, ["general", "obstetric"], 4, 0.72),
+]
+
+_FIRST_NAMES = [
+ "Asha",
+ "Rohan",
+ "Priya",
+ "Amit",
+ "Neha",
+ "Vikram",
+ "Pooja",
+ "Rahul",
+ "Sneha",
+ "Arjun",
+ "Kavya",
+ "Manish",
+ "Divya",
+ "Sanjay",
+ "Ritu",
+ "Karan",
+ "Anjali",
+ "Deepak",
+ "Meena",
+ "Suresh",
+ "Nisha",
+ "Gaurav",
+ "Sunita",
+ "Vivek",
+]
+_LAST_NAMES = [
+ "Verma",
+ "Sharma",
+ "Gupta",
+ "Singh",
+ "Yadav",
+ "Meena",
+ "Agarwal",
+ "Jain",
+ "Saini",
+ "Choudhary",
+ "Kumawat",
+ "Soni",
+ "Rathore",
+ "Bhatt",
+ "Nair",
+]
+
+
+def _scatter(rng: random.Random, max_km: float) -> tuple[float, float]:
+ """A random point within ``max_km`` of Jaipur centre."""
+ # ~111 km per degree latitude; longitude scaled by cos(latitude).
+ radius_deg = (max_km / 111.0) * (rng.random() ** 0.5) # sqrt for uniform area
+ angle = rng.uniform(0, 2 * 3.14159265)
+ import math
+
+ dlat = radius_deg * math.cos(angle)
+ dlng = radius_deg * math.sin(angle) / math.cos(math.radians(JAIPUR_LAT))
+ return round(JAIPUR_LAT + dlat, 5), round(JAIPUR_LNG + dlng, 5)
+
+
+def hospitals() -> List[Dict]:
+ """The seeded hospital list, one dict per hospital."""
+ out: List[Dict] = []
+ for i, (name, lat, lng, depts, beds, rel) in enumerate(_HOSPITALS, start=1):
+ out.append(
+ {
+ "id": f"h{i}",
+ "name": name,
+ "lat": lat,
+ "lng": lng,
+ "departments": depts,
+ "beds_available": beds,
+ "avg_response_rate": rel,
+ "phone": f"+9114{i:07d}",
+ "contact_phone": f"+9198{i:08d}",
+ "confirm_token": f"hosp-{i:02d}-token",
+ }
+ )
+ return out
+
+
+def donors(count: int = 180, seed: int = 42) -> List[Dict]:
+ """``count`` seeded donors spread across all 8 blood groups."""
+ rng = random.Random(seed)
+ today = date(2026, 6, 18)
+ out: List[Dict] = []
+ for i in range(1, count + 1):
+ lat, lng = _scatter(rng, max_km=12.0)
+ group = ALL_GROUPS[i % len(ALL_GROUPS)]
+ sex = "female" if rng.random() < 0.5 else "male"
+
+ # Spread donation history: ~25% never donated, the rest 10–200 days ago
+ # so some fall inside the cooldown (90d men / 120d women) and some don't.
+ roll = rng.random()
+ if roll < 0.25:
+ last_donated = None
+ else:
+ days_ago = rng.randint(10, 200)
+ last_donated = (today - timedelta(days=days_ago)).isoformat()
+
+ out.append(
+ {
+ "id": f"d{i}",
+ "name": f"{rng.choice(_FIRST_NAMES)} {rng.choice(_LAST_NAMES)}",
+ "phone": f"+9199{i:08d}",
+ "blood_group": group,
+ "lat": lat,
+ "lng": lng,
+ "last_donated": last_donated,
+ "sex": sex,
+ "available": True,
+ }
+ )
+ return out
diff --git a/backend/seed_supabase.py b/backend/seed_supabase.py
new file mode 100644
index 0000000..b0746ec
--- /dev/null
+++ b/backend/seed_supabase.py
@@ -0,0 +1,75 @@
+"""Seed the Supabase database with the demo hospitals and donors.
+
+Usage:
+ python seed_supabase.py # needs SUPABASE_URL + SUPABASE_ANON_KEY
+
+Run `sql/schema.sql` first to create the tables. This inserts the same ~25
+hospitals and ~180 donors used by demo mode, so the real DB and the in-memory
+demo behave identically.
+
+(Demo mode does NOT need this — the in-memory store seeds itself on boot.)
+"""
+
+from __future__ import annotations
+
+import sys
+
+import seed_data
+from config import settings
+
+
+def main() -> int:
+ if not settings.use_supabase:
+ print(
+ "SUPABASE_URL / SUPABASE_ANON_KEY are not set. Demo mode seeds itself "
+ "in-memory on boot — nothing to do here. Set them to seed a real DB."
+ )
+ return 0
+
+ try:
+ from supabase import create_client
+ except ImportError:
+ print("The 'supabase' package is required: pip install supabase")
+ return 1
+
+ client = create_client(settings.supabase_url, settings.supabase_key)
+
+ hospitals = [
+ {
+ "id": h["id"],
+ "name": h["name"],
+ "lat": h["lat"],
+ "lng": h["lng"],
+ "departments": h["departments"],
+ "beds_available": h["beds_available"],
+ "avg_response_rate": h["avg_response_rate"],
+ "phone": h["phone"],
+ "contact_phone": h["contact_phone"],
+ }
+ for h in seed_data.hospitals()
+ ]
+
+ donors = [
+ {
+ "id": d["id"],
+ "name": d["name"],
+ "phone": d["phone"],
+ "blood_group": d["blood_group"],
+ "lat": d["lat"],
+ "lng": d["lng"],
+ "last_donated": d["last_donated"],
+ "available": d["available"],
+ }
+ for d in seed_data.donors()
+ ]
+
+ print(f"Upserting {len(hospitals)} hospitals...")
+ client.table("hospitals").upsert(hospitals).execute()
+ print(f"Upserting {len(donors)} donors...")
+ client.table("blood_donors").upsert(donors).execute()
+ print("Done.")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/backend/services/__init__.py b/backend/services/__init__.py
new file mode 100644
index 0000000..df3427e
--- /dev/null
+++ b/backend/services/__init__.py
@@ -0,0 +1,6 @@
+"""Service layer: business logic for GoldenHour.
+
+Each module owns one concern (hospital ranking, donor matching, confirmation
+flow, geocoding/ETA, messaging, realtime, plus small infra helpers). Route
+handlers in ``main.py`` call into these; data access goes through ``store.py``.
+"""
diff --git a/backend/services/cache.py b/backend/services/cache.py
index 49cc8ef..0f5d98f 100644
Binary files a/backend/services/cache.py and b/backend/services/cache.py differ
diff --git a/backend/services/confirm_service.py b/backend/services/confirm_service.py
index 49cc8ef..d75fd76 100644
Binary files a/backend/services/confirm_service.py and b/backend/services/confirm_service.py differ
diff --git a/backend/services/donor_service.py b/backend/services/donor_service.py
index 49cc8ef..50991f1 100644
Binary files a/backend/services/donor_service.py and b/backend/services/donor_service.py differ
diff --git a/backend/services/emergency_service.py b/backend/services/emergency_service.py
index 49cc8ef..003a032 100644
Binary files a/backend/services/emergency_service.py and b/backend/services/emergency_service.py differ
diff --git a/backend/services/geocoder.py b/backend/services/geocoder.py
index 49cc8ef..db5fe30 100644
Binary files a/backend/services/geocoder.py and b/backend/services/geocoder.py differ
diff --git a/backend/services/hospital_service.py b/backend/services/hospital_service.py
index 49cc8ef..5a22ebb 100644
Binary files a/backend/services/hospital_service.py and b/backend/services/hospital_service.py differ
diff --git a/backend/services/phone_utils.py b/backend/services/phone_utils.py
index 49cc8ef..c5f162e 100644
Binary files a/backend/services/phone_utils.py and b/backend/services/phone_utils.py differ
diff --git a/backend/services/rate_limiter.py b/backend/services/rate_limiter.py
index 49cc8ef..d678bc6 100644
Binary files a/backend/services/rate_limiter.py and b/backend/services/rate_limiter.py differ
diff --git a/backend/services/realtime_service.py b/backend/services/realtime_service.py
index 49cc8ef..6c1210a 100644
Binary files a/backend/services/realtime_service.py and b/backend/services/realtime_service.py differ
diff --git a/backend/services/sms_service.py b/backend/services/sms_service.py
index 49cc8ef..7717c11 100644
Binary files a/backend/services/sms_service.py and b/backend/services/sms_service.py differ
diff --git a/backend/sql/schema.sql b/backend/sql/schema.sql
new file mode 100644
index 0000000..b6f91c9
--- /dev/null
+++ b/backend/sql/schema.sql
@@ -0,0 +1,143 @@
+-- GoldenHour database schema (PostgreSQL + PostGIS, via Supabase).
+--
+-- Run once in the Supabase SQL editor. Stores lat/lng as plain columns (so the
+-- seed script can insert them over PostgREST) and derives a PostGIS geography
+-- column from them for fast radius queries. Enable Realtime on
+-- `confirmation_requests` afterwards so hospital replies push to the frontend.
+
+create extension if not exists postgis;
+
+-- ---------------------------------------------------------------------------
+-- hospitals
+-- ---------------------------------------------------------------------------
+create table if not exists hospitals (
+ id text primary key, -- e.g. "h1"
+ name text not null,
+ lat double precision not null,
+ lng double precision not null,
+ location geography(Point, 4326)
+ generated always as
+ (ST_SetSRID(ST_MakePoint(lng, lat), 4326)::geography) stored,
+ departments text[] not null default '{}', -- {"trauma","cardiac","general"}
+ beds_available integer not null default 0,
+ avg_response_rate double precision not null default 0.0,
+ phone text,
+ contact_phone text
+);
+
+-- ---------------------------------------------------------------------------
+-- blood_donors
+-- ---------------------------------------------------------------------------
+create table if not exists blood_donors (
+ id text primary key, -- e.g. "d42"
+ name text not null,
+ phone text not null,
+ blood_group text not null, -- "O+", "AB-", ...
+ lat double precision not null,
+ lng double precision not null,
+ location geography(Point, 4326)
+ generated always as
+ (ST_SetSRID(ST_MakePoint(lng, lat), 4326)::geography) stored,
+ last_donated date, -- null = never donated
+ sex text, -- 'male' | 'female' | null
+ available boolean not null default true
+);
+
+-- ---------------------------------------------------------------------------
+-- emergency_requests
+-- ---------------------------------------------------------------------------
+create table if not exists emergency_requests (
+ id text primary key, -- request_id, e.g. "req0001"
+ emergency_type text not null, -- trauma|cardiac|obstetric|general
+ blood_group_needed text not null,
+ lat double precision not null,
+ lng double precision not null,
+ location geography(Point, 4326)
+ generated always as
+ (ST_SetSRID(ST_MakePoint(lng, lat), 4326)::geography) stored,
+ status text not null default 'pending', -- pending|confirmed|resolved
+ donors_alerted integer not null default 0,
+ donors_responded integer not null default 0,
+ hospitals jsonb not null default '[]', -- ranked hospital-card snapshot
+ created_at timestamptz not null default now()
+);
+
+-- ---------------------------------------------------------------------------
+-- confirmation_requests
+-- ---------------------------------------------------------------------------
+create table if not exists confirmation_requests (
+ id text primary key default gen_random_uuid()::text,
+ token text unique not null, -- the token in the /confirm URL
+ emergency_id text not null references emergency_requests(id) on delete cascade,
+ hospital_id text not null references hospitals(id),
+ hospital_name text, -- denormalised for the confirm page
+ sent_at timestamptz not null default now(),
+ replied_at timestamptz, -- null until the hospital replies
+ confirmed boolean -- null=pending, true/false on reply
+);
+
+-- ---------------------------------------------------------------------------
+-- Spatial indexes — make the radius queries fast.
+-- ---------------------------------------------------------------------------
+create index if not exists idx_donors_loc on blood_donors using gist (location);
+create index if not exists idx_hosp_loc on hospitals using gist (location);
+create index if not exists idx_conf_emergency on confirmation_requests (emergency_id);
+
+-- ---------------------------------------------------------------------------
+-- RPC: compatible donors within a radius, off-cooldown, nearest first.
+-- Call via supabase.rpc('donors_nearby', {...}). Mirrors donor_service logic.
+-- ---------------------------------------------------------------------------
+create or replace function donors_nearby(
+ p_lat double precision,
+ p_lng double precision,
+ p_groups text[],
+ p_radius_m double precision default 5000,
+ p_cooldown_days integer default 90,
+ p_cooldown_days_female integer default 120
+)
+returns setof blood_donors
+language sql
+stable
+as $$
+ select *
+ from blood_donors d
+ where d.blood_group = any(p_groups)
+ and d.available = true
+ and (d.last_donated is null
+ or d.last_donated < current_date
+ - ((case when d.sex = 'female'
+ then p_cooldown_days_female
+ else p_cooldown_days end) || ' days')::interval)
+ and ST_DWithin(
+ d.location,
+ ST_SetSRID(ST_MakePoint(p_lng, p_lat), 4326)::geography,
+ p_radius_m)
+ order by ST_Distance(
+ d.location,
+ ST_SetSRID(ST_MakePoint(p_lng, p_lat), 4326)::geography);
+$$;
+
+-- ---------------------------------------------------------------------------
+-- Row-Level Security (RLS)
+-- ---------------------------------------------------------------------------
+-- Tables created above have RLS DISABLED by default, so the anon key can read
+-- and write — convenient for the demo/prototype.
+--
+-- Recommended production model:
+-- * Backend uses the SERVICE-ROLE key (SUPABASE_SERVICE_KEY) — bypasses RLS,
+-- so all writes (emergencies, confirmations, donor inserts) go through the
+-- trusted server.
+-- * Frontend uses the anon key with read-only access to just what it needs to
+-- subscribe to via Realtime.
+--
+-- To lock down, enable RLS and add read-only anon policies, e.g.:
+--
+-- alter table confirmation_requests enable row level security;
+-- create policy "anon reads confirmations"
+-- on confirmation_requests for select to anon using (true);
+--
+-- alter table emergency_requests enable row level security;
+-- create policy "anon reads emergencies"
+-- on emergency_requests for select to anon using (true);
+--
+-- (Writes still succeed because the backend connects with the service-role key.)
diff --git a/backend/sql/seed.sql b/backend/sql/seed.sql
new file mode 100644
index 0000000..ed203eb
--- /dev/null
+++ b/backend/sql/seed.sql
@@ -0,0 +1,215 @@
+-- GoldenHour seed data (AUTO-GENERATED from seed_data.py — do not edit by hand).
+-- Run AFTER sql/schema.sql. Idempotent: re-running is safe.
+
+-- Hospitals -----------------------------------------------------------------
+insert into hospitals (id, name, lat, lng, departments, beds_available, avg_response_rate, phone, contact_phone) values
+ ('h1', 'SMS Hospital', 26.9036, 75.8147, ARRAY['trauma', 'cardiac', 'general']::text[], 12, 0.92, '+91140000001', '+919800000001'),
+ ('h2', 'Fortis Escorts Jaipur', 26.8569, 75.8064, ARRAY['cardiac', 'general']::text[], 8, 0.88, '+91140000002', '+919800000002'),
+ ('h3', 'Manipal Hospital Jaipur', 26.8853, 75.747, ARRAY['trauma', 'cardiac', 'obstetric', 'general']::text[], 15, 0.9, '+91140000003', '+919800000003'),
+ ('h4', 'Narayana Multispeciality', 26.8312, 75.804, ARRAY['cardiac', 'general']::text[], 6, 0.81, '+91140000004', '+919800000004'),
+ ('h5', 'Eternal Hospital', 26.8665, 75.8101, ARRAY['cardiac', 'trauma', 'general']::text[], 9, 0.86, '+91140000005', '+919800000005'),
+ ('h6', 'CK Birla Hospital (RBH)', 26.8901, 75.7686, ARRAY['trauma', 'obstetric', 'general']::text[], 11, 0.89, '+91140000006', '+919800000006'),
+ ('h7', 'Mahatma Gandhi Hospital', 26.77, 75.855, ARRAY['trauma', 'general']::text[], 7, 0.78, '+91140000007', '+919800000007'),
+ ('h8', 'Apex Hospital Malviya Nagar', 26.8541, 75.8136, ARRAY['general', 'obstetric']::text[], 5, 0.74, '+91140000008', '+919800000008'),
+ ('h9', 'Santokba Durlabhji Memorial', 26.9, 75.806, ARRAY['trauma', 'cardiac', 'general']::text[], 10, 0.83, '+91140000009', '+919800000009'),
+ ('h10', 'Jaipur Golden Hospital', 26.92, 75.8, ARRAY['general']::text[], 4, 0.7, '+91140000010', '+919800000010'),
+ ('h11', 'Bhandari Hospital', 26.895, 75.74, ARRAY['trauma', 'general']::text[], 6, 0.76, '+91140000011', '+919800000011'),
+ ('h12', 'Soni Manipal Hospital', 26.865, 75.756, ARRAY['cardiac', 'obstetric', 'general']::text[], 8, 0.84, '+91140000012', '+919800000012'),
+ ('h13', 'Cocoon Hospital', 26.878, 75.751, ARRAY['obstetric', 'general']::text[], 5, 0.79, '+91140000013', '+919800000013'),
+ ('h14', 'EHCC Hospital', 26.8492, 75.8024, ARRAY['cardiac', 'trauma', 'general']::text[], 9, 0.85, '+91140000014', '+919800000014'),
+ ('h15', 'Rukmani Birla Hospital', 26.8895, 75.769, ARRAY['obstetric', 'general']::text[], 7, 0.82, '+91140000015', '+919800000015'),
+ ('h16', 'Tagore Hospital', 26.835, 75.79, ARRAY['trauma', 'general']::text[], 6, 0.75, '+91140000016', '+919800000016'),
+ ('h17', 'Monilek Hospital', 26.888, 75.806, ARRAY['general', 'obstetric']::text[], 4, 0.72, '+91140000017', '+919800000017'),
+ ('h18', 'Imperial Hospital', 26.93, 75.77, ARRAY['general']::text[], 3, 0.68, '+91140000018', '+919800000018'),
+ ('h19', 'Mital Hospital', 26.908, 75.799, ARRAY['general', 'trauma']::text[], 5, 0.73, '+91140000019', '+919800000019'),
+ ('h20', 'Getwell Hospital', 26.87, 75.82, ARRAY['general']::text[], 4, 0.71, '+91140000020', '+919800000020'),
+ ('h21', 'Shalby Hospital Jaipur', 26.848, 75.772, ARRAY['trauma', 'cardiac', 'general']::text[], 8, 0.8, '+91140000021', '+919800000021'),
+ ('h22', 'Dhanwantari Hospital', 26.915, 75.812, ARRAY['general', 'obstetric']::text[], 5, 0.74, '+91140000022', '+919800000022'),
+ ('h23', 'Pratap Memorial Hospital', 26.925, 75.82, ARRAY['general']::text[], 3, 0.69, '+91140000023', '+919800000023'),
+ ('h24', 'Jeevan Rekha Hospital', 26.86, 75.798, ARRAY['trauma', 'cardiac', 'general']::text[], 7, 0.81, '+91140000024', '+919800000024'),
+ ('h25', 'Marudhar Hospital', 26.904, 75.829, ARRAY['general', 'obstetric']::text[], 4, 0.72, '+91140000025', '+919800000025')
+on conflict (id) do nothing;
+
+-- Donors --------------------------------------------------------------------
+insert into blood_donors (id, name, phone, blood_group, lat, lng, last_donated, sex, available) values
+ ('d1', 'Vivek Sharma', '+919900000001', 'O+', 26.99778, 75.80247, null, 'female', true),
+ ('d2', 'Priya Singh', '+919900000002', 'A-', 26.98169, 75.72479, '2026-06-01', 'female', true),
+ ('d3', 'Deepak Agarwal', '+919900000003', 'A+', 26.87061, 75.75233, '2025-12-11', 'male', true),
+ ('d4', 'Vikram Soni', '+919900000004', 'B-', 26.86942, 75.75702, null, 'male', true),
+ ('d5', 'Priya Agarwal', '+919900000005', 'B+', 26.90016, 75.86491, '2026-05-13', 'female', true),
+ ('d6', 'Ritu Saini', '+919900000006', 'AB-', 26.93173, 75.75645, '2025-12-04', 'male', true),
+ ('d7', 'Suresh Nair', '+919900000007', 'AB+', 26.94613, 75.76721, '2025-12-30', 'female', true),
+ ('d8', 'Rahul Rathore', '+919900000008', 'O-', 26.82367, 75.73473, null, 'male', true),
+ ('d9', 'Sneha Jain', '+919900000009', 'O+', 26.9634, 75.81865, null, 'female', true),
+ ('d10', 'Sneha Soni', '+919900000010', 'A-', 26.85547, 75.85988, null, 'female', true),
+ ('d11', 'Vivek Singh', '+919900000011', 'A+', 26.84986, 75.69323, null, 'male', true),
+ ('d12', 'Rahul Kumawat', '+919900000012', 'B-', 26.88065, 75.82097, '2026-01-17', 'male', true),
+ ('d13', 'Divya Yadav', '+919900000013', 'B+', 26.91947, 75.71872, '2026-03-20', 'female', true),
+ ('d14', 'Nisha Jain', '+919900000014', 'AB-', 26.93617, 75.77111, '2026-04-15', 'male', true),
+ ('d15', 'Vivek Saini', '+919900000015', 'AB+', 26.97083, 75.7483, '2026-04-06', 'female', true),
+ ('d16', 'Rahul Gupta', '+919900000016', 'O-', 26.91091, 75.69831, '2026-03-08', 'female', true),
+ ('d17', 'Nisha Gupta', '+919900000017', 'O+', 26.97732, 75.83409, null, 'female', true),
+ ('d18', 'Anjali Yadav', '+919900000018', 'A-', 26.82746, 75.83799, '2026-02-09', 'female', true),
+ ('d19', 'Deepak Rathore', '+919900000019', 'A+', 26.98071, 75.69562, '2025-12-16', 'female', true),
+ ('d20', 'Asha Soni', '+919900000020', 'B-', 26.87706, 75.73881, '2026-02-12', 'female', true),
+ ('d21', 'Amit Bhatt', '+919900000021', 'B+', 26.9039, 75.90036, null, 'male', true),
+ ('d22', 'Manish Rathore', '+919900000022', 'AB-', 26.95896, 75.7069, null, 'male', true),
+ ('d23', 'Kavya Jain', '+919900000023', 'AB+', 26.95401, 75.77326, '2026-01-06', 'male', true),
+ ('d24', 'Rahul Verma', '+919900000024', 'O-', 26.92601, 75.78001, '2026-03-22', 'male', true),
+ ('d25', 'Deepak Rathore', '+919900000025', 'O+', 26.86402, 75.76286, '2026-05-22', 'female', true),
+ ('d26', 'Anjali Bhatt', '+919900000026', 'A-', 26.89181, 75.75103, null, 'male', true),
+ ('d27', 'Pooja Soni', '+919900000027', 'A+', 26.9945, 75.76633, '2025-12-14', 'male', true),
+ ('d28', 'Ritu Sharma', '+919900000028', 'B-', 26.97273, 75.78524, '2026-01-27', 'male', true),
+ ('d29', 'Rahul Verma', '+919900000029', 'B+', 26.96193, 75.81093, '2026-01-09', 'female', true),
+ ('d30', 'Priya Saini', '+919900000030', 'AB-', 26.89283, 75.7636, '2026-03-16', 'female', true),
+ ('d31', 'Meena Choudhary', '+919900000031', 'AB+', 26.8867, 75.73565, null, 'female', true),
+ ('d32', 'Amit Kumawat', '+919900000032', 'O-', 26.92844, 75.70591, null, 'male', true),
+ ('d33', 'Nisha Kumawat', '+919900000033', 'O+', 26.84945, 75.82407, '2025-12-18', 'female', true),
+ ('d34', 'Pooja Singh', '+919900000034', 'A-', 26.88464, 75.80915, '2026-04-06', 'female', true),
+ ('d35', 'Ritu Rathore', '+919900000035', 'A+', 26.96279, 75.85577, '2026-05-20', 'female', true),
+ ('d36', 'Priya Nair', '+919900000036', 'B-', 26.81704, 75.75231, '2026-06-05', 'female', true),
+ ('d37', 'Rohan Gupta', '+919900000037', 'B+', 26.92042, 75.89216, '2026-02-26', 'female', true),
+ ('d38', 'Arjun Agarwal', '+919900000038', 'AB-', 26.97867, 75.78042, '2026-02-12', 'female', true),
+ ('d39', 'Neha Singh', '+919900000039', 'AB+', 26.90138, 75.68687, '2026-02-04', 'male', true),
+ ('d40', 'Rohan Verma', '+919900000040', 'O-', 26.97015, 75.7744, '2026-03-20', 'male', true),
+ ('d41', 'Anjali Sharma', '+919900000041', 'O+', 26.82978, 75.78564, null, 'male', true),
+ ('d42', 'Amit Nair', '+919900000042', 'A-', 27.00305, 75.83399, '2026-02-25', 'female', true),
+ ('d43', 'Gaurav Choudhary', '+919900000043', 'A+', 26.84063, 75.74376, null, 'female', true),
+ ('d44', 'Rahul Yadav', '+919900000044', 'B-', 26.87948, 75.87064, '2026-03-20', 'female', true),
+ ('d45', 'Asha Jain', '+919900000045', 'B+', 26.88027, 75.72008, '2026-05-21', 'female', true),
+ ('d46', 'Sneha Gupta', '+919900000046', 'AB-', 26.83378, 75.75046, '2026-01-30', 'female', true),
+ ('d47', 'Ritu Bhatt', '+919900000047', 'AB+', 26.98892, 75.70759, '2026-04-29', 'male', true),
+ ('d48', 'Asha Kumawat', '+919900000048', 'O-', 26.88658, 75.87184, '2026-01-24', 'male', true),
+ ('d49', 'Sneha Sharma', '+919900000049', 'O+', 26.88256, 75.89166, '2026-05-05', 'male', true),
+ ('d50', 'Sunita Meena', '+919900000050', 'A-', 26.90754, 75.67307, '2026-04-16', 'female', true),
+ ('d51', 'Priya Kumawat', '+919900000051', 'A+', 26.87997, 75.74644, '2026-05-26', 'female', true),
+ ('d52', 'Sneha Gupta', '+919900000052', 'B-', 26.90066, 75.8651, '2025-12-27', 'female', true),
+ ('d53', 'Priya Nair', '+919900000053', 'B+', 26.82419, 75.754, null, 'female', true),
+ ('d54', 'Neha Agarwal', '+919900000054', 'AB-', 26.96565, 75.86848, '2026-01-18', 'female', true),
+ ('d55', 'Manish Singh', '+919900000055', 'AB+', 26.89859, 75.82771, '2026-05-29', 'male', true),
+ ('d56', 'Suresh Soni', '+919900000056', 'O-', 26.86789, 75.7005, '2026-02-24', 'female', true),
+ ('d57', 'Sanjay Verma', '+919900000057', 'O+', 26.95084, 75.76697, '2026-04-24', 'male', true),
+ ('d58', 'Vivek Rathore', '+919900000058', 'A-', 26.95313, 75.76384, '2025-12-19', 'male', true),
+ ('d59', 'Karan Singh', '+919900000059', 'A+', 26.94149, 75.83813, '2026-05-30', 'male', true),
+ ('d60', 'Rahul Verma', '+919900000060', 'B-', 26.95449, 75.76075, '2026-04-11', 'female', true),
+ ('d61', 'Sneha Meena', '+919900000061', 'B+', 26.84185, 75.84596, null, 'female', true),
+ ('d62', 'Amit Nair', '+919900000062', 'AB-', 26.84245, 75.84453, '2026-06-01', 'male', true),
+ ('d63', 'Suresh Agarwal', '+919900000063', 'AB+', 26.95861, 75.8949, '2026-05-12', 'male', true),
+ ('d64', 'Divya Nair', '+919900000064', 'O-', 26.92682, 75.71788, '2026-05-10', 'female', true),
+ ('d65', 'Deepak Kumawat', '+919900000065', 'O+', 26.90996, 75.87932, null, 'male', true),
+ ('d66', 'Gaurav Nair', '+919900000066', 'A-', 26.90776, 75.6846, '2026-05-22', 'male', true),
+ ('d67', 'Anjali Yadav', '+919900000067', 'A+', 26.88812, 75.85142, '2026-03-24', 'male', true),
+ ('d68', 'Sanjay Kumawat', '+919900000068', 'B-', 26.87174, 75.87518, '2026-04-20', 'male', true),
+ ('d69', 'Divya Saini', '+919900000069', 'B+', 26.86617, 75.68178, '2026-03-23', 'male', true),
+ ('d70', 'Nisha Meena', '+919900000070', 'AB-', 26.87962, 75.89172, '2026-01-04', 'female', true),
+ ('d71', 'Vikram Kumawat', '+919900000071', 'AB+', 26.8435, 75.81669, '2025-12-02', 'female', true),
+ ('d72', 'Gaurav Yadav', '+919900000072', 'O-', 26.88107, 75.78388, '2026-04-09', 'male', true),
+ ('d73', 'Karan Choudhary', '+919900000073', 'O+', 26.9285, 75.84185, null, 'female', true),
+ ('d74', 'Sunita Soni', '+919900000074', 'A-', 27.0018, 75.83667, '2026-04-20', 'female', true),
+ ('d75', 'Sanjay Singh', '+919900000075', 'A+', 26.85829, 75.83159, '2026-05-12', 'female', true),
+ ('d76', 'Rahul Nair', '+919900000076', 'B-', 26.95617, 75.77403, null, 'male', true),
+ ('d77', 'Suresh Meena', '+919900000077', 'B+', 26.81653, 75.81747, '2026-01-16', 'male', true),
+ ('d78', 'Sanjay Bhatt', '+919900000078', 'AB-', 26.99419, 75.71279, '2026-01-30', 'male', true),
+ ('d79', 'Sneha Rathore', '+919900000079', 'AB+', 26.81534, 75.82578, '2026-02-13', 'female', true),
+ ('d80', 'Rahul Yadav', '+919900000080', 'O-', 26.87753, 75.74142, '2025-12-30', 'male', true),
+ ('d81', 'Priya Gupta', '+919900000081', 'O+', 26.89612, 75.70899, '2026-01-21', 'female', true),
+ ('d82', 'Sanjay Agarwal', '+919900000082', 'A-', 26.88125, 75.81887, null, 'female', true),
+ ('d83', 'Meena Soni', '+919900000083', 'A+', 26.85163, 75.80212, '2026-03-01', 'female', true),
+ ('d84', 'Arjun Rathore', '+919900000084', 'B-', 26.92346, 75.77575, '2026-03-10', 'male', true),
+ ('d85', 'Deepak Rathore', '+919900000085', 'B+', 26.96486, 75.73964, '2025-12-02', 'male', true),
+ ('d86', 'Divya Meena', '+919900000086', 'AB-', 26.92785, 75.87986, '2026-06-01', 'female', true),
+ ('d87', 'Suresh Saini', '+919900000087', 'AB+', 26.93874, 75.69265, '2026-05-07', 'male', true),
+ ('d88', 'Nisha Agarwal', '+919900000088', 'O-', 26.89844, 75.7996, null, 'male', true),
+ ('d89', 'Ritu Meena', '+919900000089', 'O+', 26.87372, 75.79794, '2026-04-15', 'female', true),
+ ('d90', 'Sneha Bhatt', '+919900000090', 'A-', 26.95809, 75.73897, '2026-02-21', 'female', true),
+ ('d91', 'Rahul Kumawat', '+919900000091', 'A+', 26.94311, 75.79152, '2026-03-11', 'male', true),
+ ('d92', 'Rahul Singh', '+919900000092', 'B-', 26.93971, 75.77888, null, 'female', true),
+ ('d93', 'Meena Singh', '+919900000093', 'B+', 26.84078, 75.71059, '2026-05-10', 'female', true),
+ ('d94', 'Amit Rathore', '+919900000094', 'AB-', 26.90951, 75.86991, '2025-12-07', 'female', true),
+ ('d95', 'Arjun Choudhary', '+919900000095', 'AB+', 27.00786, 75.76317, null, 'female', true),
+ ('d96', 'Meena Soni', '+919900000096', 'O-', 26.99832, 75.76138, '2026-05-20', 'female', true),
+ ('d97', 'Suresh Rathore', '+919900000097', 'O+', 26.91683, 75.89768, '2025-12-15', 'male', true),
+ ('d98', 'Gaurav Meena', '+919900000098', 'A-', 26.94982, 75.78303, '2026-02-19', 'male', true),
+ ('d99', 'Amit Agarwal', '+919900000099', 'A+', 26.89536, 75.76184, '2026-02-03', 'female', true),
+ ('d100', 'Vikram Soni', '+919900000100', 'B-', 26.84262, 75.69787, '2026-02-17', 'male', true),
+ ('d101', 'Ritu Agarwal', '+919900000101', 'B+', 26.86668, 75.7163, '2026-02-05', 'male', true),
+ ('d102', 'Priya Yadav', '+919900000102', 'AB-', 26.83028, 75.72683, null, 'female', true),
+ ('d103', 'Kavya Verma', '+919900000103', 'AB+', 26.91631, 75.90105, '2026-03-03', 'female', true),
+ ('d104', 'Kavya Yadav', '+919900000104', 'O-', 26.87789, 75.86324, '2026-04-03', 'female', true),
+ ('d105', 'Pooja Sharma', '+919900000105', 'O+', 26.88177, 75.67886, null, 'female', true),
+ ('d106', 'Karan Kumawat', '+919900000106', 'A-', 26.86821, 75.82034, null, 'male', true),
+ ('d107', 'Sunita Singh', '+919900000107', 'A+', 26.82598, 75.82002, '2026-02-25', 'female', true),
+ ('d108', 'Vivek Saini', '+919900000108', 'B-', 26.86026, 75.75441, '2026-02-20', 'female', true),
+ ('d109', 'Vivek Singh', '+919900000109', 'B+', 26.89425, 75.72061, '2026-05-09', 'female', true),
+ ('d110', 'Pooja Soni', '+919900000110', 'AB-', 26.90968, 75.71926, '2026-04-20', 'male', true),
+ ('d111', 'Amit Bhatt', '+919900000111', 'AB+', 26.90044, 75.70401, '2026-03-28', 'male', true),
+ ('d112', 'Deepak Gupta', '+919900000112', 'O-', 26.91912, 75.84018, null, 'female', true),
+ ('d113', 'Nisha Bhatt', '+919900000113', 'O+', 26.96828, 75.77709, '2026-05-07', 'male', true),
+ ('d114', 'Ritu Meena', '+919900000114', 'A-', 26.98738, 75.8505, '2026-02-06', 'female', true),
+ ('d115', 'Divya Jain', '+919900000115', 'A+', 26.95642, 75.80381, '2026-05-23', 'male', true),
+ ('d116', 'Meena Yadav', '+919900000116', 'B-', 26.89218, 75.76332, null, 'female', true),
+ ('d117', 'Suresh Singh', '+919900000117', 'B+', 26.91276, 75.82268, '2026-01-07', 'male', true),
+ ('d118', 'Sanjay Yadav', '+919900000118', 'AB-', 26.84284, 75.86021, '2026-01-09', 'male', true),
+ ('d119', 'Pooja Kumawat', '+919900000119', 'AB+', 26.98815, 75.82108, null, 'male', true),
+ ('d120', 'Priya Gupta', '+919900000120', 'O-', 26.8859, 75.7402, null, 'female', true),
+ ('d121', 'Arjun Soni', '+919900000121', 'O+', 26.90708, 75.78922, '2026-04-10', 'male', true),
+ ('d122', 'Sneha Rathore', '+919900000122', 'A-', 26.94893, 75.73753, null, 'female', true),
+ ('d123', 'Amit Saini', '+919900000123', 'A+', 26.83114, 75.72963, null, 'male', true),
+ ('d124', 'Rohan Gupta', '+919900000124', 'B-', 26.94279, 75.83359, null, 'female', true),
+ ('d125', 'Amit Jain', '+919900000125', 'B+', 26.83281, 75.72661, '2026-02-16', 'male', true),
+ ('d126', 'Ritu Sharma', '+919900000126', 'AB-', 26.88453, 75.69166, '2026-02-02', 'male', true),
+ ('d127', 'Priya Singh', '+919900000127', 'AB+', 26.97662, 75.72728, '2026-06-02', 'male', true),
+ ('d128', 'Sneha Choudhary', '+919900000128', 'O-', 26.96692, 75.68532, '2025-12-18', 'male', true),
+ ('d129', 'Vikram Choudhary', '+919900000129', 'O+', 26.91325, 75.763, '2026-03-29', 'female', true),
+ ('d130', 'Kavya Meena', '+919900000130', 'A-', 26.94044, 75.71369, '2026-02-24', 'male', true),
+ ('d131', 'Gaurav Agarwal', '+919900000131', 'A+', 26.96783, 75.70994, '2026-03-27', 'female', true),
+ ('d132', 'Amit Rathore', '+919900000132', 'B-', 26.81968, 75.75344, '2026-03-18', 'female', true),
+ ('d133', 'Ritu Agarwal', '+919900000133', 'B+', 26.84396, 75.78024, '2026-01-21', 'male', true),
+ ('d134', 'Rohan Singh', '+919900000134', 'AB-', 26.8874, 75.78404, '2026-02-15', 'male', true),
+ ('d135', 'Amit Verma', '+919900000135', 'AB+', 26.95038, 75.83324, '2026-02-04', 'female', true),
+ ('d136', 'Deepak Verma', '+919900000136', 'O-', 26.82975, 75.71172, null, 'female', true),
+ ('d137', 'Amit Kumawat', '+919900000137', 'O+', 26.97935, 75.8371, '2026-02-10', 'male', true),
+ ('d138', 'Sanjay Bhatt', '+919900000138', 'A-', 26.81379, 75.78843, '2026-03-31', 'male', true),
+ ('d139', 'Pooja Nair', '+919900000139', 'A+', 26.83845, 75.80193, null, 'female', true),
+ ('d140', 'Sanjay Meena', '+919900000140', 'B-', 26.91045, 75.69346, null, 'female', true),
+ ('d141', 'Meena Choudhary', '+919900000141', 'B+', 26.80803, 75.78161, '2026-03-24', 'male', true),
+ ('d142', 'Kavya Saini', '+919900000142', 'AB-', 26.80458, 75.79518, '2026-03-12', 'female', true),
+ ('d143', 'Rahul Choudhary', '+919900000143', 'AB+', 26.84471, 75.86113, '2025-12-12', 'male', true),
+ ('d144', 'Sunita Nair', '+919900000144', 'O-', 26.95374, 75.72831, '2026-02-07', 'female', true),
+ ('d145', 'Karan Verma', '+919900000145', 'O+', 26.83892, 75.85896, '2026-05-01', 'male', true),
+ ('d146', 'Ritu Sharma', '+919900000146', 'A-', 26.94991, 75.778, null, 'female', true),
+ ('d147', 'Neha Sharma', '+919900000147', 'A+', 26.83685, 75.81081, '2025-12-23', 'male', true),
+ ('d148', 'Priya Bhatt', '+919900000148', 'B-', 26.98509, 75.77133, '2025-12-24', 'female', true),
+ ('d149', 'Sunita Nair', '+919900000149', 'B+', 26.88404, 75.72551, '2025-12-30', 'male', true),
+ ('d150', 'Nisha Kumawat', '+919900000150', 'AB-', 26.97791, 75.71137, null, 'female', true),
+ ('d151', 'Nisha Soni', '+919900000151', 'AB+', 27.01613, 75.78542, '2026-05-14', 'male', true),
+ ('d152', 'Kavya Rathore', '+919900000152', 'O-', 26.81767, 75.82655, '2026-05-28', 'male', true),
+ ('d153', 'Sanjay Choudhary', '+919900000153', 'O+', 26.89627, 75.80961, null, 'female', true),
+ ('d154', 'Suresh Kumawat', '+919900000154', 'A-', 26.95038, 75.8779, '2026-03-03', 'female', true),
+ ('d155', 'Ritu Yadav', '+919900000155', 'A+', 26.95769, 75.75632, '2026-04-04', 'female', true),
+ ('d156', 'Vikram Sharma', '+919900000156', 'B-', 26.98346, 75.72856, '2026-01-20', 'female', true),
+ ('d157', 'Sunita Yadav', '+919900000157', 'B+', 26.8718, 75.85378, '2026-02-20', 'male', true),
+ ('d158', 'Rahul Agarwal', '+919900000158', 'AB-', 26.8889, 75.86489, '2026-05-12', 'male', true),
+ ('d159', 'Divya Yadav', '+919900000159', 'AB+', 26.83951, 75.74577, '2025-12-22', 'male', true),
+ ('d160', 'Vivek Jain', '+919900000160', 'O-', 26.91884, 75.77912, '2026-01-04', 'male', true),
+ ('d161', 'Manish Singh', '+919900000161', 'O+', 26.99327, 75.72393, null, 'male', true),
+ ('d162', 'Gaurav Bhatt', '+919900000162', 'A-', 26.84985, 75.72069, '2025-12-22', 'male', true),
+ ('d163', 'Ritu Verma', '+919900000163', 'A+', 26.94516, 75.813, null, 'male', true),
+ ('d164', 'Vikram Singh', '+919900000164', 'B-', 26.9033, 75.69557, '2026-02-22', 'female', true),
+ ('d165', 'Vikram Yadav', '+919900000165', 'B+', 26.8743, 75.7765, '2026-03-31', 'male', true),
+ ('d166', 'Amit Jain', '+919900000166', 'AB-', 27.00947, 75.74748, '2026-03-14', 'male', true),
+ ('d167', 'Gaurav Agarwal', '+919900000167', 'AB+', 26.97963, 75.87933, '2025-12-05', 'male', true),
+ ('d168', 'Sneha Saini', '+919900000168', 'O-', 26.94816, 75.67501, '2026-06-05', 'female', true),
+ ('d169', 'Nisha Meena', '+919900000169', 'O+', 26.88663, 75.81863, '2026-03-03', 'male', true),
+ ('d170', 'Kavya Nair', '+919900000170', 'A-', 26.91601, 75.827, '2026-01-16', 'female', true),
+ ('d171', 'Arjun Kumawat', '+919900000171', 'A+', 26.86176, 75.71152, '2025-12-11', 'male', true),
+ ('d172', 'Amit Sharma', '+919900000172', 'B-', 26.95651, 75.84692, '2026-02-02', 'male', true),
+ ('d173', 'Sunita Saini', '+919900000173', 'B+', 26.86147, 75.77357, '2025-11-30', 'female', true),
+ ('d174', 'Karan Choudhary', '+919900000174', 'AB-', 26.909, 75.7089, '2026-05-14', 'female', true),
+ ('d175', 'Ritu Singh', '+919900000175', 'AB+', 26.97828, 75.76407, '2026-02-15', 'female', true),
+ ('d176', 'Manish Verma', '+919900000176', 'O-', 26.99353, 75.85284, '2025-12-25', 'male', true),
+ ('d177', 'Priya Kumawat', '+919900000177', 'O+', 26.93759, 75.85839, '2026-02-12', 'female', true),
+ ('d178', 'Kavya Singh', '+919900000178', 'A-', 26.88054, 75.74439, null, 'male', true),
+ ('d179', 'Pooja Choudhary', '+919900000179', 'A+', 26.93715, 75.66998, '2026-01-18', 'female', true),
+ ('d180', 'Suresh Verma', '+919900000180', 'B-', 26.94672, 75.74616, null, 'female', true)
+on conflict (id) do nothing;
diff --git a/backend/store.py b/backend/store.py
new file mode 100644
index 0000000..547a025
--- /dev/null
+++ b/backend/store.py
@@ -0,0 +1,539 @@
+"""Data layer.
+
+Two interchangeable stores implement the same method surface:
+
+* ``InMemoryStore`` — seeded with Jaipur hospitals + donors, haversine radius
+ queries. The default; runs with zero external services (demo mode, CI).
+* ``SupabaseStore`` — PostgreSQL + PostGIS via Supabase. Used automatically when
+ ``SUPABASE_URL`` / ``SUPABASE_ANON_KEY`` are set; falls back to in-memory if the
+ client can't be created, so the API never hard-fails at boot.
+
+``get_store()`` picks the right one. Because the four logical tables
+(``hospitals``, ``blood_donors``, ``emergency_requests``,
+``confirmation_requests``) and every query method are identical across both,
+the service layer is completely unaware of which store is live.
+"""
+
+from __future__ import annotations
+
+import itertools
+import logging
+import uuid
+from datetime import date, datetime, timezone
+from typing import Dict, List, Optional
+
+import seed_data
+from config import settings
+from geo import haversine_km, haversine_meters
+
+logger = logging.getLogger("goldenhour.store")
+
+
+def _parse_date(value: Optional[str]) -> Optional[date]:
+ if not value:
+ return None
+ try:
+ return date.fromisoformat(value)
+ except ValueError:
+ return None
+
+
+def _now() -> datetime:
+ return datetime.now(timezone.utc)
+
+
+def _parse_dt(value) -> datetime:
+ """Coerce a Supabase timestamptz string (or datetime) to an aware datetime."""
+ if isinstance(value, datetime):
+ return value
+ if isinstance(value, str):
+ try:
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
+ except ValueError:
+ pass
+ return _now()
+
+
+# ===========================================================================
+# In-memory store (demo / CI)
+# ===========================================================================
+class InMemoryStore:
+ backend = "in-memory"
+
+ def __init__(self) -> None:
+ self.hospitals: List[Dict] = seed_data.hospitals()
+ self.donors: List[Dict] = seed_data.donors()
+ self.emergencies: Dict[str, Dict] = {}
+ self.confirmations: Dict[str, Dict] = {} # keyed by token
+ self._emergency_seq = itertools.count(1)
+ self._donor_seq = itertools.count(len(self.donors) + 1)
+ # OSM lazy-fetch cache: grid cells already fetched (0.3° ≈ 33 km)
+ self._osm_regions: set = set()
+ # Demo-donor cache: grid cells already given a synthesized donor pool.
+ self._donor_regions: set = set()
+
+ def _region_key(self, lat: float, lng: float) -> tuple:
+ return (round(lat / 0.3), round(lng / 0.3))
+
+ def is_region_fetched(self, lat: float, lng: float) -> bool:
+ return self._region_key(lat, lng) in self._osm_regions
+
+ def mark_region_fetched(self, lat: float, lng: float) -> None:
+ self._osm_regions.add(self._region_key(lat, lng))
+
+ def bulk_add_hospitals(self, hospitals: List[Dict]) -> int:
+ existing = {h["id"] for h in self.hospitals}
+ added = 0
+ for h in hospitals:
+ if h["id"] not in existing:
+ self.hospitals.append(h)
+ existing.add(h["id"])
+ added += 1
+ logger.info(
+ "OSM: added %d new hospitals (store total %d)", added, len(self.hospitals)
+ )
+ return added
+
+ def is_donor_region_seeded(self, lat: float, lng: float) -> bool:
+ return self._region_key(lat, lng) in self._donor_regions
+
+ def mark_donor_region_seeded(self, lat: float, lng: float) -> None:
+ self._donor_regions.add(self._region_key(lat, lng))
+
+ def bulk_add_donors(self, donors: List[Dict]) -> int:
+ existing = {d["id"] for d in self.donors}
+ added = 0
+ for d in donors:
+ if d["id"] not in existing:
+ self.donors.append(d)
+ existing.add(d["id"])
+ added += 1
+ logger.info(
+ "Donors: added %d demo donors near venue (store total %d)",
+ added,
+ len(self.donors),
+ )
+ return added
+
+ # --- Hospitals ---------------------------------------------------------
+ def hospitals_with_distance(self, lat: float, lng: float) -> List[Dict]:
+ out = []
+ for h in self.hospitals:
+ enriched = dict(h)
+ enriched["distance_km"] = round(
+ haversine_km(lat, lng, h["lat"], h["lng"]), 2
+ )
+ out.append(enriched)
+ return out
+
+ # --- Donors ------------------------------------------------------------
+ def compatible_donors_nearby(
+ self,
+ lat: float,
+ lng: float,
+ compatible_groups: List[str],
+ radius_meters: float,
+ cooldown_days: int,
+ cooldown_days_female: Optional[int] = None,
+ today: Optional[date] = None,
+ ) -> List[Dict]:
+ today = today or date.today()
+ groups = set(compatible_groups)
+ result: List[Dict] = []
+ for d in self.donors:
+ if d["blood_group"] not in groups:
+ continue
+ if not d.get("available", True):
+ continue
+ last = _parse_date(d.get("last_donated"))
+ cooldown = cooldown_days
+ if cooldown_days_female is not None and d.get("sex") == "female":
+ cooldown = cooldown_days_female
+ if last is not None and (today - last).days < cooldown:
+ continue
+ dist = haversine_meters(lat, lng, d["lat"], d["lng"])
+ if dist > radius_meters:
+ continue
+ enriched = dict(d)
+ enriched["distance_m"] = round(dist, 1)
+ result.append(enriched)
+ result.sort(key=lambda d: d["distance_m"])
+ return result
+
+ def add_donor(
+ self, name, phone, blood_group, lat, lng, last_donated, sex=None
+ ) -> str:
+ donor_id = f"d{next(self._donor_seq)}"
+ self.donors.append(
+ {
+ "id": donor_id,
+ "name": name,
+ "phone": phone,
+ "blood_group": blood_group,
+ "lat": lat,
+ "lng": lng,
+ "last_donated": last_donated,
+ "sex": sex,
+ "available": True,
+ }
+ )
+ return donor_id
+
+ # --- Emergencies -------------------------------------------------------
+ def create_emergency(
+ self, lat, lng, emergency_type, blood_group, hospital_cards, donors_alerted
+ ) -> Dict:
+ request_id = f"req{next(self._emergency_seq):04d}"
+ record = {
+ "id": request_id,
+ "lat": lat,
+ "lng": lng,
+ "emergency_type": emergency_type,
+ "blood_group": blood_group,
+ "status": "pending",
+ "created_at": _now(),
+ "hospitals": hospital_cards,
+ "donors_alerted": donors_alerted,
+ "donors_responded": 0,
+ }
+ self.emergencies[request_id] = record
+ return record
+
+ def get_emergency(self, request_id: str) -> Optional[Dict]:
+ return self.emergencies.get(request_id)
+
+ # --- Confirmation requests --------------------------------------------
+ def create_confirmation(
+ self, emergency_id, hospital_id, hospital_name, token
+ ) -> Dict:
+ record = {
+ "token": token,
+ "emergency_id": emergency_id,
+ "hospital_id": hospital_id,
+ "hospital_name": hospital_name,
+ "sent_at": _now(),
+ "replied_at": None,
+ "confirmed": None,
+ }
+ self.confirmations[token] = record
+ return record
+
+ def get_confirmation(self, token: str) -> Optional[Dict]:
+ return self.confirmations.get(token)
+
+ def confirmations_for_emergency(self, emergency_id: str) -> List[Dict]:
+ return [
+ c for c in self.confirmations.values() if c["emergency_id"] == emergency_id
+ ]
+
+ def emergency_is_taken(self, emergency_id: str) -> bool:
+ return any(
+ c["confirmed"] is True
+ for c in self.confirmations_for_emergency(emergency_id)
+ )
+
+ def hospital_reliability(self, hospital_id: str) -> tuple[int, float]:
+ """(#replied confirmations, acceptance rate) for a hospital, all-time.
+
+ Powers the cold-start gate in ranking: reliability is trusted only once
+ enough confirmations have actually been logged for this hospital.
+ """
+ replied = [
+ c
+ for c in self.confirmations.values()
+ if c["hospital_id"] == hospital_id and c["confirmed"] is not None
+ ]
+ if not replied:
+ return 0, 0.0
+ accepted = sum(1 for c in replied if c["confirmed"] is True)
+ return len(replied), accepted / len(replied)
+
+ def record_reply(self, token: str, accepted: bool) -> None:
+ """Persist a hospital's Accept/Decline and update the emergency snapshot."""
+ conf = self.confirmations.get(token)
+ if conf is None:
+ return
+ conf["confirmed"] = accepted
+ conf["replied_at"] = _now()
+ emergency = self.emergencies.get(conf["emergency_id"])
+ if emergency is not None:
+ status = "confirmed" if accepted else "declined"
+ for card in emergency["hospitals"]:
+ if card["hospital_id"] == conf["hospital_id"]:
+ card["status"] = status
+ if accepted:
+ emergency["status"] = "confirmed"
+
+ def ping(self) -> bool:
+ """Readiness check — the in-memory store is always ready."""
+ return True
+
+
+# ===========================================================================
+# Supabase store (production)
+# ===========================================================================
+class SupabaseStore:
+ backend = "supabase"
+
+ def __init__(self, client) -> None:
+ self.client = client
+ self._hospitals_cache: Optional[List[Dict]] = None
+
+ # --- Hospitals ---------------------------------------------------------
+ @property
+ def hospitals(self) -> List[Dict]:
+ if self._hospitals_cache is None:
+ res = self.client.table("hospitals").select("*").execute()
+ self._hospitals_cache = res.data or []
+ return self._hospitals_cache
+
+ def hospitals_with_distance(self, lat: float, lng: float) -> List[Dict]:
+ out = []
+ for h in self.hospitals:
+ enriched = dict(h)
+ enriched["distance_km"] = round(
+ haversine_km(lat, lng, h["lat"], h["lng"]), 2
+ )
+ out.append(enriched)
+ return out
+
+ # --- Donors (PostGIS radius via RPC) -----------------------------------
+ def compatible_donors_nearby(
+ self,
+ lat: float,
+ lng: float,
+ compatible_groups: List[str],
+ radius_meters: float,
+ cooldown_days: int,
+ cooldown_days_female: Optional[int] = None,
+ today: Optional[date] = None,
+ ) -> List[Dict]:
+ res = self.client.rpc(
+ "donors_nearby",
+ {
+ "p_lat": lat,
+ "p_lng": lng,
+ "p_groups": compatible_groups,
+ "p_radius_m": radius_meters,
+ "p_cooldown_days": cooldown_days,
+ "p_cooldown_days_female": (
+ cooldown_days_female
+ if cooldown_days_female is not None
+ else cooldown_days
+ ),
+ },
+ ).execute()
+ donors = res.data or []
+ for d in donors:
+ d["distance_m"] = round(haversine_meters(lat, lng, d["lat"], d["lng"]), 1)
+ return donors
+
+ def add_donor(
+ self, name, phone, blood_group, lat, lng, last_donated, sex=None
+ ) -> str:
+ donor_id = f"d{uuid.uuid4().hex[:8]}"
+ self.client.table("blood_donors").insert(
+ {
+ "id": donor_id,
+ "name": name,
+ "phone": phone,
+ "blood_group": blood_group,
+ "lat": lat,
+ "lng": lng,
+ "last_donated": last_donated,
+ "sex": sex,
+ "available": True,
+ }
+ ).execute()
+ return donor_id
+
+ # --- Emergencies -------------------------------------------------------
+ def create_emergency(
+ self, lat, lng, emergency_type, blood_group, hospital_cards, donors_alerted
+ ) -> Dict:
+ request_id = f"req{uuid.uuid4().hex[:8]}"
+ created_at = _now()
+ self.client.table("emergency_requests").insert(
+ {
+ "id": request_id,
+ "emergency_type": emergency_type,
+ "blood_group_needed": blood_group,
+ "lat": lat,
+ "lng": lng,
+ "status": "pending",
+ "donors_alerted": donors_alerted,
+ "donors_responded": 0,
+ "hospitals": hospital_cards,
+ }
+ ).execute()
+ return {
+ "id": request_id,
+ "lat": lat,
+ "lng": lng,
+ "emergency_type": emergency_type,
+ "blood_group": blood_group,
+ "status": "pending",
+ "created_at": created_at,
+ "hospitals": hospital_cards,
+ "donors_alerted": donors_alerted,
+ "donors_responded": 0,
+ }
+
+ def get_emergency(self, request_id: str) -> Optional[Dict]:
+ res = (
+ self.client.table("emergency_requests")
+ .select("*")
+ .eq("id", request_id)
+ .limit(1)
+ .execute()
+ )
+ if not res.data:
+ return None
+ row = res.data[0]
+ return {
+ "id": row["id"],
+ "lat": row["lat"],
+ "lng": row["lng"],
+ "emergency_type": row["emergency_type"],
+ "blood_group": row["blood_group_needed"],
+ "status": row["status"],
+ "created_at": _parse_dt(row.get("created_at")),
+ "hospitals": row.get("hospitals") or [],
+ "donors_alerted": row.get("donors_alerted", 0),
+ "donors_responded": row.get("donors_responded", 0),
+ }
+
+ # --- Confirmation requests --------------------------------------------
+ def create_confirmation(
+ self, emergency_id, hospital_id, hospital_name, token
+ ) -> Dict:
+ record = {
+ "token": token,
+ "emergency_id": emergency_id,
+ "hospital_id": hospital_id,
+ "hospital_name": hospital_name,
+ "confirmed": None,
+ }
+ self.client.table("confirmation_requests").insert(record).execute()
+ record["sent_at"] = _now()
+ record["replied_at"] = None
+ return record
+
+ def get_confirmation(self, token: str) -> Optional[Dict]:
+ res = (
+ self.client.table("confirmation_requests")
+ .select("*")
+ .eq("token", token)
+ .limit(1)
+ .execute()
+ )
+ if not res.data:
+ return None
+ row = res.data[0]
+ return {
+ "token": row["token"],
+ "emergency_id": row["emergency_id"],
+ "hospital_id": row["hospital_id"],
+ "hospital_name": row.get("hospital_name", ""),
+ "sent_at": _parse_dt(row.get("sent_at")),
+ "replied_at": row.get("replied_at"),
+ "confirmed": row.get("confirmed"),
+ }
+
+ def confirmations_for_emergency(self, emergency_id: str) -> List[Dict]:
+ res = (
+ self.client.table("confirmation_requests")
+ .select("*")
+ .eq("emergency_id", emergency_id)
+ .execute()
+ )
+ return res.data or []
+
+ def emergency_is_taken(self, emergency_id: str) -> bool:
+ res = (
+ self.client.table("confirmation_requests")
+ .select("token")
+ .eq("emergency_id", emergency_id)
+ .eq("confirmed", True)
+ .limit(1)
+ .execute()
+ )
+ return bool(res.data)
+
+ def hospital_reliability(self, hospital_id: str) -> tuple[int, float]:
+ """(#replied confirmations, acceptance rate) for a hospital, all-time."""
+ res = (
+ self.client.table("confirmation_requests")
+ .select("confirmed")
+ .eq("hospital_id", hospital_id)
+ .execute()
+ )
+ replied = [r for r in (res.data or []) if r.get("confirmed") is not None]
+ if not replied:
+ return 0, 0.0
+ accepted = sum(1 for r in replied if r["confirmed"] is True)
+ return len(replied), accepted / len(replied)
+
+ def record_reply(self, token: str, accepted: bool) -> None:
+ self.client.table("confirmation_requests").update(
+ {"confirmed": accepted, "replied_at": _now().isoformat()}
+ ).eq("token", token).execute()
+
+ conf = self.get_confirmation(token)
+ if conf is None:
+ return
+ emergency = self.get_emergency(conf["emergency_id"])
+ if emergency is None:
+ return
+ status = "confirmed" if accepted else "declined"
+ cards = emergency["hospitals"]
+ for card in cards:
+ if card["hospital_id"] == conf["hospital_id"]:
+ card["status"] = status
+ update = {"hospitals": cards}
+ if accepted:
+ update["status"] = "confirmed"
+ self.client.table("emergency_requests").update(update).eq(
+ "id", conf["emergency_id"]
+ ).execute()
+
+ def ping(self) -> bool:
+ """Readiness check — confirm the database answers a trivial query."""
+ try:
+ self.client.table("hospitals").select("id").limit(1).execute()
+ return True
+ except Exception:
+ logger.exception("Supabase ping failed")
+ return False
+
+
+# ===========================================================================
+# Store selection
+# ===========================================================================
+_store = None
+
+
+def get_store():
+ """Process-wide singleton store, chosen from configuration.
+
+ Supabase when configured and reachable; in-memory otherwise. A Supabase
+ init failure logs and falls back to in-memory rather than breaking boot.
+ """
+ global _store
+ if _store is not None:
+ return _store
+
+ if settings.use_supabase:
+ try:
+ from supabase import create_client
+
+ client = create_client(settings.supabase_url, settings.supabase_active_key)
+ _store = SupabaseStore(client)
+ key_kind = "service-role" if settings.supabase_service_key else "anon"
+ logger.info("Data layer: Supabase (PostGIS), %s key", key_kind)
+ except Exception:
+ logger.exception("Supabase init failed — falling back to in-memory store")
+ _store = InMemoryStore()
+ else:
+ _store = InMemoryStore()
+ return _store
diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py
new file mode 100644
index 0000000..ef37057
--- /dev/null
+++ b/backend/tests/conftest.py
@@ -0,0 +1,27 @@
+"""Shared pytest fixtures.
+
+Each test gets a fresh, isolated in-memory store so order never matters.
+"""
+
+import pytest
+from fastapi.testclient import TestClient
+
+import store as store_module
+from main import app
+
+
+@pytest.fixture(autouse=True)
+def fresh_store():
+ """Reset the singleton store before every test."""
+ store_module._store = None
+ yield store_module.get_store()
+ store_module._store = None
+
+
+@pytest.fixture
+def client():
+ return TestClient(app)
+
+
+# Jaipur centre — a convenient default emergency location.
+JAIPUR = {"lat": 26.9124, "lng": 75.7873}
diff --git a/backend/tests/fake_supabase.py b/backend/tests/fake_supabase.py
new file mode 100644
index 0000000..cd2322b
--- /dev/null
+++ b/backend/tests/fake_supabase.py
@@ -0,0 +1,136 @@
+"""A minimal in-process fake of the supabase-py client.
+
+Implements just the query-builder surface that ``SupabaseStore`` uses
+(``table().select().eq().limit().execute()``, ``insert``, ``update``, and
+``rpc``) backed by plain lists. Lets us exercise the production store's logic —
+column names, query chains, key mapping — without a live database.
+"""
+
+from __future__ import annotations
+
+from datetime import date
+from typing import Dict, List
+
+import seed_data
+from geo import haversine_meters
+
+
+class _Result:
+ def __init__(self, data):
+ self.data = data
+
+
+class _Query:
+ def __init__(self, rows: List[Dict], op: str, payload=None):
+ self._rows = rows # the live backing list for this table
+ self._op = op
+ self._payload = payload
+ self._filters = [] # list of (col, value)
+ self._limit = None
+
+ # builder methods (all return self) --------------------------------------
+ def select(self, *_cols):
+ self._op = "select"
+ return self
+
+ def eq(self, col, value):
+ self._filters.append((col, value))
+ return self
+
+ def limit(self, n):
+ self._limit = n
+ return self
+
+ def _matches(self, row) -> bool:
+ return all(row.get(col) == value for col, value in self._filters)
+
+ # terminal ---------------------------------------------------------------
+ def execute(self) -> _Result:
+ if self._op == "select":
+ matched = [dict(r) for r in self._rows if self._matches(r)]
+ if self._limit is not None:
+ matched = matched[: self._limit]
+ return _Result(matched)
+
+ if self._op == "insert":
+ items = (
+ self._payload if isinstance(self._payload, list) else [self._payload]
+ )
+ for item in items:
+ self._rows.append(dict(item))
+ return _Result([dict(i) for i in items])
+
+ if self._op == "update":
+ updated = []
+ for row in self._rows:
+ if self._matches(row):
+ row.update(self._payload)
+ updated.append(dict(row))
+ return _Result(updated)
+
+ return _Result([])
+
+
+class FakeSupabaseClient:
+ def __init__(self):
+ self.tables: Dict[str, List[Dict]] = {
+ "hospitals": [dict(h) for h in seed_data.hospitals()],
+ "blood_donors": [dict(d) for d in seed_data.donors()],
+ "emergency_requests": [],
+ "confirmation_requests": [],
+ }
+
+ def table(self, name: str) -> _Query:
+ return _Query(self.tables.setdefault(name, []), op="select")
+
+ # supabase: client.table(name).insert(...) / .update(...)
+ # The builder above starts in "select"; insert/update switch the op.
+ def _wrap_insert_update(self): # pragma: no cover - documentation only
+ ...
+
+ def rpc(self, name: str, params: Dict) -> _Query:
+ if name != "donors_nearby":
+ return _Query([], op="select")
+ rows = self._donors_nearby(params)
+ q = _Query(rows, op="select")
+ return q
+
+ def _donors_nearby(self, p: Dict) -> List[Dict]:
+ today = date.today()
+ groups = set(p["p_groups"])
+ cd_male = p.get("p_cooldown_days", 90)
+ cd_female = p.get("p_cooldown_days_female", cd_male)
+ out = []
+ for d in self.tables["blood_donors"]:
+ if d["blood_group"] not in groups or not d.get("available", True):
+ continue
+ last = d.get("last_donated")
+ if last:
+ cooldown = cd_female if d.get("sex") == "female" else cd_male
+ if (today - date.fromisoformat(last)).days < cooldown:
+ continue
+ if (
+ haversine_meters(p["p_lat"], p["p_lng"], d["lat"], d["lng"])
+ > p["p_radius_m"]
+ ):
+ continue
+ out.append(dict(d))
+ return out
+
+
+# supabase-py exposes insert()/update() on the table builder; add them here so
+# `client.table(x).insert(...)` works with our _Query starting state.
+def _insert(self, payload):
+ self._op = "insert"
+ self._payload = payload
+ return self
+
+
+def _update(self, payload):
+ self._op = "update"
+ self._payload = payload
+ return self
+
+
+_Query.insert = _insert
+_Query.update = _update
diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py
new file mode 100644
index 0000000..6d6f0ab
--- /dev/null
+++ b/backend/tests/test_api.py
@@ -0,0 +1,215 @@
+"""Endpoint tests — verify the API_CONTRACT.md shapes and the live flow."""
+
+from tests.conftest import JAIPUR
+
+import store as store_module
+
+
+def _trigger(client, **overrides):
+ payload = {**JAIPUR, "emergency_type": "trauma", "blood_group": "O+", **overrides}
+ return client.post("/emergency", json=payload)
+
+
+# --- Health / meta ---------------------------------------------------------
+def test_health(client):
+ resp = client.get("/health")
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["status"] == "ok"
+ assert "version" in body and "mode" in body
+
+
+def test_ready(client):
+ resp = client.get("/ready")
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["ready"] is True
+ assert body["backend"] == "in-memory"
+
+
+# --- POST /emergency -------------------------------------------------------
+def test_emergency_returns_contract_shape(client):
+ resp = _trigger(client)
+ assert resp.status_code == 200
+ body = resp.json()
+ assert set(body) == {"request_id", "hospitals", "donors_alerted", "rare_group"}
+ assert isinstance(body["hospitals"], list) and body["hospitals"]
+ assert isinstance(body["donors_alerted"], int)
+ assert isinstance(body["rare_group"], bool)
+
+ card = body["hospitals"][0]
+ assert set(card) == {
+ "hospital_id",
+ "name",
+ "eta_minutes",
+ "department_match",
+ "distance_km",
+ "status",
+ "phone",
+ }
+ assert card["status"] == "pending"
+
+
+def test_emergency_alerts_donors_with_blood_bank_directive(client):
+ from services import sms_service
+
+ sms_service.recent_alerts.clear()
+ _trigger(client)
+ assert sms_service.recent_alerts, "expected the blood branch to dispatch alerts"
+ assert any("blood bank" in a["message"].lower() for a in sms_service.recent_alerts)
+
+
+def test_rare_group_flag_tracks_rh_negative(client):
+ assert _trigger(client, blood_group="O-").json()["rare_group"] is True
+ assert _trigger(client, blood_group="O+").json()["rare_group"] is False
+
+
+def test_emergency_hospitals_sorted_best_first(client):
+ body = _trigger(client).json()
+ etas = [h["eta_minutes"] for h in body["hospitals"]]
+ # Not strictly sorted by ETA (score blends factors), but the best card
+ # should be a sensible, nearby option.
+ assert all(isinstance(e, int) and e >= 1 for e in etas)
+
+
+def test_emergency_rejects_bad_enum(client):
+ resp = _trigger(client, emergency_type="zombie")
+ assert resp.status_code == 422
+
+
+def test_emergency_rejects_out_of_range_coordinates(client):
+ assert _trigger(client, lat=999).status_code == 422
+ assert _trigger(client, lng=-500).status_code == 422
+
+
+# --- GET /emergency/{id}/status -------------------------------------------
+def test_status_unknown_request_id_is_404(client):
+ assert client.get("/emergency/nope/status").status_code == 404
+
+
+def test_status_shape(client):
+ request_id = _trigger(client).json()["request_id"]
+ body = client.get(f"/emergency/{request_id}/status").json()
+ assert set(body) == {
+ "request_id",
+ "hospitals",
+ "donors_alerted",
+ "donors_responded",
+ "unconfirmed_fallback",
+ }
+ assert body["request_id"] == request_id
+ # Freshly created — well inside the no-confirmation window.
+ assert body["unconfirmed_fallback"] is False
+ card = body["hospitals"][0]
+ assert set(card) == {"hospital_id", "name", "eta_minutes", "status"}
+
+
+# --- POST /confirm/{token} — the live flow --------------------------------
+def test_confirm_flips_hospital_status_live(client):
+ request_id = _trigger(client).json()["request_id"]
+ store = store_module.get_store()
+ confs = store.confirmations_for_emergency(request_id)
+ assert len(confs) >= 2
+ first, second = confs[0], confs[1]
+
+ # First hospital accepts -> ok, not already confirmed.
+ r1 = client.post(f"/confirm/{first['token']}", json={"accepted": True})
+ assert r1.status_code == 200
+ body1 = r1.json()
+ assert set(body1) == {"ok", "hospital_name", "already_confirmed"}
+ assert body1["ok"] is True
+ assert body1["already_confirmed"] is False
+
+ # Status now shows that hospital confirmed.
+ status = client.get(f"/emergency/{request_id}/status").json()
+ confirmed = [h for h in status["hospitals"] if h["status"] == "confirmed"]
+ assert any(h["hospital_id"] == first["hospital_id"] for h in confirmed)
+
+ # A second hospital accepting is told the patient is already routed.
+ r2 = client.post(f"/confirm/{second['token']}", json={"accepted": True})
+ assert r2.json()["already_confirmed"] is True
+
+
+def test_confirm_unknown_token_is_404(client):
+ resp = client.post("/confirm/does-not-exist", json={"accepted": True})
+ assert resp.status_code == 404
+
+
+# --- GET /confirm/{token} — details for the hospital page ------------------
+def test_confirm_details_shape_and_values(client):
+ request_id = _trigger(client, emergency_type="trauma", blood_group="O+").json()[
+ "request_id"
+ ]
+ store = store_module.get_store()
+ conf = store.confirmations_for_emergency(request_id)[0]
+
+ body = client.get(f"/confirm/{conf['token']}").json()
+ assert set(body) == {
+ "hospital_name",
+ "emergency_type",
+ "blood_group",
+ "eta_minutes",
+ "already_confirmed",
+ "responded",
+ "accepted",
+ }
+ assert body["emergency_type"] == "trauma"
+ assert body["blood_group"] == "O+"
+ assert body["hospital_name"] == conf["hospital_name"]
+ assert isinstance(body["eta_minutes"], int)
+ # Freshly created, nobody has replied yet.
+ assert body["already_confirmed"] is False
+ assert body["responded"] is False
+ assert body["accepted"] is False
+
+
+def test_confirm_details_reflects_reply_and_race(client):
+ request_id = _trigger(client).json()["request_id"]
+ store = store_module.get_store()
+ confs = store.confirmations_for_emergency(request_id)
+ first, second = confs[0], confs[1]
+
+ client.post(f"/confirm/{first['token']}", json={"accepted": True})
+
+ # The hospital that accepted sees responded/accepted true.
+ winner = client.get(f"/confirm/{first['token']}").json()
+ assert winner["responded"] is True
+ assert winner["accepted"] is True
+
+ # Another hospital sees the patient is already routed.
+ other = client.get(f"/confirm/{second['token']}").json()
+ assert other["already_confirmed"] is True
+ assert other["responded"] is False
+
+
+def test_confirm_details_unknown_token_is_404(client):
+ assert client.get("/confirm/does-not-exist").status_code == 404
+
+
+# --- POST /donor/register --------------------------------------------------
+def test_register_donor(client):
+ resp = client.post(
+ "/donor/register",
+ json={
+ "name": "Asha Verma",
+ "phone": "+919812345678",
+ "blood_group": "B+",
+ "lat": 26.92,
+ "lng": 75.80,
+ "last_donated": "2025-01-15",
+ },
+ )
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["ok"] is True
+ assert body["donor_id"].startswith("d")
+
+
+# --- POST /sms/inbound -----------------------------------------------------
+def test_sms_inbound_parses_and_replies(client):
+ resp = client.post(
+ "/sms/inbound",
+ json={"from": "+919800000000", "body": "BLOOD O+ Malviya Nagar Jaipur"},
+ )
+ assert resp.status_code == 200
+ assert "Nearest hospitals" in resp.json()["reply"]
diff --git a/backend/tests/test_supabase_store.py b/backend/tests/test_supabase_store.py
new file mode 100644
index 0000000..0d5d7cc
--- /dev/null
+++ b/backend/tests/test_supabase_store.py
@@ -0,0 +1,86 @@
+"""Verify the production SupabaseStore logic against a fake supabase client.
+
+Exercises the same emergency -> confirm -> status flow as the in-memory tests,
+but through ``SupabaseStore`` query chains, proving column names and key mapping
+(e.g. blood_group_needed, hospitals jsonb, hospital_name) are correct.
+"""
+
+import asyncio
+
+from blood import compatible_donor_groups
+from services import confirm_service, emergency_service
+from store import SupabaseStore
+from tests.fake_supabase import FakeSupabaseClient
+
+
+def _store():
+ return SupabaseStore(FakeSupabaseClient())
+
+
+def test_ping_ok():
+ assert _store().ping() is True
+
+
+def test_hospitals_with_distance_reads_table():
+ store = _store()
+ rows = store.hospitals_with_distance(26.9124, 75.7873)
+ assert len(rows) == 25
+ assert all("distance_km" in r for r in rows)
+
+
+def test_compatible_donors_nearby_via_rpc():
+ store = _store()
+ donors = store.compatible_donors_nearby(
+ 26.9124,
+ 75.7873,
+ compatible_donor_groups("O+"),
+ radius_meters=5000,
+ cooldown_days=90,
+ )
+ compatible = set(compatible_donor_groups("O+"))
+ assert all(d["blood_group"] in compatible for d in donors)
+ assert all(d["distance_m"] <= 5000 for d in donors)
+
+
+def test_full_flow_emergency_confirm_status():
+ store = _store()
+
+ resp = asyncio.run(
+ emergency_service.trigger_emergency(
+ store, lat=26.9124, lng=75.7873, emergency_type="trauma", blood_group="O+"
+ )
+ )
+ assert resp["hospitals"] and "request_id" in resp
+ request_id = resp["request_id"]
+
+ # The emergency and its confirmations persisted to the fake DB.
+ confs = store.confirmations_for_emergency(request_id)
+ assert len(confs) == len(resp["hospitals"])
+
+ # First hospital accepts.
+ first = confs[0]
+ out = confirm_service.handle_confirmation(store, first["token"], accepted=True)
+ assert out["already_confirmed"] is False
+ assert out["hospital_name"]
+
+ # Status reflects the confirmation, read back from the DB.
+ status = emergency_service.get_status(store, request_id)
+ confirmed = [h for h in status["hospitals"] if h["status"] == "confirmed"]
+ assert any(h["hospital_id"] == first["hospital_id"] for h in confirmed)
+
+ # A second hospital accepting is told the patient is already routed.
+ if len(confs) > 1:
+ out2 = confirm_service.handle_confirmation(
+ store, confs[1]["token"], accepted=True
+ )
+ assert out2["already_confirmed"] is True
+
+
+def test_register_donor_persists():
+ store = _store()
+ before = len(store.tables["blood_donors"]) if hasattr(store, "tables") else None
+ donor_id = store.add_donor("Test User", "+919800000000", "B+", 26.9, 75.8, None)
+ assert donor_id.startswith("d")
+ # Confirm it is queryable back.
+ rows = store.client.tables["blood_donors"]
+ assert any(r["id"] == donor_id for r in rows)
diff --git a/backend/tests/test_units.py b/backend/tests/test_units.py
new file mode 100644
index 0000000..1e21e9f
--- /dev/null
+++ b/backend/tests/test_units.py
@@ -0,0 +1,129 @@
+"""Unit tests for the algorithms: blood compatibility, geo, ranking, matching."""
+
+import asyncio
+from datetime import date, timedelta
+
+from blood import compatible_donor_groups
+from geo import haversine_km
+from services.donor_service import match_donors
+from services.hospital_service import rank_hospitals
+from store import get_store
+
+
+# --- Blood compatibility ---------------------------------------------------
+def test_o_negative_is_universal_donor_only_for_o_negative_recipient():
+ assert compatible_donor_groups("O-") == ["O-"]
+
+
+def test_ab_positive_is_universal_recipient():
+ assert set(compatible_donor_groups("AB+")) == {
+ "O-",
+ "O+",
+ "A-",
+ "A+",
+ "B-",
+ "B+",
+ "AB-",
+ "AB+",
+ }
+
+
+def test_a_positive_recipient_accepts_o_and_a():
+ assert set(compatible_donor_groups("A+")) == {"O-", "O+", "A-", "A+"}
+
+
+def test_unknown_group_fails_safe_to_empty():
+ assert compatible_donor_groups("Z+") == []
+
+
+# --- Geo -------------------------------------------------------------------
+def test_haversine_zero_distance():
+ assert haversine_km(26.9, 75.8, 26.9, 75.8) == 0.0
+
+
+def test_haversine_known_distance_is_reasonable():
+ # ~1 degree of latitude is ~111 km.
+ km = haversine_km(26.0, 75.0, 27.0, 75.0)
+ assert 110 < km < 112
+
+
+# --- Hospital ranking ------------------------------------------------------
+def test_ranking_returns_at_most_top_n_sorted_by_score():
+ store = get_store()
+ ranked = asyncio.run(rank_hospitals(store, 26.9124, 75.7873, "trauma", top_n=5))
+ assert 1 <= len(ranked) <= 5
+ scores = [c["_score"] for c in ranked]
+ assert scores == sorted(scores, reverse=True)
+
+
+def test_ranking_prefers_department_match():
+ store = get_store()
+ ranked = asyncio.run(rank_hospitals(store, 26.9124, 75.7873, "trauma", top_n=5))
+ # At least one of the top hospitals actually has the trauma department.
+ assert any(c["department_match"] for c in ranked)
+
+
+def test_hospital_reliability_cold_start_then_accumulates():
+ # At launch a hospital has no logged confirmations -> rel is not trusted.
+ store = get_store()
+ assert store.hospital_reliability("h1") == (0, 0.0)
+
+ # Two confirmations logged for the same hospital: one accept, one decline.
+ store.create_confirmation("e1", "h1", "SMS Hospital", "tok-a")
+ store.create_confirmation("e1", "h1", "SMS Hospital", "tok-b")
+ store.record_reply("tok-a", True)
+ store.record_reply("tok-b", False)
+ count, rate = store.hospital_reliability("h1")
+ assert count == 2 and rate == 0.5
+
+
+# --- Donor matching --------------------------------------------------------
+def test_match_donors_respects_compatibility_and_radius():
+ store = get_store()
+ today = date(2026, 6, 18)
+ donors = match_donors(store, 26.9124, 75.7873, "O+", today=today)
+ compatible = set(compatible_donor_groups("O+"))
+ assert all(d["blood_group"] in compatible for d in donors)
+ assert all(d["distance_m"] <= 5000 for d in donors)
+
+
+def test_match_donors_excludes_recent_donations():
+ store = get_store()
+ today = date(2026, 6, 18)
+ donors = match_donors(store, 26.9124, 75.7873, "AB+", today=today)
+ for d in donors:
+ if d["last_donated"]:
+ days = (today - date.fromisoformat(d["last_donated"])).days
+ assert days >= 90
+
+
+def test_female_donor_has_longer_cooldown():
+ # Same group, same spot, donated 100 days ago: a man is eligible (>= 90d),
+ # a woman is still inside her 120-day window.
+ store = get_store()
+ today = date(2026, 6, 18)
+ hundred_days_ago = (today - timedelta(days=100)).isoformat()
+ store.add_donor(
+ "Male Donor",
+ "+919800000001",
+ "O-",
+ 26.9124,
+ 75.7873,
+ hundred_days_ago,
+ sex="male",
+ )
+ store.add_donor(
+ "Female Donor",
+ "+919800000002",
+ "O-",
+ 26.9124,
+ 75.7873,
+ hundred_days_ago,
+ sex="female",
+ )
+
+ phones = {
+ d["phone"] for d in match_donors(store, 26.9124, 75.7873, "O-", today=today)
+ }
+ assert "+919800000001" in phones # man: 100 days >= 90-day cooldown
+ assert "+919800000002" not in phones # woman: 100 days < 120-day cooldown
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..3eaf7ce
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,17 @@
+# One-command local backend: `docker compose up`.
+# The API runs in demo mode by default (in-memory store, no external services).
+# To point it at a real Supabase/Maps stack, fill in backend/.env and it is
+# picked up automatically.
+services:
+ api:
+ build: ./backend
+ ports:
+ - "8000:8000"
+ env_file:
+ - path: ./backend/.env
+ required: false
+ environment:
+ # Sensible demo defaults even without a .env file present.
+ DELIVERY_CHANNEL: ${DELIVERY_CHANNEL:-console}
+ CORS_ORIGINS: ${CORS_ORIGINS:-*}
+ restart: unless-stopped
diff --git a/docs/architecture.svg b/docs/architecture.svg
new file mode 100644
index 0000000..2f7155d
--- /dev/null
+++ b/docs/architecture.svg
@@ -0,0 +1,70 @@
+
diff --git a/docs/banner.svg b/docs/banner.svg
new file mode 100644
index 0000000..3ca9019
--- /dev/null
+++ b/docs/banner.svg
@@ -0,0 +1,64 @@
+
diff --git a/docs/flow.svg b/docs/flow.svg
new file mode 100644
index 0000000..4406991
--- /dev/null
+++ b/docs/flow.svg
@@ -0,0 +1,75 @@
+
diff --git a/frontend/.env.example b/frontend/.env.example
new file mode 100644
index 0000000..8209332
--- /dev/null
+++ b/frontend/.env.example
@@ -0,0 +1,4 @@
+# GoldenHour Frontend Environment Variables
+VITE_API_BASE_URL=http://localhost:8000
+VITE_SUPABASE_URL=https://your-supabase-project.supabase.co
+VITE_SUPABASE_ANON_KEY=your-supabase-anon-key
diff --git a/frontend/.gitignore b/frontend/.gitignore
index a547bf3..1eb4846 100644
--- a/frontend/.gitignore
+++ b/frontend/.gitignore
@@ -22,3 +22,8 @@ dist-ssr
*.njsproj
*.sln
*.sw?
+
+# Environments
+.env
+.env.*
+!.env.example
diff --git a/frontend/README.md b/frontend/README.md
index a36934d..f580ff1 100644
--- a/frontend/README.md
+++ b/frontend/README.md
@@ -1,16 +1,108 @@
-# React + Vite
+# GoldenHour — Frontend
-This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
+The mobile-first PWA that everything the user touches lives in: a panicked
+family member triggers an emergency, watches hospitals respond live, and a
+hospital contact accepts the patient — all on a phone screen.
-Currently, two official plugins are available:
+Built with **React + Vite + Tailwind CSS** and **React Router**. Every API call
+matches [`../API_CONTRACT.md`](../API_CONTRACT.md) exactly.
-- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
-- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
+---
-## React Compiler
+## Tech stack
-The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
+| Piece | Tool | Why |
+| --- | --- | --- |
+| Framework | React 19 + Vite | Fast dev server, instant HMR |
+| Styling | Tailwind CSS | Clean, mobile-friendly utility classes |
+| Routing | React Router | Switch between the four screens |
+| Live updates | Supabase JS client / polling | Cards flip live when a hospital confirms |
-## Expanding the ESLint configuration
+---
-If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
+## The four screens
+
+Each is a route rendered inside a shared mobile shell (`src/App.jsx`):
+
+| Route | Component | What it does |
+| --- | --- | --- |
+| `/` | `PatientIntakeView` | Emergency intake — "Use my location" (Geolocation API), emergency type + blood group dropdowns, big **GET HELP** button → `POST /emergency`, navigates to `/results/:id`. |
+| `/results/:id` | `PatientResultsView` | Live results — list of hospital cards (name, ETA badge, department match, status badge `pending`/`confirmed`/`declined`, 1-tap call button) and a blood-donor panel. Cards update live. |
+| `/confirm/:token` | `HospitalConfirmPage` | The hospital contact's view — emergency type · blood group · ETA, **Accept Patient** / **Not Available** → `POST /confirm/{token}`. Shows "already routed" when `already_confirmed` is true. |
+| `/register` | `DonorRegistration` | One-screen donor sign-up form → `POST /donor/register`. |
+
+> **Status:** screens are built and navigable; they currently render placeholder
+> data per the "fake data first" approach in the PRD. Wiring the real `fetch()`
+> calls against the backend is the remaining integration step — because each
+> screen is built against the contract shapes, swapping fake data for `fetch()`
+> is a localized change.
+
+### Live updates
+
+When a hospital taps **Accept** on `/confirm/:token`, the matching card on
+`/results/:id` must flip `pending → confirmed` without a refresh. Two options
+(agree with the backend owner which to ship):
+
+- **Polling** — call `GET /emergency/{id}/status` every ~3 s and re-render.
+- **Supabase Realtime** — subscribe to the `confirmation_requests` table and
+ update the matching card when a row changes.
+
+Start with polling; upgrade to Realtime if there's time.
+
+---
+
+## Repository structure
+
+```
+frontend/
+├── index.html # App shell + meta/description/OG/PWA tags
+├── src/
+│ ├── main.jsx # React entrypoint
+│ ├── App.jsx # Mobile shell + React Router routes
+│ ├── components/
+│ │ ├── PatientIntakeView.jsx # Screen 1 (/)
+│ │ ├── PatientResultsView.jsx # Screen 2 (/results/:id)
+│ │ ├── HospitalConfirmPage.jsx # Screen 4 (/confirm/:token)
+│ │ └── DonorRegistration.jsx # Screen 3 (/register)
+│ ├── index.css # Tailwind entry
+│ └── assets/ # Images / icons
+├── public/ # favicon, static icons
+├── tailwind.config.js
+├── vite.config.js
+└── package.json
+```
+
+---
+
+## Running it
+
+```bash
+cd frontend
+npm install
+npm run dev # http://localhost:5173
+```
+
+Other scripts: `npm run build` (production build → `dist/`), `npm run preview`
+(serve the build), `npm run lint`.
+
+### Talking to the backend
+
+The API base URL is supplied via a Vite env var. Create `frontend/.env.local`:
+
+```
+VITE_API_BASE_URL=http://localhost:8000
+```
+
+In production set it to the deployed backend, e.g.
+`https://goldenhour-api.onrender.com`. Read it in code with
+`import.meta.env.VITE_API_BASE_URL`. (The backend has CORS open for the
+frontend origin, so cross-port calls in dev just work.)
+
+---
+
+## Deployment
+
+Deployed on Vercel — see [`../vercel.json`](../vercel.json). It builds
+`frontend/` and rewrites all routes to `index.html` so React Router's client-side
+routes (`/results/:id`, `/confirm/:token`, …) resolve on hard refresh and
+deep links. Set `VITE_API_BASE_URL` as a Vercel environment variable.
diff --git a/frontend/index.html b/frontend/index.html
index f94d687..0794bb4 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -3,11 +3,52 @@
-
- frontend
+
+
+
+
+
+ GoldenHour — Every Second Counts
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
-
+
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 49a1301..acb10ff 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -1,19 +1,27 @@
{
- "name": "frontend",
- "version": "0.0.0",
+ "name": "goldenhour-frontend",
+ "version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
- "name": "frontend",
- "version": "0.0.0",
+ "name": "goldenhour-frontend",
+ "version": "0.1.0",
+ "license": "MIT",
"dependencies": {
+ "@supabase/supabase-js": "^2.108.2",
+ "@types/three": "^0.184.1",
+ "framer-motion": "^12.40.0",
+ "gsap": "^3.15.0",
+ "lenis": "^1.3.23",
+ "lucide-react": "^1.21.0",
"react": "^19.2.6",
- "react-dom": "^19.2.6"
+ "react-dom": "^19.2.6",
+ "three": "^0.184.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
- "@supabase/supabase-js": "^2.108.2",
+ "@tailwindcss/vite": "^4.3.1",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
@@ -23,7 +31,7 @@
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"postcss": "^8.5.15",
- "react-router-dom": "^7.18.0",
+ "react-router-dom": "^6.30.4",
"tailwindcss": "^4.3.1",
"vite": "^8.0.12"
}
@@ -269,10 +277,16 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@dimforge/rapier3d-compat": {
+ "version": "0.12.0",
+ "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
+ "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
+ "license": "Apache-2.0"
+ },
"node_modules/@emnapi/wasi-threads": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
- "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
+ "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -553,6 +567,16 @@
"url": "https://github.com/sponsors/Boshen"
}
},
+ "node_modules/@remix-run/router": {
+ "version": "1.23.3",
+ "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz",
+ "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz",
@@ -776,6 +800,40 @@
"node": "^20.19.0 || >=22.12.0"
}
},
+ "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": {
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
+ "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.1",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
+ "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
+ "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz",
@@ -821,7 +879,6 @@
"version": "2.108.2",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.108.2.tgz",
"integrity": "sha512-tNaQmBgodDZwgB40mRwVbxFy8IDYwjdpcZ0BYrWiwlULCSQoJj4QoG4zgJT7QRPXcqipefNOzvO/qAu4dF98ag==",
- "dev": true,
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
@@ -834,7 +891,6 @@
"version": "2.108.2",
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.108.2.tgz",
"integrity": "sha512-RNUX8EiBy3iLwAX19jtRzLyePnl11/fHcgwDHLnpKcDSXt/5qBnh3LUwAtIjT21Q66QsmNUR2esrHziLCpNubw==",
- "dev": true,
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
@@ -847,14 +903,12 @@
"version": "0.4.4",
"resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.4.tgz",
"integrity": "sha512-Gt0pqoXuIqX/8dvG0OKp/wMCobXNH3klNbUPBNyOfN0YA1IswrM3HyWFMOPk1Jy+BRaIyDPcFx4jLBwHNmlyfQ==",
- "dev": true,
"license": "MIT"
},
"node_modules/@supabase/postgrest-js": {
"version": "2.108.2",
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.108.2.tgz",
"integrity": "sha512-GQ28/Y8hk3CFmkb3kXH1h/AQx6JIYSQfO0CJMRVBcEKZoNy6C45cXAZ4fcJvRC5Id0cs6xnkUV0+c0rIocigsw==",
- "dev": true,
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
@@ -867,7 +921,6 @@
"version": "2.108.2",
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.108.2.tgz",
"integrity": "sha512-aAGxCSUemZvQIibnCdvNvgaKib28I4rfrNjKbQ9cG1uBLwUsI7hVpGXgEbypCCDhLjQlDTAiJlu7rgljYUT73g==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@supabase/phoenix": "^0.4.2",
@@ -881,7 +934,6 @@
"version": "2.108.2",
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.108.2.tgz",
"integrity": "sha512-TVZPQxXGxY2+A6yTtm77zUHsh70lBhYUEaJL8RQC+BghcX/ygiMG/rmXrNVBce30/WAeNPa8FiG8HbqlGeV05g==",
- "dev": true,
"license": "MIT",
"dependencies": {
"iceberg-js": "^0.8.1",
@@ -895,7 +947,6 @@
"version": "2.108.2",
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.108.2.tgz",
"integrity": "sha512-hFhnPveb5JQg4a0QYicM0swT253YHMdfeRAl2BKHOlI5VAzuHxUGSr8RbwNLYNPauWOgQMS1H8sz8bvYlgwUfQ==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@supabase/auth-js": "2.108.2",
@@ -908,6 +959,284 @@
"node": ">=20.0.0"
}
},
+ "node_modules/@tailwindcss/node": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz",
+ "integrity": "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.5",
+ "enhanced-resolve": "5.21.6",
+ "jiti": "^2.7.0",
+ "lightningcss": "1.32.0",
+ "magic-string": "^0.30.21",
+ "source-map-js": "^1.2.1",
+ "tailwindcss": "4.3.1"
+ }
+ },
+ "node_modules/@tailwindcss/oxide": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.1.tgz",
+ "integrity": "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20"
+ },
+ "optionalDependencies": {
+ "@tailwindcss/oxide-android-arm64": "4.3.1",
+ "@tailwindcss/oxide-darwin-arm64": "4.3.1",
+ "@tailwindcss/oxide-darwin-x64": "4.3.1",
+ "@tailwindcss/oxide-freebsd-x64": "4.3.1",
+ "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1",
+ "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1",
+ "@tailwindcss/oxide-linux-arm64-musl": "4.3.1",
+ "@tailwindcss/oxide-linux-x64-gnu": "4.3.1",
+ "@tailwindcss/oxide-linux-x64-musl": "4.3.1",
+ "@tailwindcss/oxide-wasm32-wasi": "4.3.1",
+ "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1",
+ "@tailwindcss/oxide-win32-x64-msvc": "4.3.1"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-android-arm64": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.1.tgz",
+ "integrity": "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-arm64": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.1.tgz",
+ "integrity": "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-x64": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.1.tgz",
+ "integrity": "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-freebsd-x64": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.1.tgz",
+ "integrity": "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.1.tgz",
+ "integrity": "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.1.tgz",
+ "integrity": "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.1.tgz",
+ "integrity": "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.1.tgz",
+ "integrity": "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-musl": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.1.tgz",
+ "integrity": "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.1.tgz",
+ "integrity": "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==",
+ "bundleDependencies": [
+ "@napi-rs/wasm-runtime",
+ "@emnapi/core",
+ "@emnapi/runtime",
+ "@tybys/wasm-util",
+ "@emnapi/wasi-threads",
+ "tslib"
+ ],
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "^1.10.0",
+ "@emnapi/runtime": "^1.10.0",
+ "@emnapi/wasi-threads": "^1.2.1",
+ "@napi-rs/wasm-runtime": "^1.1.4",
+ "@tybys/wasm-util": "^0.10.2",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz",
+ "integrity": "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.1.tgz",
+ "integrity": "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/vite": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.1.tgz",
+ "integrity": "sha512-hItDHuIIlEV61R+faXu66s1K36aTurO/Qw0e45Vskz57gXl9pWOT6eg3zmcEui6CZXddbN7zd41bwmvag4JGwQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@tailwindcss/node": "4.3.1",
+ "@tailwindcss/oxide": "4.3.1",
+ "tailwindcss": "4.3.1"
+ },
+ "peerDependencies": {
+ "vite": "^5.2.0 || ^6 || ^7 || ^8"
+ }
+ },
+ "node_modules/@tweenjs/tween.js": {
+ "version": "23.1.3",
+ "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
+ "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
+ "license": "MIT"
+ },
"node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
@@ -961,6 +1290,32 @@
"@types/react": "^19.2.0"
}
},
+ "node_modules/@types/stats.js": {
+ "version": "0.17.4",
+ "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
+ "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/three": {
+ "version": "0.184.1",
+ "resolved": "https://registry.npmjs.org/@types/three/-/three-0.184.1.tgz",
+ "integrity": "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA==",
+ "license": "MIT",
+ "dependencies": {
+ "@dimforge/rapier3d-compat": "~0.12.0",
+ "@tweenjs/tween.js": "~23.1.3",
+ "@types/stats.js": "*",
+ "@types/webxr": ">=0.5.17",
+ "fflate": "~0.8.2",
+ "meshoptimizer": "~1.1.1"
+ }
+ },
+ "node_modules/@types/webxr": {
+ "version": "0.5.24",
+ "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
+ "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
+ "license": "MIT"
+ },
"node_modules/@vitejs/plugin-react": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz",
@@ -1164,20 +1519,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/cookie": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
- "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=18"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/express"
- }
- },
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -1242,6 +1583,20 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/enhanced-resolve": {
+ "version": "5.21.6",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz",
+ "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.4",
+ "tapable": "^2.3.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -1490,6 +1845,12 @@
}
}
},
+ "node_modules/fflate": {
+ "version": "0.8.3",
+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
+ "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
+ "license": "MIT"
+ },
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -1555,6 +1916,33 @@
"url": "https://github.com/sponsors/rawify"
}
},
+ "node_modules/framer-motion": {
+ "version": "12.40.0",
+ "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.40.0.tgz",
+ "integrity": "sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==",
+ "license": "MIT",
+ "dependencies": {
+ "motion-dom": "^12.40.0",
+ "motion-utils": "^12.39.0",
+ "tslib": "^2.4.0"
+ },
+ "peerDependencies": {
+ "@emotion/is-prop-valid": "*",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@emotion/is-prop-valid": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1606,6 +1994,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/gsap": {
+ "version": "3.15.0",
+ "resolved": "https://registry.npmjs.org/gsap/-/gsap-3.15.0.tgz",
+ "integrity": "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==",
+ "license": "Standard 'no charge' license: https://gsap.com/standard-license."
+ },
"node_modules/hermes-estree": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
@@ -1627,7 +2028,6 @@
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=20.0.0"
@@ -1683,6 +2083,16 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/jiti": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
+ "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jiti": "lib/jiti-cli.mjs"
+ }
+ },
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -1747,6 +2157,37 @@
"json-buffer": "3.0.1"
}
},
+ "node_modules/lenis": {
+ "version": "1.3.23",
+ "resolved": "https://registry.npmjs.org/lenis/-/lenis-1.3.23.tgz",
+ "integrity": "sha512-YxYq3TJqj9sJNv0V9SkyQHejt14xwyIwgDaaMK89Uf9SxQfIszu+gTQSSphh6BWlLTNVKvvXAGkg+Zf+oFIevg==",
+ "license": "MIT",
+ "workspaces": [
+ "packages/*",
+ "playground",
+ "playground/*"
+ ],
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/darkroomengineering"
+ },
+ "peerDependencies": {
+ "@nuxt/kit": ">=3.0.0",
+ "react": ">=17.0.0",
+ "vue": ">=3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@nuxt/kit": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "vue": {
+ "optional": true
+ }
+ }
+ },
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -2048,6 +2489,31 @@
"yallist": "^3.0.2"
}
},
+ "node_modules/lucide-react": {
+ "version": "1.21.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.21.0.tgz",
+ "integrity": "sha512-reEZMXq8Qdd5jg5XYkQ5TR1fB/GiQ7ih4vcrthYDtgjSDwh0i6/YLiGjsWsIwgN49gpAnd4J2elSNzncMEEUUQ==",
+ "license": "ISC",
+ "peerDependencies": {
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/meshoptimizer": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz",
+ "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==",
+ "license": "MIT"
+ },
"node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
@@ -2064,6 +2530,21 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "node_modules/motion-dom": {
+ "version": "12.40.0",
+ "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.40.0.tgz",
+ "integrity": "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==",
+ "license": "MIT",
+ "dependencies": {
+ "motion-utils": "^12.39.0"
+ }
+ },
+ "node_modules/motion-utils": {
+ "version": "12.39.0",
+ "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz",
+ "integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==",
+ "license": "MIT"
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -2279,43 +2760,37 @@
}
},
"node_modules/react-router": {
- "version": "7.18.0",
- "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.0.tgz",
- "integrity": "sha512-pTTGt8J+ji1NOmYnjzT+bAJy/1zD+Jp4ziO6cL7T3ZLvXKtusO7BpFqlRXitqpcPVqllsIXFHRMt+2/k3Xn6HQ==",
+ "version": "6.30.4",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz",
+ "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "cookie": "^1.0.1",
- "set-cookie-parser": "^2.6.0"
+ "@remix-run/router": "1.23.3"
},
"engines": {
- "node": ">=20.0.0"
+ "node": ">=14.0.0"
},
"peerDependencies": {
- "react": ">=18",
- "react-dom": ">=18"
- },
- "peerDependenciesMeta": {
- "react-dom": {
- "optional": true
- }
+ "react": ">=16.8"
}
},
"node_modules/react-router-dom": {
- "version": "7.18.0",
- "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.0.tgz",
- "integrity": "sha512-Fi0yY6kgtKae/Th2xibdWK0KSdYZ4B53Gyf6wRtomOKWgpNm7H7+DyfDhncdz9FKbpS+1jmDhg3F4WoGJ+yFOA==",
+ "version": "6.30.4",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz",
+ "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==",
"dev": true,
"license": "MIT",
"dependencies": {
- "react-router": "7.18.0"
+ "@remix-run/router": "1.23.3",
+ "react-router": "6.30.4"
},
"engines": {
- "node": ">=20.0.0"
+ "node": ">=14.0.0"
},
"peerDependencies": {
- "react": ">=18",
- "react-dom": ">=18"
+ "react": ">=16.8",
+ "react-dom": ">=16.8"
}
},
"node_modules/rolldown": {
@@ -2368,13 +2843,6 @@
"semver": "bin/semver.js"
}
},
- "node_modules/set-cookie-parser": {
- "version": "2.7.2",
- "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
- "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -2415,6 +2883,26 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/tapable": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
+ "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/three": {
+ "version": "0.184.0",
+ "resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz",
+ "integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==",
+ "license": "MIT"
+ },
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -2436,7 +2924,6 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
- "dev": true,
"license": "0BSD"
},
"node_modules/type-check": {
diff --git a/frontend/package.json b/frontend/package.json
index cff662a..95a1508 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,8 +1,20 @@
{
- "name": "frontend",
+ "name": "goldenhour-frontend",
"private": true,
- "version": "0.0.0",
+ "version": "0.1.0",
"type": "module",
+ "description": "GoldenHour PWA — patient emergency intake, live hospital results, donor registration, and the hospital confirmation page. React + Vite + Tailwind.",
+ "keywords": [
+ "goldenhour",
+ "emergency",
+ "healthcare",
+ "blood-donor",
+ "pwa",
+ "react",
+ "vite"
+ ],
+ "author": "Team GoldenHour",
+ "license": "MIT",
"scripts": {
"dev": "vite",
"build": "vite build",
@@ -10,12 +22,19 @@
"preview": "vite preview"
},
"dependencies": {
+ "@supabase/supabase-js": "^2.108.2",
+ "@types/three": "^0.184.1",
+ "framer-motion": "^12.40.0",
+ "gsap": "^3.15.0",
+ "lenis": "^1.3.23",
+ "lucide-react": "^1.21.0",
"react": "^19.2.6",
- "react-dom": "^19.2.6"
+ "react-dom": "^19.2.6",
+ "three": "^0.184.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
- "@supabase/supabase-js": "^2.108.2",
+ "@tailwindcss/vite": "^4.3.1",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
@@ -25,7 +44,7 @@
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"postcss": "^8.5.15",
- "react-router-dom": "^7.18.0",
+ "react-router-dom": "^6.30.4",
"tailwindcss": "^4.3.1",
"vite": "^8.0.12"
}
diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js
deleted file mode 100644
index 2e7af2b..0000000
--- a/frontend/postcss.config.js
+++ /dev/null
@@ -1,6 +0,0 @@
-export default {
- plugins: {
- tailwindcss: {},
- autoprefixer: {},
- },
-}
diff --git a/frontend/public/icon-192.png b/frontend/public/icon-192.png
new file mode 100644
index 0000000..3a7c55d
Binary files /dev/null and b/frontend/public/icon-192.png differ
diff --git a/frontend/public/icon-512.png b/frontend/public/icon-512.png
new file mode 100644
index 0000000..d61b655
Binary files /dev/null and b/frontend/public/icon-512.png differ
diff --git a/frontend/public/manifest.webmanifest b/frontend/public/manifest.webmanifest
new file mode 100644
index 0000000..ee7b6f7
--- /dev/null
+++ b/frontend/public/manifest.webmanifest
@@ -0,0 +1,26 @@
+{
+ "name": "GoldenHour — Emergency Hospital & Blood Response",
+ "short_name": "GoldenHour",
+ "description": "One GPS tap finds a hospital with the right department and a free bed, and alerts nearby compatible blood donors.",
+ "start_url": "/",
+ "scope": "/",
+ "display": "standalone",
+ "orientation": "portrait",
+ "background_color": "#FBFAF8",
+ "theme_color": "#DC2626",
+ "categories": ["health", "medical"],
+ "icons": [
+ {
+ "src": "/icon-192.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "any maskable"
+ },
+ {
+ "src": "/icon-512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "any maskable"
+ }
+ ]
+}
diff --git a/frontend/public/sw.js b/frontend/public/sw.js
new file mode 100644
index 0000000..21a4881
--- /dev/null
+++ b/frontend/public/sw.js
@@ -0,0 +1,35 @@
+// GoldenHour service worker — minimal offline app shell.
+// Bump CACHE when the shell changes to evict the old one on activate.
+const CACHE = 'goldenhour-v1';
+const SHELL = ['/', '/index.html', '/favicon.svg', '/icon-192.png', '/icon-512.png'];
+
+self.addEventListener('install', (event) => {
+ event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(SHELL)));
+ self.skipWaiting();
+});
+
+self.addEventListener('activate', (event) => {
+ event.waitUntil(
+ caches
+ .keys()
+ .then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
+ );
+ self.clients.claim();
+});
+
+self.addEventListener('fetch', (event) => {
+ const { request } = event;
+ // Never touch non-GET (API writes) or cross-origin requests (backend API, fonts).
+ if (request.method !== 'GET') return;
+ if (new URL(request.url).origin !== self.location.origin) return;
+
+ // Navigations: network-first so a fresh deploy is picked up; fall back to the
+ // cached shell when offline.
+ if (request.mode === 'navigate') {
+ event.respondWith(fetch(request).catch(() => caches.match('/index.html')));
+ return;
+ }
+
+ // Same-origin static assets (content-hashed by Vite): cache-first.
+ event.respondWith(caches.match(request).then((hit) => hit || fetch(request)));
+});
diff --git a/frontend/src/App.css b/frontend/src/App.css
index f90339d..0c8e5b8 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -1,184 +1,19 @@
-.counter {
- font-size: 16px;
- padding: 5px 10px;
- border-radius: 5px;
- color: var(--accent);
- background: var(--accent-bg);
- border: 2px solid transparent;
- transition: border-color 0.3s;
- margin-bottom: 24px;
-
- &:hover {
- border-color: var(--accent-border);
- }
- &:focus-visible {
- outline: 2px solid var(--accent);
- outline-offset: 2px;
- }
-}
-
-.hero {
- position: relative;
-
- .base,
- .framework,
- .vite {
- inset-inline: 0;
- margin: 0 auto;
- }
-
- .base {
- width: 170px;
- position: relative;
- z-index: 0;
- }
-
- .framework,
- .vite {
- position: absolute;
- }
-
- .framework {
- z-index: 1;
- top: 34px;
- height: 28px;
- transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
- scale(1.4);
+/* App-specific custom animations or styles */
+@keyframes pulse-ring {
+ 0% {
+ transform: scale(0.95);
+ box-shadow: 0 0 0 0 rgba(220, 38, 38, 0.4);
}
-
- .vite {
- z-index: 0;
- top: 107px;
- height: 26px;
- width: auto;
- transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
- scale(0.8);
- }
-}
-
-#center {
- display: flex;
- flex-direction: column;
- gap: 25px;
- place-content: center;
- place-items: center;
- flex-grow: 1;
-
- @media (max-width: 1024px) {
- padding: 32px 20px 24px;
- gap: 18px;
- }
-}
-
-#next-steps {
- display: flex;
- border-top: 1px solid var(--border);
- text-align: left;
-
- & > div {
- flex: 1 1 0;
- padding: 32px;
- @media (max-width: 1024px) {
- padding: 24px 20px;
- }
+ 70% {
+ transform: scale(1);
+ box-shadow: 0 0 0 10px rgba(220, 38, 38, 0);
}
-
- .icon {
- margin-bottom: 16px;
- width: 22px;
- height: 22px;
- }
-
- @media (max-width: 1024px) {
- flex-direction: column;
- text-align: center;
+ 100% {
+ transform: scale(0.95);
+ box-shadow: 0 0 0 0 rgba(220, 38, 38, 0);
}
}
-#docs {
- border-right: 1px solid var(--border);
-
- @media (max-width: 1024px) {
- border-right: none;
- border-bottom: 1px solid var(--border);
- }
-}
-
-#next-steps ul {
- list-style: none;
- padding: 0;
- display: flex;
- gap: 8px;
- margin: 32px 0 0;
-
- .logo {
- height: 18px;
- }
-
- a {
- color: var(--text-h);
- font-size: 16px;
- border-radius: 6px;
- background: var(--social-bg);
- display: flex;
- padding: 6px 12px;
- align-items: center;
- gap: 8px;
- text-decoration: none;
- transition: box-shadow 0.3s;
-
- &:hover {
- box-shadow: var(--shadow);
- }
- .button-icon {
- height: 18px;
- width: 18px;
- }
- }
-
- @media (max-width: 1024px) {
- margin-top: 20px;
- flex-wrap: wrap;
- justify-content: center;
-
- li {
- flex: 1 1 calc(50% - 8px);
- }
-
- a {
- width: 100%;
- justify-content: center;
- box-sizing: border-box;
- }
- }
-}
-
-#spacer {
- height: 88px;
- border-top: 1px solid var(--border);
- @media (max-width: 1024px) {
- height: 48px;
- }
-}
-
-.ticks {
- position: relative;
- width: 100%;
-
- &::before,
- &::after {
- content: '';
- position: absolute;
- top: -4.5px;
- border: 5px solid transparent;
- }
-
- &::before {
- left: 0;
- border-left-color: var(--border);
- }
- &::after {
- right: 0;
- border-right-color: var(--border);
- }
+.animate-emergency-pulse {
+ animation: pulse-ring 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
deleted file mode 100644
index 348dc6a..0000000
--- a/frontend/src/App.jsx
+++ /dev/null
@@ -1,28 +0,0 @@
-import React from 'react';
-import { BrowserRouter, Routes, Route } from 'react-router-dom';
-import PatientIntakeView from './components/PatientIntakeView';
-import PatientResultsView from './components/PatientResultsView';
-import HospitalConfirmPage from './components/HospitalConfirmPage';
-import DonorRegistration from './components/DonorRegistration';
-
-function App() {
- return (
-
-
+
+
+ {/* =============================================
+ SECTION 2: PROBLEM STATEMENT — Text reveals
+ ============================================= */}
+
+
+
+ In an emergency, every minute between injury and hospital care determines survival. The golden hour is not a metaphor — it is a countdown.
+
+
+
+ GoldenHour eliminates the chaos. One tap locks your GPS, dispatches the nearest hospital with a matching department, and broadcasts to every registered blood donor within range. Simultaneously.
+
+
+ Pin your GPS coordinates, select the emergency type and blood group needed, then hit dispatch. We route the request to the closest matching hospital and alert nearby donors instantly.
+
+
+
+
+ GPS Precision
+
+
+
+ Real-time Sync
+
+
+
+ Donor Alert
+
+
+
+
+ {/* Right: Intake Form Card */}
+
+
+ {/* Top accent */}
+
+
+ {/* Header */}
+
+
Emergency Dispatch
+
Secure your location and select emergency details.
+
+
+ {/* Location Button */}
+
+
+
+
+ {!coords && !locating && (
+
+
+
+ )}
+
+
+ {coords && (
+
+
Coordinates Secured
+
Lat: {coords.lat} · Lng: {coords.lng}
+
+
+ )}
+ {locationError && (
+
+
{locationError}
+
+
+ |
+
+
+
+ )}
+
+
+
+ {/* Selects */}
+
+
+
+
+
+
+ {/* =============================================
+ SECTION 4: HOW IT WORKS — Horizontal scroll
+ ============================================= */}
+
+
+
+
How It Works
+
Three steps. Zero delay.
+
+
+
+
+ {/* Step 1 */}
+
+
+
📍
+
Step 01
+
Lock Location
+
+ Your browser GPS pins your exact coordinates. High-accuracy mode ensures precision even in dense urban areas.
+
+
+
+
+
+ {/* Step 2 */}
+
+
+
🏥
+
Step 02
+
Smart Dispatch
+
+ Our algorithm matches your emergency type to the nearest hospital with the right department and available bed capacity.
+
+
+
+
+
+ {/* Step 3 */}
+
+
+
🩸
+
Step 03
+
Donor Alert
+
+ Every registered blood donor matching your blood type within range receives an instant alert with your location coordinates.
+