What it is and why it exists
Food insecurity in Kansas City is not a supply problem in isolation. It is a coordination problem: food batches arrive, spoil windows are narrow, and the neighborhoods with the highest need are not always the ones operators reach first. Abundance-KC closes that gap with a scoring-driven allocation engine, real-time dashboard, and a bilingual public portal that does not require residents to have an account to get help.
The system has two primary users: operators (food bank staff, dispatchers, pantry managers) who confirm dispatches and manage supply alerts, and residents who can browse sites on a map, request help, and vote on community priorities by ZIP code.
Food banks, pantries, mobile units, shelters, schools, gardens, pop-ups.
Batches escalate at 40% of spoilage window, leaving real logistics lead time.
Language state is cookie-persisted and drives allocation scoring, not just labels.
App runs fully without ANTHROPIC_API_KEY. Claude is additive, not load-bearing.
Technology choices
The stack is intentionally constrained. No global state manager, no ORM, no separate backend service. Supabase handles auth, database, and realtime subscriptions. Business logic lives in lib/; route handlers are thin orchestrators only.
| Layer | Technology |
|---|---|
| Framework | Next.js 16.2.1 — App Router |
| Language | TypeScript 5 — strict mode, no any in prod |
| UI / Styling | Tailwind CSS 4 + Radix UI + shadcn-style primitives |
| Backend / DB | Supabase — Postgres, Auth, Realtime |
| Maps | Mapbox GL via react-map-gl |
| AI | claude-haiku-4-5 — speed and cost-efficient |
Resend — transactional help-request alerts | |
| Fonts | Plus Jakarta Sans (headings) · Inter (body) |
Neighborhood need scoring
A weighted composite of five signals computes a 0–100 need score per ZIP code. The formula is deterministic, transparent, and operator-auditable — not a black box.
need_score = poverty_rate × 0.25 + food_insecurity_pct × 0.25 + no_car_pct × 0.20 + store_closure_impact× 0.15 // people_impacted / 50 000 + distress_calls_norm × 0.10 // calls / 500 + food_desert_flag × 0.05 × 1.15 if harvest_priority ZIP // capped at 100
Poverty rate alone under-counts households that are food-insecure because of mobility, language, or supply-side gaps. Harvest priority ZIPs get a 15% multiplier because fresh produce spoils faster and those neighborhoods have the least access to refrigeration or transportation.
How food batches get assigned
When a batch arrives, every eligible site is scored against it. The algorithm weights community need highest because that is the mission, followed by cold-storage compatibility because wasted perishables are an irreversible failure.
site_score = neighborhood_need_score × 0.30 + cold_storage_match × 0.25 // hard 0 if batch needs cold, site has none + language_match × 0.20 // 1.0 if site serves ZIP's primary language + capacity_available × 0.15 // normalized remaining lbs + transit_accessible × 0.10 // bus stop within walking distance
| Signal | Weight | Rationale |
|---|---|---|
| Neighborhood need | Mission-critical signal | |
| Cold storage match | Spoilage is irreversible | |
| Language match | Inaccessibility without communication | |
| Capacity available | Normalized remaining pounds | |
| Transit accessible | Bus stop within walking distance |
Sites are ranked and presented to operators with human-readable rationale. Claude generates a 1–2 sentence plain-English explanation of the top pick. Operators confirm before dispatch fires.
Perishable batch auto-escalation
Batches approaching spoilage escalate automatically. The threshold is tuned to leave enough logistics lead time without triggering false alarms.
escalation_threshold = perishability_hours × 0.4
// If a batch will expire within this window and is still unallocated:
1. Select the highest-need ZIP in the batch's impacted zone
2. Create a popup_event tied to that alert
3. Notify operators via realtime dashboard channel
POST /api/escalate?alert_id=… — the 40% threshold is configurable per deployment.
Push-based dashboard updates
The operator dashboard uses Supabase Realtime channel subscriptions instead of polling. Operators need sub-second awareness of new perishable alerts. HTTP polling introduces lag that is unacceptable when spoilage windows are measured in hours.
const channel = supabase
.channel('supply_alerts_changes')
.on('postgres_changes', {
event: 'INSERT', schema: 'public', table: 'supply_alerts'
}, (payload) => {
setAlerts(prev => [payload.new as SupplyAlert, ...prev])
})
.subscribe();
Claude as explainer, not decision-maker
The core allocation algorithm is deterministic code. Claude is additive — it explains decisions in plain language and drafts outreach, but has no bearing on which site gets food. This keeps the system auditable and fully functional without an API key.
Allocation rationale
Takes allocation + scores, returns a 1–2 sentence plain-English explanation of why the top site was selected.
Bilingual outreach drafts
Takes site and event details, returns a bilingual message draft for community outreach communications.
Alert parsing
Extracts structured fields (quantity, perishability) from freeform alert description text on ingest.
End-to-end dispatch lifecycle
/api/ingest, scored, and upserted into Supabase. TTL-cached at the API client layer to avoid rate limit issues.
POST /api/dispatch/[id]/confirm — allocation record updated, analytics event logged.
Notable engineering patterns
→ Algorithm over AI
Scoring and allocation are deterministic and auditable. Claude handles explanation and messaging only — never a decision path.
→ No global state manager
Supabase client + React Context for language + local useState is sufficient. No Redux or Zustand added.
→ Thin API routes
Business logic lives in lib/. Route handlers are orchestrators only — easy to test and reason about.
→ Cookies over URL params
Language state persists across navigation without encoding it into every URL, and is readable server-side for SSR.
→ Accessibility first
Radix UI primitives for keyboard nav, a TTS endpoint for screen-reader fallback, barrier-aware help request form fields.
→ TypeScript end-to-end
All entities typed in types/index.ts. No any in production code.