Branch: feat/batch-b-env
Status: sync + db + cron + loader + panel all landed and committed.
Wiring into shared files (map/page.tsx, cockpit-sidebar.tsx,
sf-map.tsx) was attempted but kept getting reverted by parallel agents
on the same branch, so it's documented below for whichever agent
reconciles last.
| commit | summary |
|---|---|
23a4f0b |
feat(db): env_signals table for multi-kind environmental layer |
df198d9 |
feat(sync): NWS alerts, PurpleAir AQI, USGS quakes sources |
55ac5a9 |
feat(sync): ADS-B aircraft, AIS marine, BART/MTA transit sources |
94e0df3 |
feat(web): sync-env cron route, env loader, env panel |
456103b |
chore(vercel): register sync-env cron every 5 min |
-- packages/db/migrations/0013_env_signals.sql
CREATE TABLE env_signals (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
kind text NOT NULL CHECK (kind IN ('weather','aqi','quake','aircraft','vessel','transit')),
source text NOT NULL, -- 'nws_alerts','purpleair','usgs_quakes','adsb_opensky','aisstream','bart_mta'
source_uid text NOT NULL, -- per-source stable id; (source, source_uid) is the upsert key
lat double precision,
lng double precision,
severity text NOT NULL DEFAULT 'low' CHECK (severity IN ('low','med','high')),
title text NOT NULL,
subtitle text,
occurred_at timestamptz NOT NULL,
expires_at timestamptz, -- NULL = never expires (rare); active rows are WHERE expires_at IS NULL OR > now()
ingested_at timestamptz NOT NULL DEFAULT now(),
raw jsonb,
UNIQUE (source, source_uid)
);
CREATE INDEX idx_env_signals_kind_time ON env_signals (kind, occurred_at DESC);
CREATE INDEX idx_env_signals_active ON env_signals (occurred_at DESC) WHERE expires_at IS NULL OR expires_at > now();
CREATE INDEX idx_env_signals_source ON env_signals (source);
-- RLS: SELECT for anon + authenticated; added to supabase_realtime publication.Drizzle binding exported as envSignals / EnvSignal / NewEnvSignal
from @caltrans/db.
| path | purpose |
|---|---|
packages/db/migrations/0013_env_signals.sql |
Migration (table, indexes, RLS, realtime). |
packages/sync/src/env-signals.ts |
upsertEnvSignals(db, rows) helper (mirrors upsertLiveIncidents). |
packages/sync/src/sources/nws-alerts.ts (+ .test.ts) |
fetchNwsAlerts() — keyless NWS CA alerts, SF bbox + SAME-code filter. |
packages/sync/src/sources/purpleair.ts (+ .test.ts) |
fetchPurpleAir() — SF-bbox AQI sensors + synthetic sf-avg row. |
packages/sync/src/sources/usgs-quakes.ts (+ .test.ts) |
fetchUsgsQuakes() — keyless USGS all_hour.geojson, Bay Area bbox. |
packages/sync/src/sources/adsb.ts (+ .test.ts) |
fetchAdsb() — OpenSky 25km-of-SF, surfaces helicopters + loiterers. |
packages/sync/src/sources/ais-marine.ts (+ .test.ts) |
fetchAis() — AISStream.io websocket snapshot, SF bbox vessels. |
packages/sync/src/sources/bart-mta-alerts.ts (+ .test.ts) |
fetchBartMtaAlerts() — BART BSA + 511 SFMTA, isolated failure modes. |
apps/web/app/api/cron/sync-env/route.ts |
Fan-out cron; auths via isAuthorizedCron, batches by source. |
apps/web/lib/cockpit/environmental.ts |
loadEnvSignals() cached loader + ENV_SIGNALS_CACHE_TAG. |
apps/web/components/cockpit/environmental-panel.tsx |
EnvironmentalPanel cockpit widget; top 5 by severity then recency. |
| path | change |
|---|---|
packages/db/src/schema.ts |
Added envSignals table + EnvSignal / NewEnvSignal / EnvSignalKind types. |
packages/sync/src/index.ts |
Re-exported the six new sources + env-signals helper. |
apps/web/lib/env.ts |
Added Zod entries for PURPLEAIR_API_KEY, OPENSKY_USERNAME, OPENSKY_PASSWORD, AISSTREAM_API_KEY, BART_API_KEY. |
apps/web/.env.example |
Documented the 5 new env vars + their providers. |
apps/web/vercel.json |
Added /api/cron/sync-env */5 * * * *. |
| key | required? | failure mode |
|---|---|---|
PURPLEAIR_API_KEY |
optional | AQI source returns disabled: true |
OPENSKY_USERNAME + OPENSKY_PASSWORD |
optional | ADS-B falls back to keyless rate limits |
AISSTREAM_API_KEY |
optional | AIS marine source returns disabled: true |
BART_API_KEY |
optional | Falls back to the documented public sample key MW9S-E7SL-26DU-VV8V |
SF_511_API_KEY |
reused | SFMTA half of BART/MTA source needs this; was already in .env.example |
Existing CRON_SECRET gates the new /api/cron/sync-env route via
isAuthorizedCron.
Whichever agent reconciles last needs to apply these additive edits. Everything else is in place; without these the env loader runs but the panel doesn't receive data on the cockpit and the map doesn't render the new pins.
Add one import, one loader, one extra prop on SFMap and CockpitSidebar:
import { loadTrafficDisruptions } from "@/lib/cockpit/traffic-disruptions";
+import { loadEnvSignals } from "@/lib/cockpit/environmental";
const [
cameras,
newsRes,
liveIncidents,
instability,
sfBrief,
trafficDisruptions,
+ envSignals,
] = await Promise.all([
loadCameraPins(),
supabase.from("news_incidents").select(...) ...,
listLiveIncidents({ unacknowledgedOnly: true }),
loadInstability(),
loadSFBrief(),
loadTrafficDisruptions(),
+ loadEnvSignals(),
]);
- <SFMap cameras={cameras} newsIncidents={newsIncidents} />
+ <SFMap cameras={cameras} newsIncidents={newsIncidents} envSignals={envSignals} />
<CockpitSidebar
liveIncidents={liveIncidents}
...
trafficDisruptions={trafficDisruptions}
+ envSignals={envSignals}
/>Add the import + the prop + one widget array entry:
import type { TrafficDisruption } from "@/lib/cockpit/traffic-disruptions";
+import type { EnvSignalRow } from "@/lib/cockpit/environmental";
import { LiveFeedPanel } from "./live-feed-panel";
+import { EnvironmentalPanel } from "./environmental-panel";
interface Props {
...
trafficDisruptions: TrafficDisruption[];
+ envSignals?: EnvSignalRow[];
}
export function CockpitSidebar({
...
trafficDisruptions,
+ envSignals = [],
}: Props) {
const widgets: CockpitWidget[] = [
...
{ id: "traffic-disruptions", label: "Traffic Disruptions", defaultSpan: 2, node: <TrafficDisruptionsPanel rows={trafficDisruptions} /> },
+ { id: "environmental", label: "Environmental", defaultSpan: 2, node: <EnvironmentalPanel rows={envSignals} /> },
];Per the brief: render env layers as toggleable pin sets, no heavy
refactor (Batch C owns that). Recommended minimal additive change —
add an envSignals?: EnvSignalRow[] prop, a showEnv toggle, and a
single HTML-marker layer for env signals with shape varying by kind.
+import type { EnvSignalRow, EnvSignalKind } from "@/lib/cockpit/environmental";
...
interface Props {
cameras: CamWithCoords[];
newsIncidents?: NewsIncidentRow[];
+ envSignals?: EnvSignalRow[];
}
-export function SFMap({ cameras, newsIncidents = [] }: Props) {
+export function SFMap({ cameras, newsIncidents = [], envSignals = [] }: Props) {
...
+ const [showEnv, setShowEnv] = useState(true);
+ const [envKinds, setEnvKinds] = useState<Record<EnvSignalKind, boolean>>({
+ weather: true, aqi: true, quake: true, aircraft: true, vessel: true, transit: true,
+ });
+ const envMarkersRef = useRef<Map<string, maplibregl.Marker>>(new Map());
+ // Diff-and-sync the env pin set the same way `newsMarkersRef` does,
+ // keyed on EnvSignalRow.id, with element class `wd-env-marker` +
+ // data-kind so CSS can colour each kind distinctly.The brief calls out 6 toggles on the map control bar:
Weather / AQI / Quakes / Aircraft / Marine / Transit.
Trigger manually after deploy:
curl -H "Authorization: Bearer $CRON_SECRET" \
https://watchdog-yc.vercel.app/api/cron/sync-envPer-source result schema (returned by the route):
{
ok: boolean,
ranAt: string, // ISO timestamp
sources: Array<{
source: string, // "nws_alerts" | "purpleair" | "usgs_quakes" | "adsb_opensky" | "aisstream" | "bart_mta"
status: "ok" | "skipped" | "error",
upserted: number,
attempted: number,
dropped: number,
durationMs: number,
error?: string,
disabled?: boolean, // true when the source's key is missing
}>
}The route returns 200 when every source either succeeded or was disabled (missing key); 207 when at least one source threw.
- All six source files have >=4 tests each (28 source tests + the
pm25ToAqihelper trio = 33 new test cases). - Whole sync package: 72 tests passing, 0 failures.
- Sync + db:
pnpm typecheckclean. - Web:
pnpm typecheckclean after deleting stale.next/typescaches from an unrelated batch-d route that had been removed. - Web
pnpm test: 70 tests passing, 0 failures.
- Cron-running verification. I can't actually hit Vercel to
exercise the cron — needs real upstream API keys + a deploy. All
tests use mocked
fetch/WebSocket. The first real cron run will reveal any upstream response shape drift; fetchers are defensive (parse → drop unparseable rows, returndroppedcount). - PurpleAir
sensor_indexcolumn. Per docs PurpleAir always returnssensor_indexas the first column regardless of thefieldsparameter. If their docs are stale, the helper currently reads index 0 viaidx("sensor_index")against the response'sfieldsarray, which would return-1and crash. If the first real run logsdropped≫attempted, force-piniSensor = 0instead. - AISStream subscription shape. Docs vary by version. Current
payload is
{ APIKey, BoundingBoxes: [[[swLat,swLng],[neLat,neLng]]], FilterMessageTypes: ["PositionReport"] }. If the first run yields zero messages, double-checkBoundingBoxesnesting against their live docs and cross-referenceFilterMessageTypescasing. - BART unique key. BART BSA advisories don't ship a stable UID;
the source synthesizes
bart-<type>-<posted>-<desc[:32]>to upsert idempotently. If BART changes theirpostedformatting between polls a row will look new each time. Acceptable while volume is small (≤ a dozen rows/day across all advisories). The three shared-file edits (page / sidebar / sf-map) are documented above but were not committed — see "Wiring still required".Resolved in commit7382d2f. Page, cockpit sidebar, and sf-map now wire envSignals end-to-end; the Env layer toggle and marker pins are live on/map.
| commit | what landed |
|---|---|
6ed7edc |
OpenSky migrated to OAuth2 client credentials. OPENSKY_USERNAME/OPENSKY_PASSWORD renamed to OPENSKY_CLIENT_ID/OPENSKY_CLIENT_SECRET. adsb.ts now exchanges them for a bearer token at auth.opensky-network.org/.../openid-connect/token and caches the token for its TTL. New OAuth2 test added (73/73 sync). |
7382d2f |
Shared-file wiring: map/page.tsx calls loadEnvSignals() and passes envSignals to both SFMap and CockpitSidebar. CockpitSidebar registers EnvironmentalPanel widget. sf-map.tsx adds an envMarkersRef diff-and-sync useEffect mirroring news markers, plus an Env LayerToggle and per-kind/per-severity styled-jsx CSS. Per-kind toggles remain Batch C's scope. |
After this resolution: web typecheck clean, sync typecheck clean, db typecheck clean, sync 73/73, web 70/70.
This batch adds the env_signals table + six free-tier OSINT
sources (NWS, PurpleAir, USGS, OpenSky, AISStream, BART/MTA) plus a
fan-out cron, a cached loader, a cockpit panel, an Env layer on the
map, and OAuth2 auth for OpenSky. All committed on feat/batch-b-env.
The single-table-multi-kind shape lets future sources (tide, lightning,
satellite passes) drop in without schema churn.