A family-friendly James River conditions dashboard for Richmond, Virginia.
Live at rvajames.org.
There are a half-dozen places to read raw USGS gage data, NWS forecasts, and James River Watch bacterial samples. None of them tell a parent in Richmond, in plain language, whether it's safe to take a four-year-old to skip rocks at Belle Isle today. This app does — by pulling the data on a schedule, running it through a deterministic safety-rules engine, and layering a small amount of AI on top to translate conditions into experience.
Richmond families with kids who want to spend a day at the river without spending an hour piecing together gauge readings, advisories, weather, and trail closures from five different websites first.
The app shows nine specific access points — Belle Isle, Pony Pasture, Texas Beach, Browns Island, Mayo Island, Shiplock Trail, North Bank Trail, Buttermilk Trail, Pump House — with status, recommendations tailored to the youngest family member's age band, and a per-location detail page with resources and activity guidance.
- Pulls live data from USGS (two gages, historical percentiles), NWS (forecast + alerts), NOAA AHPS (72-hour flood forecast), James River Association (water quality), and rva.gov (CSO + park closures) on Cloudflare Cron Triggers.
- Applies a deterministic rules engine (
lib/safety/rules.ts+lib/safety/thresholds.json) to compute a status (safe / caution / danger / closed) per access point. The same thresholds drive the at-a-glance UI and the AI prompt — single source of truth. - Layers AI on top, lazily. Anthropic Claude (Haiku by default, Sonnet on high-severity advisories) generates a metro-river summary and per-location interpretations only when a visitor requests them. Results are cached in Supabase keyed by a prompt hash so repeat visits cost nothing.
- Respects age bands. A "youngest child" selector (0-2 / 3-5 / 6-9 / 10-13 / 14+ / none) tailors the language and the recommended activities, grounded in AAP, NPS, and USCG guidance.
- Surfaces closures and advisories prominently. A trail being out for months structurally outranks "the river is fine today" in the UI — closed locations sort to the top with a distinct treatment (gray with a lock icon, not danger red).
| Layer | Choice | Why |
|---|---|---|
| Framework | Next.js 15, App Router, React 19, TypeScript | Server Components + streaming + edge-runtime support |
| Hosting | Cloudflare Workers via @opennextjs/cloudflare |
Cheap, fast, global. Replaced @cloudflare/next-on-pages (deprecated) early. |
| Database | Supabase (Postgres) | Generated TypeScript types, RLS for anon read-only access, service-role for ingest writes |
| AI | Anthropic Claude API — Haiku default, Sonnet on escalation | Prompt caching keeps cost negligible; lazy on-demand generation cached in Supabase |
| Styling | Tailwind CSS v4 (CSS-first via @theme) |
No JS config; container queries native |
| Validation | Zod | Runtime validation at every external boundary (API ingest, AI output) |
| Scraping | cheerio | Works on Workers with nodejs_compat |
| Auth (admin only) | Cloudflare Access | Zero-trust gate; no auth code to maintain |
| Tests | Vitest | Pure-function rules-engine coverage |
┌──────────────────┐ ┌──────────────────┐
│ USGS (15 min) │ │ NWS+NOAA (hourly)│ Cloudflare Cron
│ JRA (daily) │ │ RVA closures (1d)│ Triggers
│ RVA CSO (12h) │ │ USGS pct (daily) │
└────────┬─────────┘ └────────┬─────────┘
│ │
└───────────┬───────────┘
▼
┌───────────────┐
│ Supabase │ conditions_snapshots,
│ (Postgres) │ advisories, location_status,
│ │ usgs_percentiles, ...
└───────┬───────┘
│
▼
┌───────────────────────┐
│ Next.js Server │
│ Components on │
│ Cloudflare Workers │
└───────────┬───────────┘
│
Rules engine (deterministic)
│
▼
┌───────────────────────┐
│ Page render │
│ • Deterministic UI │
│ • <Suspense> │
│ └─► Lazy AI ─────┼──► Anthropic (Haiku/Sonnet)
│ cached │ + cached system prompt
│ in DB │
└───────────────────────┘
Highlights:
- Mobile-first, always. Families check on the way to the river. Touch targets ≥44px, single-column at 375px viewport, no hover-only affordances. Desktop is responsive (capped at ~896px in the main column) but the design language is mobile.
- Two USGS gages, different datums, never compared numerically. Westham (02037500) for safety thresholds (gage height in feet, established normal range). City Locks (02037705) for downriver tidal context (NAVD 1988 elevation). The system prompt and rules engine explicitly know they're not comparable.
- Lazy AI, not cron. Original plan generated 45+ interpretations daily on a cron. That's pure waste at low traffic. Switched to on-demand generation with
lib/ai/get-or-generate.tscached in Supabase by prompt hash. UNIQUE constraint handles concurrent-write races. Net cost at current traffic: pennies per month. - Rules engine + AI hybrid, not pure AI. The homepage location cards are deterministic — a status pill computed from
lib/safety/rules.ts. AI narration only appears in the metro summary at top and on per-location detail pages, where its voice and nuance earn their keep. Avoids 9 AI calls per homepage visit. - Prompt caching is critical. The cached system prompt (~6200 tokens) holds brand voice, location encyclopedia, activity matrix, age-band reference (AAP/NPS/USCG-grounded), and safety thresholds. Per-call input is just today's conditions + advisories + age bucket. First call of the day pays cache-create; everything else reads cache.
- Modern web platform over polyfills. Native
<dialog>withclosedby="any". Container queries via Tailwind v4@container.text-wrap: balanceandpretty. Speculation Rules for prefetch. The GoogleChrome/modern-web-guidance skill (installed viaskills-lock.json) informs component-level decisions. - Closures are operational state, not weather advisories. Stored in a separate
location_statustable withkindenum (open/restricted/closed/closed_indefinite). A closed location overrides weather-based status entirely. - Manual admin entry for closures; rva.gov scrape produces drafts for review. No automated Facebook/Instagram ingest — ToS and brittleness. Admin route at
/admin/closures(Cloudflare Access-gated).
- Node.js 20+ and pnpm (
npm i -g pnpm) - Docker Desktop (for the local Supabase stack)
- Supabase CLI (
brew install supabase/tap/supabase) - An Anthropic API key for AI generation (optional for most local work; required to test the lazy AI path end-to-end)
git clone <this repo>
cd rva-james
pnpm install
# Start the local Supabase stack
supabase start
# Note the printed URL, anon key, and service_role key
# Copy env templates
cp .env.development.local.example .env.development.local
# Wrangler requires the filename .dev.vars — symlink it to the single source of truth
ln -s .env.development.local .dev.varsPopulate .env.development.local with:
| Variable | Value |
|---|---|
NEXT_PUBLIC_SUPABASE_URL |
Supabase API URL (http://127.0.0.1:54321 locally) |
NEXT_PUBLIC_SUPABASE_ANON_KEY |
anon key from supabase start |
SUPABASE_URL |
same as NEXT_PUBLIC_SUPABASE_URL |
SUPABASE_ANON_KEY |
same as anon key |
SUPABASE_SERVICE_ROLE_KEY |
service_role key from supabase start |
ANTHROPIC_API_KEY |
your Anthropic API key (optional locally) |
CRON_SECRET |
any random string for local cron testing |
pnpm dev # Next.js dev at http://localhost:3000For a faithful preview of the Cloudflare Worker build:
pnpm build:cf # OpenNext build
pnpm preview # Local Worker at http://localhost:8787Hit each cron route directly with the CRON_SECRET header:
curl -H "x-cron-secret: $CRON_SECRET" http://localhost:3000/api/cron/usgs
curl -H "x-cron-secret: $CRON_SECRET" http://localhost:3000/api/cron/nws
curl -H "x-cron-secret: $CRON_SECRET" http://localhost:3000/api/cron/jra
curl -H "x-cron-secret: $CRON_SECRET" http://localhost:3000/api/cron/cso
curl -H "x-cron-secret: $CRON_SECRET" http://localhost:3000/api/cron/usgs-percentiles
curl -H "x-cron-secret: $CRON_SECRET" http://localhost:3000/api/cron/noaa-ahps
curl -H "x-cron-secret: $CRON_SECRET" http://localhost:3000/api/cron/rva-closuresEach returns { ok, rowsWritten } on success.
pnpm test # Vitest — pure-function rules engine + ingest| Command | Description |
|---|---|
pnpm dev |
Next.js dev server |
pnpm build:cf |
OpenNext Cloudflare bundle |
pnpm preview |
Local Worker preview |
pnpm deploy:cf |
Deploy to Cloudflare Workers |
pnpm test |
Vitest |
pnpm lint |
ESLint |
supabase db push |
Apply migrations to linked project |
supabase gen types typescript --local > lib/supabase/types.ts |
Regenerate DB types |
app/
page.tsx Homepage (deterministic + lazy AI)
layout.tsx Brand font, metadata
globals.css Tailwind v4 @theme + tokens
locations/[slug]/ Per-location detail
safety/ Safety + sources page
status/ Ingestion + cost dashboard
admin/closures/ Cloudflare Access-gated closure admin
api/cron/<source>/route.ts Each ingest job
_dev/ Dev-only routes (NODE_ENV gated)
brand/ Brand token showcase (dev-gated)
components/
metro/ RiverSegmentPanel, MetroSummaryPanel,
RiverConditionsDetailDialog,
RiverWideActivityGrid
tiles/ RiverLevelTile, AdvisoriesBanner, ...
ui/ PageContainer, HorizontalGauge,
Sparkline, TrendArrow
filters/ ConditionsForm
location/ ActivityMatrix, ResourceList
legal/ DisclaimerFooter, FirstVisitModal
banners/ FloodBanner
lib/
ai/ client, get-or-generate, system-prompt,
prompts/{interpret-location, summarize-metro}
ingest/ One file per data source + run.ts wrapper
queries/ Server-side data fetchers per surface
safety/ rules.ts + thresholds.json (single source
of truth shared with the AI prompt)
supabase/ Server + browser clients, generated types
supabase/migrations/ 0001..0009 schema evolution
The architecture is general-purpose; the data is local. To stand up a version for another waterway:
In supabase/migrations/0001_init.sql (and 0003 for the gauge entries), replace:
- The 9 access-point seed rows with your own access points
- The two USGS gauge entries with the gauges that cover your river (find them at waterdata.usgs.gov). Confirm which parameter codes are useful —
00065(gage height),00060(discharge),00010(water temp),62620(tidal/regulated elevation).
lib/ingest/usgs.ts— gage IDs and parameter codeslib/ingest/usgs-percentiles.ts— historical percentile fetch (same station list)lib/ingest/nws.ts— NWS grid point for your city (find viahttps://api.weather.gov/points/{lat},{lon})lib/ingest/noaa-ahps.ts— AHPS forecast gauge ID (find at water.noaa.gov)lib/ingest/jra.ts— replace with your region's bacterial water-quality program (Riverkeeper, Waterkeeper Alliance member, etc.); may require rewriting the scrape entirelylib/ingest/cso.ts— your city's combined-sewer-overflow advisory page (may not exist for separate-sewer cities; remove if so)lib/ingest/rva-closures.ts— your city/parks closure announcement page
Edit lib/safety/thresholds.json for your river's characteristics:
- Gage height bands (
normal_max_ft,flood_stage, etc.) — pull from NWS AHPS for your gauge - Activity-specific thresholds (
gage_safe_max_ftper activity) - Rapids class bands (varies by river; James River below the Fall Line is unusual — your river may not have rapids at all)
- Bacterial CFU thresholds (usually EPA standards: 235 E. coli / 100mL)
The rules engine in lib/safety/rules.ts reads from this JSON — no code changes needed if your river fits the same value shape.
lib/ai/system-prompt.ts is the heart of the personality. Update:
- The location encyclopedia (terrain notes, hazards, parking, distance from gauges)
- Brand voice section
- Any city-specific guidance (e.g., the James River's Fall Line and tidal vs. free-flowing reach distinction will differ for your river)
Note: the AI is told to derive nothing it shouldn't — every safety claim is grounded in thresholds.json. Maintain this discipline.
app/globals.css @theme block defines colors. Replace Richmond's rva-blue etc. with your city's brand or an independent palette. Verify AA contrast programmatically (e.g. via wcag-contrast) on every foreground/background pair.
supabase/migrations/0006_location_resources.sql seeds the resource links per access point. Rewrite for your locations — your local parks department, riverkeeper, NPS unit, etc.
app/safety/page.tsx— your local emergency services + relevant authoritiescomponents/legal/DisclaimerFooter.tsx— adjust if local liability framing differscomponents/legal/FirstVisitModal.tsx— first-visit copy- Site metadata in
app/layout.tsx
- Create a Cloudflare Workers account and a Supabase project
wrangler secret putthe four secrets (SUPABASE_URL,SUPABASE_SERVICE_ROLE_KEY,ANTHROPIC_API_KEY,CRON_SECRET)- Add public vars to
wrangler.jsonc supabase link --project-ref <yours>andsupabase db pushpnpm deploy:cf- Configure your domain in the Cloudflare dashboard
- Set up Cloudflare Access for
/admin/*with allowed admin email(s)
Trigger each cron route against the deployed Worker, confirm ingestion_runs rows show ok=true, visit the homepage. The deterministic side renders instantly; the AI side warms on first visit per (date, age bucket) combo.
Is: A serviceable starting point for any city-river dashboard targeting families. The patterns — deterministic rules + lazy AI cached in Supabase, cron-driven ingest, modal-and-disclosure UX — are reusable.
Isn't: A turnkey SaaS, a generic data dashboard framework, or production code for high-traffic use. It's tuned for one specific river, one specific audience, and traffic that fits in Cloudflare Workers' free tier. If your river is dramatically different — significant ice formation, tidal-only with no free-flowing reach, no public gauges — expect to rewrite more than you reuse.
See LICENSE. The data sources have their own licenses and terms; respect upstream attribution requirements (USGS public domain, NWS public domain, James River Association attribution requested, OSM if you add map tiles, etc.).
- USGS Water Services and NOAA AHPS for the public hydrology data that makes any of this possible
- The James River Association for their volunteer-driven James River Watch bacterial sampling program
- The City of Richmond Department of Public Utilities for the CSO advisory feed
- The James River Park System volunteers who keep the access points open
- The GoogleChrome/modern-web-guidance team for the guide library that informs many UI decisions in this codebase