Technical Write-Up

Abundance-KC

A food access coordination platform for Kansas City. Bridges food distribution networks with residents through a real-time operator dashboard and a public-facing resident portal.

Next.js 16 TypeScript Supabase Tailwind CSS 4 Claude Haiku Mapbox GL Resend Radix UI

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.

7
Site types supported

Food banks, pantries, mobile units, shelters, schools, gardens, pop-ups.

0.4×
Perishability escalation threshold

Batches escalate at 40% of spoilage window, leaving real logistics lead time.

EN / ES
Bilingual by default

Language state is cookie-persisted and drives allocation scoring, not just labels.

Graceful
AI degradation

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.

LayerTechnology
FrameworkNext.js 16.2.1 — App Router
LanguageTypeScript 5 — strict mode, no any in prod
UI / StylingTailwind CSS 4 + Radix UI + shadcn-style primitives
Backend / DBSupabase — Postgres, Auth, Realtime
MapsMapbox GL via react-map-gl
AIclaude-haiku-4-5 — speed and cost-efficient
EmailResend — transactional help-request alerts
FontsPlus 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.

src/lib/scoring/need.ts
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.

src/lib/scoring/allocation.ts
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
SignalWeightRationale
Neighborhood need
30%
Mission-critical signal
Cold storage match
25%
Spoilage is irreversible
Language match
20%
Inaccessibility without communication
Capacity available
15%
Normalized remaining pounds
Transit accessible
10%
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.

src/lib/escalation.ts
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
Manual override available via 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.

src/app/(dashboard)/dashboard/page.tsx
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();
Supabase Realtime subscriptions are push-based and included in the free tier. No additional infrastructure required.

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.

POST /api/ai/explain

Allocation rationale

Takes allocation + scores, returns a 1–2 sentence plain-English explanation of why the top site was selected.

POST /api/ai/outreach

Bilingual outreach drafts

Takes site and event details, returns a bilingual message draft for community outreach communications.

Inline in /api/ingest

Alert parsing

Extracts structured fields (quantity, perishability) from freeform alert description text on ingest.


End-to-end dispatch lifecycle

1
Ingest Challenge API data pulled via /api/ingest, scored, and upserted into Supabase. TTL-cached at the API client layer to avoid rate limit issues.
2
Alert arrives New supply alert triggers Realtime subscription on the operator dashboard. Escalation engine checks perishability threshold in parallel.
3
Allocation scored Every eligible site is scored against the batch. Top candidates presented to the operator with weights and Claude-generated rationale.
4
Operator confirms 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.