Originally published at parvejshah.com/blog/offline-first-pwa-emergency-volunteer-networks by Parvej Shah.
The Bottleneck Wasn’t Search, It Was Entry
The app runs a Workbox service worker caching static assets, and a NetworkFirst strategy on the API calls that matter — the core donor-lookup workflow still needs a network connection. Offline caching wasn’t the interesting problem here.
The real friction Badhan’s coordinators had wasn’t looking donors up — a blood-group-indexed Postgres query handles that fine. It was getting donor information in. Volunteers were already reporting new donors the way people naturally coordinate things: as free text, in Telegram, in whatever format they happened to type it in. A form nobody consistently fills out correctly is worse than no form at all. So we built two different entry paths for two different shapes of input, instead of forcing one workflow on both.
Path One: Telegram, No AI Involved
Coordinators post donor info directly into a Telegram group. The bot detects donor-shaped messages with a keyword/pattern heuristic — blood group plus a phone number or date is usually enough — and hands the text to a deterministic parser: no LLM call, no API cost, no rate limit to worry about.
Parvej Shah
B+
IIT 23-24
01516538054
25-08-25
Hasanur Rahman
AEH Hall
Enter fullscreen mode Exit fullscreen mode
That’s the expected shape: referrer name, donor name, then blood group / phone / date / batch / hall in any order, matched by what each token looks like rather than its position. A comma-separated single-line variant works too. Multiple donors in one message just need a blank line between blocks. The bot replies per donor — ✅ submitted, ⚠️ already exists, or ❌ with the specific validation error — so a bad phone number in donor 3 of 5 doesn’t obscure that the other four went through fine.
This path is intentionally not AI. A Telegram group can get bursts of messages, and every one of them gets scanned for the donor-data pattern. Running an LLM call against every group message would be slow, costly, and unnecessary — the format volunteers actually use is regular enough that pattern matching gets it right without asking an API to guess.
Path Two: The Web Submit Page, Where AI Earns Its Keep
There’s a second entry point — a plain “paste your donor list” form on the web app — for the messier case: someone dumping a half-formatted list from a spreadsheet, a WhatsApp export, or a batch of records that don’t line up with the strict positional format. That’s where the AI parser actually lives, and it’s built as a three-tier fallback, not a single point of failure:
// 1. Attempt Gemini AI parsing (skipped if useAI is false)
const aiDonors = useAI ? await parseWithGemini(trimmedText) : null;
if (aiDonors && aiDonors.length > 0) {
return NextResponse.json({ donors: aiDonors, usedAI: true });
}
// 2. Try fixed-format block parser
if (isFixedBlockFormat(trimmedText)) {
const fixedDonors = parseFixedFormatBlocks(trimmedText);
if (fixedDonors.length > 0) return NextResponse.json({ donors: fixedDonors, usedAI: false });
}
// 3. Fall back to regex parser
const regexDonors = await parseBulkFormattedText(trimmedText);
Enter fullscreen mode Exit fullscreen mode
Gemini gets a strict extraction prompt: blank-line-separated blocks, a fixed referrer/donor-name convention for the first two lines, and explicit field-identification rules — a blood-group token can be “B(+ve)” or “o+” or “AB(positive)” and should normalize to AB+; a date can be 5-5-26 or 09/04/2026 and should normalize to DD-MM-YYYY; a hall name like “AEH” or “Ae hall” should normalize to AE Hall. The model is told exactly what shape to return and nothing else — a bare JSON array, no markdown fences, no commentary.
Fallback Isn’t Optional
Gemini calls fail for boring reasons: rate limits, transient errors, a malformed response the model didn’t quite get right. The parser rotates across multiple API keys, and when a key gets a 429 it’s marked as cooling down for 10 minutes and skipped on the next attempt rather than retried into more failures:
function markKeyCooledDown(keyState: KeyState): void {
keyState.cooledUntil = Date.now() + COOLDOWN_MS; // 10 minutes
}
Enter fullscreen mode Exit fullscreen mode
If every key is cooling down, or the response fails to parse as valid JSON, parseWithGemini returns null and the route falls through to the fixed-block parser, then the regex parser. A submission on the web form never just fails because the AI step had a bad moment — it degrades to a dumber but reliable path instead.
What This Buys, and What It Doesn’t
The honest version of this feature: two intake paths matched to two real usage patterns, a fallback chain that treats the AI step as an enhancement rather than a dependency, and a UserFeedback table plus an internal review page where coordinators can flag a bad parse for someone to look at later. That last part is a correction log, not a self-improving system — the extraction prompt doesn’t change itself based on feedback, and there’s no active training loop running today. It’s a place mistakes get recorded, not a model that gets smarter on its own.
What it produced: 407 donors and 599 donation records for the Amar Ekushey Hall Unit, most of them entered as Telegram messages typed the way people already type, not through a form built assuming they’d type differently.
Parvej Shah is a Lead Full-Stack Web Developer & Platform Architect based in Dhaka, Bangladesh. Explore full architecture case studies and production code at parvejshah.com.