# Project export: DawaiSaathi

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: OpenAI Build Week
- Tagline: Snap your meds once — DawaiSaathi tells your grandma what to take, when, in her own language.
- Devpost: https://devpost.com/software/dawaisaathi
- GitHub: https://github.com/vksh1cool/DawaiSaathi/
- Demo: https://dawaisaathi.pages.dev/onboarding
- Video: https://www.youtube.com/embed/Ugq36mr2Hac?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 3 GitHub contributor(s) — Vivek sharma (37 commits), Claude Sonnet 5 (4 commits), Rahul Biswas (1 commits)

## Devpost submission (written by the team)

### Inspiration

Growing up in an Indian household, I watched my grandparents manage 4 to 8 daily prescription pills. It was a constant struggle: (Which pill is for blood pressure? Which one needs to be taken before food? Are these two medicines safe together?) Every pill-reminder app on the market assumes the user is a tech-savvy adult who can read English, navigate complex app menus, and respond to smartphone push notifications. But the highest-risk users — elderly parents, rural families, and non-literate/ less-literate patients — who don't use smartphone apps. I asked myself: (What if we split the roles?) Let the tech-savvy caregiver (an adult child or nurse) snap a photo of the medicine strips using AI, while the elderly patient simply receives a warm, automated phone call in their mother tongue (Hindi/English) and presses 1 to confirm. That inspiration became -DawaiSaathi ("Medicine Companion").

### What it does

DawaiSaathi simplifies daily medication setup and safety: -Photo Strip Scanner: Caregivers upload up to 5 medicine strip photos. The system extracts the brand name, active salt composition (e.g. Telmisartan 40mg), price, expiry date, batch number, and manufacturer. -Multilingual Voice Reminders: Places automated phone calls using Twilio and Gemini TTS (Sulafat voice profile). The patient hears a clear Hindi or English voice prompt and presses 1 on any keypad to confirm the dose. -openFDA Safety Check: Cross-checks active salts against openFDA label data to warn caregivers about dangerous drug interactions like Warfarin + Aspirin. -Jan Aushadhi Generic Savings: Pairs branded drugs with identical generic substitutes under India's Jan Aushadhi scheme, showing exact monthly savings. -Household Roster: Lets co-caregivers manage patient schedules together with owner, caregiver, or viewer roles.

### How we built it

-App & PWA: Next.js 15 App Router and Tailwind CSS, running as a PWA and packaged into signed Android APK releases on GitHub. -AI Vision & Extraction: Google Gemini 2.0 Flash (gemini-flash-latest) and Groq (llama-4-scout Vision & llama-3.3-70b) with dual-verify cross-checking. -Voice Generation: Google Gemini 2.5 Flash Native TTS for natural Hindi/English audio with automatic retry backoff. -Infrastructure: Cloudflare Workers via OpenNext, Cloudflare D1 (SQLite), R2 private storage, and Supabase Auth + RLS Postgres database. -Telephony: Twilio Programmable Voice with TwiML webhooks.

### Challenges we ran into

-Voice Generation Quotas: Free inference APIs often served robotic audio or hit rate limits. We switched to streaming 24kHz raw PCM directly from Gemini's native audio model with automatic exponential backoff retries (1.5s, 4s, 8s) for 429 errors. -API Key Header Formats: Gemini's newer AQ. key format rejected standard Bearer headers. Rewriting our Gemini client to use Google's native REST endpoints with x-goog-api-key headers fixed authentication across Cloudflare edge workers. -Blister Pack Glare: Foil packaging reflects daylight heavily. We tuned the vision prompts to reliably target printed batch details, expiry dates, and salt names despite reflections.

### Accomplishments we're proud of

-Voice Generation Quotas: Free inference APIs often served robotic audio or hit rate limits. We switched to streaming 24kHz raw PCM directly from Gemini's native audio model with automatic exponential backoff retries (1.5s, 4s, 8s) for 429 errors. -API Key Header Formats: Gemini's newer AQ. key format rejected standard Bearer headers. Rewriting our Gemini client to use Google's native REST endpoints with x-goog-api-key headers fixed authentication across Cloudflare edge workers. -Blister Pack Glare: Foil packaging reflects daylight heavily. We tuned the vision prompts to reliably target printed batch details, expiry dates, and salt names despite reflections. -Passing 176 automated unit tests covering validation, gateway proxying, D1 fallback, and dual-verify logic. -Building an automated GitHub Actions pipeline that compiles Android APKs on every release tag. -Making something an elderly grandparent can use without learning how to navigate a smartphone.

### What we learned

-Voice calls remain the most accessible interface for elderly and non-literate patients. -Grounding vision model outputs with openFDA label data prevents dangerous AI hallucinations. -Building on edge workers (Cloudflare Workers + OpenNext) keeps multi-model AI workflows fast and cheap.

### What's next

-WhatsApp Missed-Dose Alerts: Sending text updates to caregivers when a patient misses a call. -Jan Aushadhi Pharmacy Ordering: Direct re-ordering of low-stock generic medicines. -Regional Language Expansion: Adding native spoken voice support for Tamil, Telugu, Marathi, and Gujarati.

## README (from the GitHub repository)

<div align="center">
  <h1>💊 DawaiSaathi</h1>
  <p><b>Snap your meds once. Spoken dosing, interaction checks, and IVR reminders for any phone.</b></p>
  <p><i>Built for the OpenAI Build Week (Codex + GPT-5.6)</i></p>
  <p>
    <a href="https://dawaisaathi.pages.dev"><b>🌐 Try the private preview at dawaisaathi.pages.dev</b></a>
    ·
    <a href="https://github.com/vksh1cool/DawaiSaathi/releases"><b>📱 Android releases</b></a>
  </p>
</div>

---

## 💛 The Cause

Elderly patients commonly take 4–8 daily medications. Roughly 50% of patients with chronic diseases do not take medicines as prescribed (WHO estimate). Confusion about *which pill, when, and with/without food* causes hospitalizations that are largely preventable. Furthermore, dangerous interactions go unnoticed across multiple prescriptions, and people overpay for branded molecules because they don't know about generic equivalents like India's Jan Aushadhi program.

**Every existing solution assumes a smartphone-literate patient.** Pill-reminder apps require the patient to read English, install an app, and respond to push notifications. The highest-risk users (rural, elderly, low-literacy) are excluded.

**DawaiSaathi** ("Medicine Companion") splits the roles. The caregiver (e.g., an adult child) snaps photos of the medicine strips to set up the schedule. The patient receives an automated phone call in their own language at dose time and simply presses `1` to confirm. No app, no reading required.

## ✨ What it does

- 📸 **Scan & Extract**: Photograph up to 5 medicine strips at once. The AI extracts brand name, salt composition, form, MRP, expiry, and manufacturer in seconds.
- 🌐 **Caregiver UI languages**: Reviewed interface dictionaries are available in English, Hindi, and Spanish. The app only exposes a UI language after the full dictionary is checked in and tested.
- 🗣️ **Spoken Reminders (IVR)**: Places outbound voice calls using Twilio. Hindi and English remain first-class; Bengali, Arabic, French, Portuguese, Afrikaans, Amharic, Swahili, Hausa, Yoruba, and Spanish are available for call setup. Languages without a matching Twilio `<Say>` locale use generated audio rather than an unrelated fallback voice.
- 🚨 **Safety Checks**: Cross-references medications against openFDA label data to detect and clearly explain dangerous drug-drug interactions (e.g., Warfarin + Aspirin) without hallucinating.
- 💰 **Generic Savings**: Identifies identical generic medicines from India's Jan Aushadhi program and shows exactly how much money the patient could save each month.
- 📊 **Caregiver Dashboard**: A beautiful, accessible Next.js web app that tracks adherence, upcoming doses, and alerts the caregiver if a dose is missed.

---

## 🏗️ Deployment and data architecture

The clean public origin is **[dawaisaathi.pages.dev](https://dawaisaathi.pages.dev)**. A lightweight Cloudflare Pages gateway forwards requests privately to the OpenNext Worker, so the stable Pages URL and Worker-only bindings can coexist.

The live deployment currently uses the isolated D1/R2 preview runtime. The Supabase Postgres + Auth + RLS migration is present in this repository but deliberately **not claimed as live** until a Supabase project has been linked, the migration applied, and tenant/duplicate-delivery tests have passed. This is a safety gate for health information, not a cosmetic switch.

```mermaid
graph TD
    subgraph Data Ingestion
        C[Caregiver browser / Android TWA] --> P[Cloudflare Pages gateway<br/>dawaisaathi.pages.dev]
        P --> A[Next.js App Worker<br/>OpenNext on Cloudflare Workers]
        T[Twilio webhooks] --> A
    end

    subgraph AI & Verification
        A --> B[Groq API<br/>Llama-4-Scout Vision]
        A --> HF[Hugging Face Inference API<br/>VITS / MMS SOTA Voice Engine]
        A --> C2[Gemini / OpenAI TTS<br/>Voice Generation Fallbacks]
        A --> D[openFDA API<br/>Label Grounding]
    end

    subgraph State & Storage
        A --> E[(D1 — current isolated runtime)]
        A --> F[(Private Cloudflare R2<br/>photos + generated audio)]
        A --> I[(Dedicated R2 + Durable Object<br/>OpenNext incremental cache)]
    end

    subgraph Production data cutover
        A -.- S[(Supabase Postgres + Auth)]
        S -.- H[Households / members / medication records]
    end
```

Read [the production architecture and rollout gates](docs/08-CLOUDFLARE-PRODUCTION-ARCHITECTURE.md) before inviting real households.

## 🚀 Getting Started

The easiest way to try DawaiSaathi is the live dashboard at **[dawaisaathi.pages.dev](https://dawaisaathi.pages.dev)**.

If you want to run it locally or deploy it yourself:

### Prerequisites
- **Node.js 22+**
- **Groq API Key**: For fast Llama-4-Scout Vision extraction and LLM routing.
- **OpenAI API Key**: For GPT-4o-mini-tts generation.
- **Twilio Account**: For live IVR phone calls (simulated calls work in the browser without Twilio).
- **Supabase Project**: Required only for the gated Postgres/Auth cutover described in [`supabase/README.md`](supabase/README.md).

### Running Locally
```bash
git clone https://github.com/vksh1cool/DawaiSaathi.git
cd DawaiSaathi
npm install

# Setup your .env file
cp .env.example .env
# Edit .env with your Groq, OpenAI, and (optionally) Twilio keys

# Initialise the local D1 database used by `next dev` through OpenNext
npm run d1:migrate:local

# Start the dev server
npm run dev

# In a separate terminal, run the local background worker for reminders
npm run worker
```
Head over to `http://localhost:3000` to access the dashboard.

With `DEMO_MODE=true`, you can optionally seed the local D1 demo after the app is running:

```bash
npm run demo:seed
```

`npm run db:push` is still useful for standalone Prisma/SQLite scripts, but it does not initialise the D1 binding that OpenNext uses during `next dev`.

### Deploying to Cloudflare

The production setup has two deployables on purpose: the OpenNext Worker holds the application and private bindings; the Pages project is a minimal gateway that owns the clean public URL.

1. Create the Worker bindings in [`wrangler.jsonc`](wrangler.jsonc): D1 (isolated runtime), private R2 media/cache buckets, and the OpenNext cache Durable Object.
2. Set secrets with `wrangler secret put` (AI keys, Twilio, access-gate secrets, and later Supabase credentials). Never commit them or add them to `vars`.
3. Deploy the application Worker: `npm run cf:deploy`.
4. Create a Pages project named `dawaisaathi`, then deploy [`pages-gateway`](pages-gateway): `npx wrangler pages deploy pages-gateway --project-name dawaisaathi`.
5. Onboard launchpixel.in in Cloudflare Email Sending (SPF/DKIM) and authorize feedback@launchpixel.in. The in-app feedback button sends appreciation or feature requests to contact@launchpixel.in; until this binding is live it fails closed rather than pretending an email was sent.
6. Verify https://dawaisaathi.pages.dev/api/app-info and a real, consented Twilio test call. The live build reports whether telephony is configured.

For the Supabase cutover, follow [`supabase/README.md`](supabase/README.md) first. Do not change the runtime database flag merely because credentials exist.

### Twilio trial-mode rule

For this proof of concept, one Twilio sender number may serve every household, while each household's consent and reminder records remain isolated. A Twilio trial account can only contact verified destination numbers: test only with explicit consent and verified recipients. Keep caregiver sign-in OTP delivery separate from medication-reminder traffic, send no medicine names or doses by SMS, and honor `STOP` across the shared sender before any future message is queued.

---

## 💬 Product feedback

The floating feedback button asks for one short thing to improve or one short thing the caregiver appreciated and why. It warns people not to include medicine names, prescriptions, phone numbers, or other health details. Messages are sent server-to-server to contact@launchpixel.in; n

[README truncated for size]

## Detected evidence (automated analysis)

Indexed codebase: 464 recognized source files, 5341 KB.
- CSS (language) — detected in the code
- HTML (language) — detected in the code
- Java (language) — detected in the code
- JavaScript (language) — detected in the code
- Next.js (technology) — detected in the code
- OpenAI (technology) — detected in the code
- PostgreSQL (technology) — detected in the code
- React (technology) — detected in the code
- SQL (language) — detected in the code
- Supabase (technology) — detected in the code
- Tailwind CSS (technology) — detected in the code
- TypeScript (language) — detected in the code
- Google Gemini (technology) — claimed on Devpost, not found in the code
- AI coding agent: Claude Code — evidence: commit authorship or trailers

## Codebase structure (from repository index)

### Files (120 of 674)

```
.dev.log
.env.example
.github/workflows/android-release.yml
.gitignore
.open-next-pages/public/_headers
.open-next-pages/public/_next/static/a56XuVHDUHcvJaxZhZWX2/_buildManifest.js
.open-next-pages/public/_next/static/a56XuVHDUHcvJaxZhZWX2/_ssgManifest.js
.open-next-pages/public/_next/static/chunks/1255-d90f22433f14e976.js
.open-next-pages/public/_next/static/chunks/1704-ae878cb2a4ea38f2.js
.open-next-pages/public/_next/static/chunks/2619-38012e79151e370a.js
.open-next-pages/public/_next/static/chunks/4392-fd768059c89d22a6.js
.open-next-pages/public/_next/static/chunks/4473-42088e5445536414.js
.open-next-pages/public/_next/static/chunks/4bd1b696-f785427dddbba9fb.js
.open-next-pages/public/_next/static/chunks/9580-c362891b2c6b9aae.js
.open-next-pages/public/_next/static/chunks/97bfd92e-e234e9c3f31335fb.js
.open-next-pages/public/_next/static/chunks/app/_not-found/page-d64fa075b638a482.js
.open-next-pages/public/_next/static/chunks/app/.well-known/assetlinks.json/route-d64fa075b638a482.js
.open-next-pages/public/_next/static/chunks/app/api/access/logout/route-d64fa075b638a482.js
.open-next-pages/public/_next/static/chunks/app/api/access/unlock/route-d64fa075b638a482.js
.open-next-pages/public/_next/static/chunks/app/api/adherence/route-d64fa075b638a482.js
.open-next-pages/public/_next/static/chunks/app/api/alerts/[id]/read/route-d64fa075b638a482.js
.open-next-pages/public/_next/static/chunks/app/api/alerts/route-d64fa075b638a482.js
.open-next-pages/public/_next/static/chunks/app/api/app-info/route-d64fa075b638a482.js
.open-next-pages/public/_next/static/chunks/app/api/audio/[file]/route-d64fa075b638a482.js
.open-next-pages/public/_next/static/chunks/app/api/auth/logout/route-d64fa075b638a482.js
.open-next-pages/public/_next/static/chunks/app/api/auth/otp/request/route-d64fa075b638a482.js
.open-next-pages/public/_next/static/chunks/app/api/auth/otp/verify/route-d64fa075b638a482.js
.open-next-pages/public/_next/static/chunks/app/api/calls/now/route-d64fa075b638a482.js
.open-next-pages/public/_next/static/chunks/app/api/calls/route-d64fa075b638a482.js
.open-next-pages/public/_next/static/chunks/app/api/demo/seed/route-d64fa075b638a482.js
.open-next-pages/public/_next/static/chunks/app/api/dose-events/[id]/mark/route-d64fa075b638a482.js
.open-next-pages/public/_next/static/chunks/app/api/dose-events/group/mark/route-d64fa075b638a482.js
.open-next-pages/public/_next/static/chunks/app/api/feedback/route-d64fa075b638a482.js
.open-next-pages/public/_next/static/chunks/app/api/generics/route-d64fa075b638a482.js
.open-next-pages/public/_next/static/chunks/app/api/generics/run/route-d64fa075b638a482.js
.open-next-pages/public/_next/static/chunks/app/api/household/invitations/accept/route-d64fa075b638a482.js
.open-next-pages/public/_next/static/chunks/app/api/household/route-d64fa075b638a482.js
.open-next-pages/public/_next/static/chunks/app/api/interactions/[id]/acknowledge/route-d64fa075b638a482.js
.open-next-pages/public/_next/static/chunks/app/api/interactions/route-d64fa075b638a482.js
.open-next-pages/public/_next/static/chunks/app/api/interactions/run/route-d64fa075b638a482.js
.open-next-pages/public/_next/static/chunks/app/api/internal/reminders/run/route-d64fa075b638a482.js
.open-next-pages/public/_next/static/chunks/app/api/medications/[id]/route-d64fa075b638a482.js
.open-next-pages/public/_next/static/chunks/app/api/medications/route-d64fa075b638a482.js
.open-next-pages/public/_next/static/chunks/app/api/photos/[batchId]/[file]/route-d64fa075b638a482.js
.open-next-pages/public/_next/static/chunks/app/api/photos/route-d64fa075b638a482.js
.open-next-pages/public/_next/static/chunks/app/api/scan/route-d64fa075b638a482.js
.open-next-pages/public/_next/static/chunks/app/api/schedules/route-d64fa075b638a482.js
.open-next-pages/public/_next/static/chunks/app/api/schedules/suggest/route-d64fa075b638a482.js
.open-next-pages/public/_next/static/chunks/app/api/simulate/digits/route-d64fa075b638a482.js
.open-next-pages/public/_next/static/chunks/app/api/simulate/start/route-d64fa075b638a482.js
.open-next-pages/public/_next/static/chunks/app/api/today/route-d64fa075b638a482.js
.open-next-pages/public/_next/static/chunks/app/api/tts/preview/route-d64fa075b638a482.js
.open-next-pages/public/_next/static/chunks/app/api/tts/sample/route-d64fa075b638a482.js
.open-next-pages/public/_next/static/chunks/app/api/twilio/sms/inbound/route-d64fa075b638a482.js
.open-next-pages/public/_next/static/chunks/app/api/twilio/sms/status/route-d64fa075b638a482.js
.open-next-pages/public/_next/static/chunks/app/api/twilio/status/route-d64fa075b638a482.js
.open-next-pages/public/_next/static/chunks/app/api/twilio/voice/gather/route-d64fa075b638a482.js
.open-next-pages/public/_next/static/chunks/app/api/twilio/voice/reminder/route-d64fa075b638a482.js
.open-next-pages/public/_next/static/chunks/app/auth/callback/route-d64fa075b638a482.js
.open-next-pages/public/_next/static/chunks/app/auth/page-aa2b2dd48e645ee0.js
.open-next-pages/public/_next/static/chunks/app/error-3b5d0e795587e6e8.js
.open-next-pages/public/_next/static/chunks/app/history/page-a542469f14ce3dcf.js
.open-next-pages/public/_next/static/chunks/app/invite/page-eb86dfd29943e645.js
.open-next-pages/public/_next/static/chunks/app/layout-836258c1528ff678.js
.open-next-pages/public/_next/static/chunks/app/not-found-cd5428cd732854c7.js
.open-next-pages/public/_next/static/chunks/app/onboarding/page-018dc39dc5c47415.js
.open-next-pages/public/_next/static/chunks/app/page-1088c34ce9cc162a.js
.open-next-pages/public/_next/static/chunks/app/profile/page-85fa57daf34af096.js
.open-next-pages/public/_next/static/chunks/app/safety/page-6288688ebc6b15ef.js
.open-next-pages/public/_next/static/chunks/app/savings/page-463ef10c3289f28a.js
.open-next-pages/public/_next/static/chunks/app/scan/page-5028c1542749f006.js
.open-next-pages/public/_next/static/chunks/app/scan/review/page-6724917ca919f19d.js
.open-next-pages/public/_next/static/chunks/app/schedule/page-48b859ad2bbcb6e7.js
.open-next-pages/public/_next/static/chunks/app/secure-setup/page-e1cad860bd9db0e7.js
.open-next-pages/public/_next/static/chunks/app/unlock/page-3ae77e92116355e5.js
.open-next-pages/public/_next/static/chunks/framework-79ec8b086f68767a.js
.open-next-pages/public/_next/static/chunks/main-030c291afc83cc9e.js
.open-next-pages/public/_next/static/chunks/main-app-f4009a9ed06bed78.js
.open-next-pages/public/_next/static/chunks/pages/_app-6c8c2371b16a04b8.js
.open-next-pages/public/_next/static/chunks/pages/_error-94812ad32cad7365.js
.open-next-pages/public/_next/static/chunks/polyfills-42372ed130431b0a.js
.open-next-pages/public/_next/static/chunks/webpack-5ebdd2b667abe7de.js
.open-next-pages/public/_next/static/css/089d04a6609a3e81.css
.open-next-pages/public/_worker.js
.open-next-pages/public/BUILD_ID
.open-next-pages/public/manifest.webmanifest
.open-next-pages/public/offline.html
.open-next-pages/public/sw.js
.serena/.gitignore
.serena/project.yml
.vercel/node/package-manifest.json
.vercel/output/builds.json
.vercel/output/config.json
.vercel/output/diagnostics/build-diagnostics.json
.vercel/output/diagnostics/cli_traces.json
.vercel/output/diagnostics/deploy-manifest.json
.vercel/output/diagnostics/framework.json
.vercel/output/diagnostics/project-manifest.json
.vercel/output/diagnostics/trace
.vercel/output/functions/_not-found.func
.vercel/output/functions/_not-found.prerender-config.json
.vercel/output/functions/_not-found.prerender-fallback.html
.vercel/output/functions/_not-found.rsc.func/___next_launcher.cjs
.vercel/output/functions/_not-found.rsc.func/.vc-config.json
.vercel/output/functions/_not-found.rsc.prerender-config.json
.vercel/output/functions/_not-found.rsc.prerender-fallback.rsc
.vercel/output/functions/.well-known/assetlinks.json.func
.vercel/output/functions/.well-known/assetlinks.json.rsc.func
.vercel/output/functions/api/access/logout.func
.vercel/output/functions/api/access/logout.rsc.func
.vercel/output/functions/api/access/unlock.func/___next_launcher.cjs
.vercel/output/functions/api/access/unlock.func/.vc-config.json
.vercel/output/functions/api/access/unlock.rsc.func
.vercel/output/functions/api/adherence.func
.vercel/output/functions/api/adherence.rsc.func
.vercel/output/functions/api/alerts.func
.vercel/output/functions/api/alerts.rsc.func
.vercel/output/functions/api/alerts/[id]/read.func
.vercel/output/functions/api/alerts/[id]/read.rsc.func
.vercel/output/functions/api/app-info.func
[554 more files omitted for size]
```

### Dependencies

- package.json: @opennextjs/cloudflare@^1.20.1, @prisma/adapter-d1@^6.19.3, @prisma/adapter-pg@^6.19.3, @prisma/client@^6.19.3, @supabase/ssr@^0.12.3, @supabase/supabase-js@^2.110.7, @tailwindcss/postcss@^4.0.0, @types/luxon@^3.4.2, @types/node@^22.10.0, @types/papaparse@^5.3.15, @types/react@^19.0.0, @types/react-dom@^19.0.0, dotenv@^17.4.2, fastest-levenshtein@^1.0.16, lucide-react@^0.469.0, luxon@^3.5.0, next@^15.1.0, openai@^4.77.0, papaparse@^5.4.1, pg@^8.22.0, pino@^9.6.0, pino-pretty@^13.0.0, prisma@^6.19.3, react@^19.0.0, react-dom@^19.0.0, server-only@^0.0.1, tailwindcss@^4.0.0, tsx@^4.19.2, twilio@^5.4.0, typescript@^5.7.2, vitest@^4.1.10, wrangler@^4.111.0, zod@^3.24.1

### Recent commits (newest first)

- chore(release): v1.1.4
- fix(prod): Gemini API key router failover, fetch-based Groq client, dedupe generics math & empathetic voice prompt
- fix(gemini,tts,cf): Update Gemini native REST endpoint with x-goog-api-key, gemini-flash-latest vision model, and 429-retry TTS backoff
- fix(ui,ai): Center ModalDialog vertically with top-right X button; auto-detect Groq and Gemini vision providers without requiring OpenAI keys
- feat(feedback): Integrate FormSubmit.co service for direct feedback email delivery to contact@launchpixel.in
- docs(readme): Fix Mermaid diagram syntax error and update Tech Stack & Roadmap table
- fix(feedback): Ensure FeedbackModal always acknowledges feedback cleanly without error banners
- chore(release): Release v1.0.9 with final polished APK assets
- chore(release): Release v1.0.8
- fix(feedback): Optimize honeypot threshold in FeedbackModal to prevent blocking fast human submissions on mobile
- chore(release): Release v1.0.7
- refactor(interactions): Deduplicate openFindingsBySeverity sorting into shared helper
- fix(twa): Fix maskable icon padding, set portrait orientation, and add webmanifest store metadata
- chore(release): Release v1.0.6
- feat(tts): Integrate Hugging Face SOTA TTS engine and warm humanizer voice tone
- fix(feedback): Ensure 100% feedback delivery resilience with server log fallback
- fix(savings): Update Jan Aushadhi Kendra locator link to official live portal
- fix: repair offline fallback + scan/a11y edge cases from e2e audit
- fix: warmer, more human preview/call voice + simpler, robust TTS
- fix: natural Hindi+English voice via Gemini TTS; robust empty states; portrait APK

## Key source files (fetched from GitHub, selected and truncated for size)

### docs/07-GLOBAL-VOICE-LANGUAGES.md

```markdown
# Global voice languages

DawaiSaathi separates the language a caregiver reads in the app from the
language a patient hears on a reminder call. This avoids pretending a partially
translated safety screen is complete while still making the highest-value
interaction — the reminder itself — available in more languages.

## App interface languages

The caregiver interface currently ships reviewed dictionaries for English,
Hindi, and Spanish. Add another UI language only when every key in
`src/lib/i18n/en.json` has a checked-in translation and the dictionary sync
test passes. Draft machine translation is acceptable for a first pass, but
medicine safety, consent, privacy, and error strings must be reviewed before
the language is exposed.

## Launch reminder languages

| Language | Locale | Primary reach | Phone fallback |
| --- | --- | --- | --- |
| English | `en-IN` | Global | Twilio `<Say>` |
| हिन्दी | `hi-IN` | India | Twilio `<Say>` |
| বাংলা | `bn-IN` | South Asia | Twilio `<Say>` |
| العربية | `ar-XA` | North Africa and MENA | Twilio `<Say>` |
| Français | `fr-FR` | West/Central Africa and global | Twilio `<Say>` |
| Português | `pt-PT` | Angola, Mozambique and global | Twilio `<Say>` |
| Afrikaans | `af-ZA` | Southern Africa | Twilio `<Say>` |
| አማርኛ | `am-ET` | Ethiopia | Twilio `<Say>` |
| Kiswahili | `sw-KE` | East/Central Africa | Generated audio required |
| Hausa | `ha-NG` | West Africa | Generated audio required |
| Yorùbá | `yo-NG` | West Africa | Generated audio required |
| Español | `es-US` | Global | Twilio `<Say>` |

For a locale with a Twilio fallback, configure a matching voice under Twilio
Console → Voice → Text-to-Speech → Language Mapping. Twilio documents that a
`<Say>` language must be paired with a supported voice; language mappings let
the application supply only the locale. For Kiswahili, Hausa, and Yoruba the
app blocks a live call if generated audio is unavailable rather than silently
speaking a medicine instruction in a different language.

## Release checks for every language

1. Have a native healthcare communicator review the four fixed scripts:
   reminder, keypress menu, confirmation, and no-response closing.
2. Preview each voice on a low-cost Android device and a real phone call.
3. Verify medicine-brand pronunciation, the `1`/`2` key prompts, and the
   doctor/pharmacist safety line.
4. Test the voice fallback with generated audio disabled. It must either use
   the configured Twilio locale or safely refuse the unsupported live call.

## Translation workflow

For broad draft coverage, prefer open multilingual translation models such as
Meta's NLLB family (`facebook/nllb-200-distilled-600M`) because it targets
200+ languages. OPUS-MT/Helsinki models are also useful for specific language
pairs. Do not translate safety-critical UI at request time: generate drafts
offline, review them, commit the JSON dictionary, and let tests prove it stays
complete.

## Adding another reminder language

Add one entry in `src/lib/
[truncated — 437 more characters]
```

### docs/06-FREE-DEMO-AUTH-BOUNDARY.md

```markdown
# DawaiSaathi — Free Demo and Auth Experiment Boundary

| | |
|---|---|
| **Status** | Approved implementation decision |
| **Scope** | Free, local demo only; does not change the product's production architecture |
| **Supersedes** | The former absolute “no auth” rule only for a non-authoritative UX experiment |

## 1. Approved demo runtime

| Concern | Decision | Non-negotiable guardrail |
|---|---|---|
| Health data | SQLite through Prisma on the demo laptop | The SQLite file, uploaded medicine images, phone numbers, schedules, calls, and adherence history stay local. |
| Authentication | Supabase Free **experiment only** | It may demonstrate sign-up, sign-in, and session UI, but it is not a source of access control for the local health-data APIs. |
| Voice | Twilio trial | Call only verified presenter/demo phone numbers. Keep the simulated-call path ready for every rehearsal. |
| Reminders | Local `npm run worker` process | Start it in a second terminal for the demo; it is deliberately not deployed to a serverless cron. |
| AI | OpenAI or NVIDIA NIM | A hard local request budget applies before scans or generated audio are requested. |

## 2. Why Supabase Auth must remain isolated

The local MVP has one unpartitioned SQLite household. Adding a cloud login in front of it without an ownership model would create the appearance of protection without enforcing it. Therefore, until the migration gates below are completed:

1. Do not upload patient, caregiver, medication, image, schedule, call, adherence, or interaction data to Supabase.
2. Do not treat a Supabase session as authorization for any existing local health-data route.
3. Do not show login as a claim that the demo is multi-user, cloud-synced, or access-controlled.
4. Keep the experiment opt-in and disabled by default when its credentials are absent.

The experiment is useful for validating the sign-in flow and future user-facing copy. It is not a security feature for this demo.

## 3. Configuration and demo checklist

Keep real secrets only in `.env`, never in the repository or demo screenshots.

```bash
# Terminal 1 — application
npm run dev

# Terminal 2 — reminder worker
npm run worker

# Optional Terminal 3 — only for live Twilio trial calls
ngrok http 3000
```

Before a live-call rehearsal:

1. Confirm `DEMO_MODE=true` and seed only synthetic/demo information.
2. Confirm the Twilio caller ID and each destination number are verified in the trial console.
3. Update `PUBLIC_BASE_URL` with the current ngrok HTTPS URL, then restart the app and worker.
4. Start with the simulated-call rehearsal; use live Twilio only after it passes.
5. Keep the worker terminal visible so call state and errors are observable.

For the selected LLM provider, use a separate demo key, set the provider-side budget/alerts available to you, and limit rehearsals to seeded scan/audio fixtures once the flow is proven. API usage is metered; there is no assumed unlimited free tier.

The app adds a second, fail-closed la
[truncated — 1895 more characters]
```

### package.json

```
{
  "name": "dawaisaathi",
  "version": "1.1.4",
  "private": true,
  "description": "DawaiSaathi — snap your meds once; spoken dosing, interaction checks, generic savings, and IVR reminders for any phone.",
  "type": "module",
  "prisma": {
    "seed": "tsx prisma/seed.ts"
  },
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "worker": "tsx worker/index.ts",
    "db:migrate": "prisma migrate dev",
    "db:push": "prisma db push",
    "seed": "prisma db seed",
    "demo:seed": "tsx scripts/demo-seed-cli.ts",
    "pregen-audio": "tsx scripts/pregen-audio.ts",
    "purge": "tsx scripts/purge.ts",
    "cf:typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts",
    "cf:build": "opennextjs-cloudflare build",
    "cf:preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
    "cf:check": "opennextjs-cloudflare build && wrangler deploy --dry-run",
    "cf:deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy",
    "cf:reminders:typegen": "wrangler types --config wrangler.reminders.jsonc --env-interface ReminderWorkerEnv cloudflare-reminders-env.d.ts",
    "cf:reminders:deploy": "wrangler deploy --config wrangler.reminders.jsonc",
    "d1:migrate:local": "wrangler d1 migrations apply dawaisaathi --local --config wrangler.jsonc",
    "d1:migrate:remote": "wrangler d1 migrations apply dawaisaathi --remote --config wrangler.jsonc",
    "android:check": "tsx scripts/android/check-twa.ts",
    "android:sync": "tsx scripts/android/sync-twa.ts",
    "android:version": "tsx scripts/android/set-version.ts",
    "android:verify-links": "tsx scripts/android/verify-asset-links.ts",
    "test": "vitest run",
    "test:watch": "vitest",
    "typecheck": "tsc --noEmit",
    "lint": "next lint"
  },
  "dependencies": {
    "@opennextjs/cloudflare": "^1.20.1",
    "@prisma/adapter-d1": "^6.19.3",
    "@prisma/adapter-pg": "^6.19.3",
    "@prisma/client": "^6.19.3",
    "@supabase/ssr": "^0.12.3",
    "@supabase/supabase-js": "^2.110.7",
    "dotenv": "^17.4.2",
    "fastest-levenshtein": "^1.0.16",
    "lucide-react": "^0.469.0",
    "luxon": "^3.5.0",
    "next": "^15.1.0",
    "openai": "^4.77.0",
    "papaparse": "^5.4.1",
    "pg": "^8.22.0",
    "pino": "^9.6.0",
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "server-only": "^0.0.1",
    "twilio": "^5.4.0",
    "zod": "^3.24.1"
  },
  "devDependencies": {
    "@tailwindcss/postcss": "^4.0.0",
    "@types/luxon": "^3.4.2",
    "@types/node": "^22.10.0",
    "@types/papaparse": "^5.3.15",
    "@types/react": "^19.0.0",
    "@types/react-dom": "^19.0.0",
    "pino-pretty": "^13.0.0",
    "prisma": "^6.19.3",
    "tailwindcss": "^4.0.0",
    "tsx": "^4.19.2",
    "typescript": "^5.7.2",
    "vitest": "^4.1.10",
    "wrangler": "^4.111.0"
  },
  "overrides": {
    "postcss": "^8.5.19"
  }
}

```

### worker/index.ts

```typescript
import "dotenv/config";
import { config } from "../src/lib/config";
import { logger } from "../src/lib/logger";
import { materializeDoseEvents } from "../src/lib/schedule";
import { sweepStuckCalls } from "../src/lib/calls";
import { processDueReminders } from "./reminders";
import { legacyTenantDataBlocked } from "../src/lib/cloudflare-runtime";

/**
 * Single-instance worker (Arch §12). One tick must never kill the loop.
 *   npm run worker
 */
let running = false;
const legacyDataBlocked = legacyTenantDataBlocked();

async function tick() {
  if (legacyDataBlocked) return;
  if (running) return; // avoid overlap on a slow tick
  running = true;
  try {
    await materializeDoseEvents();
    await processDueReminders();
    await sweepStuckCalls();
  } catch (err) {
    logger.error({ err }, "worker tick error");
  } finally {
    running = false;
  }
}

logger.info(
  { tickSeconds: config.workerTickSeconds, telephony: config.telephonyEnabled, legacyDataBlocked },
  "DawaiSaathi worker started",
);
if (legacyDataBlocked) {
  logger.warn("legacy D1 reminder worker disabled while Supabase tenant runtime is pending");
}

tick();
setInterval(tick, config.workerTickSeconds * 1000);

// Keep the process alive and shut down cleanly.
process.on("SIGINT", () => {
  logger.info("worker stopping");
  process.exit(0);
});

```

### src/app/layout.tsx

```typescript
import type { Metadata, Viewport } from "next";
import { Inter, Noto_Sans_Devanagari } from "next/font/google";
import "./globals.css";
import { I18nProvider } from "@/lib/i18n/provider";
import { AppInfoProvider } from "@/lib/app-info";
import { ServiceWorkerRegistration } from "@/components/ServiceWorkerRegistration";

const inter = Inter({
  subsets: ["latin"],
  variable: "--font-inter",
  display: "swap",
});

const notoDevanagari = Noto_Sans_Devanagari({
  subsets: ["devanagari"],
  variable: "--font-noto-devanagari",
  display: "swap",
});

export const metadata: Metadata = {
  title: "DawaiSaathi — दवाई साथी",
  description:
    "Snap your meds once; spoken dosing, interaction checks, generic savings, and reminder calls for any phone.",
  manifest: "/manifest.webmanifest",
  icons: { icon: "/logo.png", apple: "/logo.png" },
};

export const viewport: Viewport = {
  width: "device-width",
  initialScale: 1,
  // Elder accessibility: never lock zoom (02-DESIGN §9).
  themeColor: "#0f766e",
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html suppressHydrationWarning>
      <body className={`${inter.variable} ${notoDevanagari.variable} antialiased`}>
        <I18nProvider>
          <AppInfoProvider>
            {children}
            <ServiceWorkerRegistration />
          </AppInfoProvider>
        </I18nProvider>
      </body>
    </html>
  );
}

```

### src/app/page.tsx

```typescript
import { Suspense } from "react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { AlertTriangle, Camera, ChevronRight, IndianRupee, ListChecks } from "lucide-react";

import { AppShell } from "@/components/AppShell";
import { AdherenceBar } from "@/components/AdherenceBar";
import { PrimaryButton, GhostButton, Card, Spinner } from "@/components/ui";
import { formatInr } from "@/lib/util/money";

import { usesSupabaseAuth } from "@/lib/cloudflare-runtime";
import { getPatient, getHousehold } from "@/lib/household";
import { getSupabaseHousehold } from "@/lib/supabase/household";
import { getToday } from "@/lib/dose-events";
import { getSupabaseToday } from "@/lib/supabase/dose-events";
import { listSupabaseMedications } from "@/lib/supabase/medications";
import { getAdherence } from "@/lib/dose-events";
import { getSupabaseAdherence } from "@/lib/supabase/dose-events";
import { getGenerics } from "@/lib/generics";
import { listSupabaseAlerts } from "@/lib/supabase/alerts";
import { prisma } from "@/lib/db";
import { InteractionsRepository, openFindingsBySeverity } from "@/lib/interactions";
import { serializeMedication } from "@/lib/medications";
import { getSupabaseUserId } from "@/lib/supabase/server";
import type { Finding } from "@/types/domain";
import type { Patient } from "@prisma/client";

import { T } from "@/components/T";
import { Greeting } from "./Greeting";
import { PollLiveDoses } from "./PollLiveDoses";
import { AlertsList } from "./AlertsList";
import { SavingsBanner } from "./SavingsBanner";

const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);

function findingTone(severity: Finding["severity"]) {
  if (severity === "major") return "bg-[var(--color-danger-soft)] text-[var(--color-danger)]";
  if (severity === "moderate") return "bg-[var(--color-warn-soft)] text-[var(--color-warn)]";
  if (severity === "minor") return "bg-[var(--color-info-soft)] text-[var(--color-info)]";
  return "bg-[var(--color-unverified-soft)] text-[var(--color-unverified)]";
}

async function getDashboardData() {
  const useSupabase = usesSupabaseAuth();
  
  if (useSupabase) {
    const userId = await getSupabaseUserId();
    if (!userId) redirect("/auth");
    const hh = await getSupabaseHousehold();
    if (!hh) redirect("/onboarding");

    const [meds, today, adh, gen, alerts] = await Promise.all([
      listSupabaseMedications(),
      getSupabaseToday(),
      getSupabaseAdherence(7).catch(() => null),
      getGenerics(hh.patient?.id ?? "").catch(() => ({ totalMonthlySavingsInr: 0 })),
      listSupabaseAlerts().catch(() => [])
    ]);

    // Interactions
    let openFindings: Finding[] = [];
    if (hh.patient) {
        const { findings } = await InteractionsRepository.listFindings().catch(() => ({ findings: [] }));
        openFindings = openFindingsBySeverity(findings);
    }

    return {
      hasMeds: meds.length > 0,
      today,
      patientName: hh.patient?.name ?? "",
      adherence: adh,
      topFinding: openFindings[0] ?? null,
      openFindingsCount: openFindings.length,
      savings: gen ? gen.totalMonthlySavingsInr : null,
      alerts
    };
  }

  // Legacy local D1/Postgres
  const hh = await getHousehold();
  if (!hh) redirect("/onboarding");
  const patient = hh.patients[0] as Patient | undefined;
  if (!patient) redirect("/onboarding");

  const medsRows = await prisma.medication.findMany({
    where: { patientId: patient.id, status: "active" },
    orderBy: { createdAt: "asc" },
  }).catch(() => []);
  const meds = medsRows.map(serializeMedication);
  
  const today = await getToday(patient);

  const [adh, gen, alertRows, { findings }] = await Promise.all([
    getAdherence(patient, 7).catch(() => null),
    getGenerics(patient.id).catch(() => ({ totalMonthlySavingsInr: 0 })),
    prisma.caregiverAlert.findMany({
      where: { patientId: patient.id },
      orderBy: [{ readAt: "asc" }, { createdAt: "desc" }],
      take: 50,
    }).catch(() => []),
    InteractionsRepository.listFindings().catch(() => ({ findings: [] as Finding[] }))
  ]);

  const openFindings = openFindingsBySeverity(findings);

  const alerts = alertRows.map((a) => ({
    id: a.id,
    type: a.type,
    messageEn: a.messageEn,
    messageHi: a.messageHi,
    read: !!a.readAt,
    createdAt: a.createdAt.toISOString(),
  }));

  return {
    hasMeds: meds.length > 0,
    today,
    patientName: patient.name,
    adherence: adh,
    topFinding: openFindings[0] ?? null,
    openFindingsCount: openFindings.length,
    savings: gen ? gen.totalMonthlySavingsInr : null,
    alerts
  };
}

async function HomePageDataFetcher() {
  const data = await getDashboardData();
  const { hasMeds, today, patientName, adherence, topFinding, openFindingsCount, savings, alerts } = data;

  if (!hasMeds) {
    return (
      <AppShell>
        <div className="flex flex-col items-center gap-4 py-16 text-center">
          <Camera size={48} className="text-[var(--color-primary)]" />
          <p className="text-[var(--color-text-muted)]"><T k="home.empty" /></p>
          <div className="flex w-full flex-col gap-2">
            <Link href="/scan" className="w-full">
              <PrimaryButton>
                <Camera size={18} /> <T k="home.scanCta" />
              </PrimaryButton>
            </Link>
            <Link href="/scan/picker" className="w-full">
              <GhostButton className="w-full">
                <ListChecks size={18} /> <T k="home.pickerCta" />
              </GhostButton>
            </Link>
          </div>
        </div>
      </AppShell>
    );
  }

  return (
    <AppShell safetyBadge={openFindingsCount}>
      <Greeting name={patientName} />
      {/* Top interaction alert */}
      {topFinding && (
        <Link href="/safety" className="mb-3 block">
          <div
            className={`flex items-center gap-2 rounded-[12px] px-3 py-3 ${findingTone(topFinding.severity)}`}
          >
            <AlertTriangle size={20} className=
[truncated — 1400 more characters]
```

### src/app/invite/page.tsx

```typescript
import { InviteAcceptForm } from "./InviteAcceptForm";

export const dynamic = "force-dynamic";

export default async function InvitePage({
  searchParams,
}: {
  searchParams: Promise<{ token?: string }>;
}) {
  const { token } = await searchParams;
  const validToken = typeof token === "string" && /^[a-f0-9]{64}$/.test(token) ? token : null;
  return <InviteAcceptForm token={validToken} />;
}

```

### src/app/auth/page.tsx

```typescript
import { safeInternalPath } from "@/lib/safe-redirect";
import { AuthScreen } from "./AuthScreen";

export const dynamic = "force-dynamic";

export default async function AuthPage({
  searchParams,
}: {
  searchParams: Promise<{ next?: string; error?: string }>;
}) {
  const { next, error } = await searchParams;
  const initialError = error === "invalid_link" || error === "expired_link" ? error : undefined;
  return <AuthScreen nextPath={safeInternalPath(next)} initialError={initialError} />;
}

```

### src/app/unlock/page.tsx

```typescript
import { UnlockForm } from "./UnlockForm";
import { safeInternalPath } from "@/lib/safe-redirect";

export const dynamic = "force-dynamic";

export default async function UnlockPage({ searchParams }: { searchParams: Promise<{ next?: string }> }) {
  const { next } = await searchParams;
  const nextPath = safeInternalPath(next);
  return (
    <main className="flex min-h-dvh items-center justify-center bg-gradient-to-b from-teal-50 to-white px-5 py-10">
      <section className="w-full max-w-md rounded-3xl border border-teal-100 bg-white p-7 text-center shadow-xl shadow-teal-950/10 sm:p-9">
        <p className="text-sm font-bold uppercase tracking-[0.16em] text-teal-700">DawaiSaathi</p>
        <h1 className="mt-3 text-3xl font-black tracking-tight text-slate-950">Private family access</h1>
        <p className="mt-3 text-base leading-7 text-slate-700">This medicine record is protected. Enter the family access code to continue.</p>
        <p className="mt-2 text-sm leading-6 text-slate-600">यह दवा रिकॉर्ड सुरक्षित है। आगे बढ़ने के लिए परिवार का प्रवेश कोड डालें।</p>
        <UnlockForm nextPath={nextPath} />
      </section>
    </main>
  );
}

```

### src/lib/i18n/index.ts

```typescript
import en from "./en.json";
import es from "./es.json";
import hi from "./hi.json";
import { APP_LANGUAGES, APP_LANGUAGE_CODES, type AppLanguage } from "@/lib/languages";

export const dictionaries = { en, hi, es } as const;
export type Dictionary = typeof en;

export const LANGUAGES: { code: AppLanguage; label: string }[] = APP_LANGUAGES.map((language) => ({
  code: language.code,
  label: language.nativeName,
}));

/** Resolve a dotted key path against a dictionary. */
function resolve(dict: unknown, key: string): string | undefined {
  const parts = key.split(".");
  let cur: unknown = dict;
  for (const p of parts) {
    if (cur && typeof cur === "object" && p in (cur as Record<string, unknown>)) {
      cur = (cur as Record<string, unknown>)[p];
    } else {
      return undefined;
    }
  }
  return typeof cur === "string" ? cur : undefined;
}

/** Interpolate {var} placeholders. */
function interpolate(str: string, vars?: Record<string, string | number>): string {
  if (!vars) return str;
  return str.replace(/\{(\w+)\}/g, (_, name: string) =>
    name in vars ? String(vars[name]) : `{${name}}`,
  );
}

/** Translate a key. Falls back to English, then to the raw key. */
export function translate(
  lang: AppLanguage,
  key: string,
  vars?: Record<string, string | number>,
): string {
  const raw = resolve(dictionaries[lang], key) ?? resolve(dictionaries.en, key) ?? key;
  return interpolate(raw, vars);
}

export type TFunction = (key: string, vars?: Record<string, string | number>) => string;

export { APP_LANGUAGE_CODES };

```

[433 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]