Know where to park in SF. Live at curb.guide.
San Francisco posts a 2-hour street-cleaning window. We pulled about 1.35 million real citations (over a million matched to their exact blocks): on the median block, most of the tickets land inside a ~20-minute span. CURB puts that on a map — every curb in SF colored by its next sweep, with the posted schedule AND the times tickets are actually written there, plus permit (RPP) areas, meters, loading zones (including the unmetered white school zones the city doesn't publish on DataSF), and one-tap Web Push move-your-car alerts.
The whole app is one static index.html — vanilla JS + Leaflet, no framework, no
build step — plus a few Vercel serverless functions for push and share pages, and
precomputed JSON data assets rebuilt by scripts/build-*.mjs. Everything runs on
free tiers. Free, no accounts, no ads, no cookies.
Read the data story: curb.guide/tickets · How it works: curb.guide/about · Contributions welcome — see CONTRIBUTING.md · Security: SECURITY.md
npm install # for the web-push dep used by the API
npm run dev # = npx serve . -l 3077 (http://localhost:3077)Open http://localhost:3077. A localhost origin is needed for geolocation + service worker.
npm run deploy # = vercelThe "🔔 Sweep alerts" button in the detail sheet subscribes the device to Web Push and saves its spot; a scheduled sender (Upstash QStash every 15 min, GitHub Actions as backup) pushes "move your car" before the next sweep.
Setup (one time):
- VAPID keys —
npx web-push generate-vapid-keys. The public key is embedded inindex.html(VAPID_PUBLIC_KEY); both keys go in env (see.env.example). If you rotate keys, update the constant inindex.htmltoo. - Subscription store — add the Upstash for Redis integration on Vercel (Storage
tab). It sets
KV_REST_API_URL/KV_REST_API_TOKENautomatically.api/_store.jsalso acceptsUPSTASH_REDIS_REST_URL/_TOKENfor a standalone Upstash DB. - Env vars on Vercel —
VAPID_PUBLIC_KEY,VAPID_PRIVATE_KEY,VAPID_SUBJECT,CRON_SECRET,QSTASH_CURRENT_SIGNING_KEY,QSTASH_NEXT_SIGNING_KEY, optionalHC_PING_URL, plus the KV vars from step 2. The sender refuses to run unless a QStash signature or the CRON_SECRET bearer checks out. - Scheduler — two timers plus a backup. Vercel Cron in
vercel.jsonruns at :07/:22/:37/:52 (needs Vercel Pro; remove thecronsblock before any move to Hobby, which rejects sub-daily crons).- Primary: Upstash QStash (Upstash console → QStash → Schedules): destination
https://curb.guide/api/send-notifications(exactly — no query string), cron*/15 * * * *, method POST, empty body, retries 0-1. Copy the QStash signing keys into the two env vars above; the endpoint verifies every call's signature, so QStash never needsCRON_SECRET. - Backup: GitHub Actions —
.github/workflows/sweep-alerts-cron.ymlcurls the endpoint withAuthorization: Bearer <CRON_SECRET>(repo secret). GitHub schedules are best-effort (they ran ~7x/day in Sep 2026), so it only fills gaps. Overlapping runs never double-notify. - Watching it:
HC_PING_URL= a healthchecks.io check (period 15 min, grace ~30 min); the endpoint pings it after every successful QStash run and/failon errors.GET /api/send-notifications?status=1with the CRON_SECRET bearer shows the last run, and the monitor workflow alerts when the latest run failed or, once QStash has run, when no QStash run succeeded for 40 min. Until QStash first runs, only the GitHub backup sends, hours apart, so the monitor then alerts only after 8 h without a successful run. - Monitor via QStash (optional): two more QStash schedules dispatch
.github/workflows/monitor.ymlthrough the GitHub API — smoke every 30 min, nightly once a day (exact request in that file's header). About 145 QStash messages a day in total.
- Primary: Upstash QStash (Upstash console → QStash → Schedules): destination
iOS — two paths:
- Native app (the iOS build is a WKWebView wrapper with native APNs): the "🔔 Sweep
alerts" button is diverted to the native bridge, so no Home-Screen install is needed. Set
APNS_KEY_P8_B64(orAPNS_KEY_P8),APNS_KEY_ID,APNS_TEAM_ID,APNS_BUNDLE_IDon Vercel (see.env.example/docs/native-push-plan.md). - Mobile Safari (PWA): Web Push only works once CURB is installed to the Home Screen (the app shows an "Add to Home Screen" hint for un-installed iPhones).
With a Google Map Tiles API key the basemap uses official Google tiles; without one it falls back to keyless CARTO Voyager. The key is a client key — **restrict it by HTTP referrer
- API** in Google Cloud Console (add
http://localhost:3077/*,https://*.vercel.app/*, and your domain).
The key is kept out of this public repo:
- Local: copy
config.example.js→config.js(gitignored) and paste your key. - Deployed: set
GMAPS_KEYin your Vercel env.api/config.jsserves it to the client andvercel.jsonrewrites/config.js→/api/config, so nothing changes inindex.html.
- Street sweeping: DataSF
yhqp-riqs - Parking meters: DataSF
8vzz-qzz9 - Parking regulations / RPP: DataSF
hi6h-neyh(2017 set; may be incomplete) - Address search + block ranges: DataSF
3mea-di5p(Enterprise Addressing System, nightly) - Loading / color-curb zones: DataSF
6cqg-dxku(Meter Operating Schedules) ⋈ meters - Unmetered white zones (passenger loading, school zones): SFMTA Digital Curb on the
city ArcGIS hub — snapshot via
npm run build:whitezones(data DataSF excludes) - Enforcement history: SFMTA street-cleaning citations with GPS, records request #26-5453 (the public feed is DataSF
ab4h-6ztd) — precomputed intodata/enforcement.jsonbynpm run build:enforcement(seescripts/build-enforcement-records.py) - /tickets aggregates:
npm run build:stats→data/stats.json(yearly fines, violations, hour histograms, neighborhood totals + five-year surge) - Neighborhood boundaries: DataSF
j2bu-swwd(Analysis Neighborhoods) — /tickets + the per-hood pages
All DataSF datasets are published under the Open Data Commons PDDL; the code here is MIT (see LICENSE).
The posted street sign is always the source of truth. "Ticketed ~" times are historical guidance from past citations, not a guarantee. There is no live space-availability data for SF (SFpark sensors retired in 2014).
See CLAUDE.md for architecture and data schemas, docs/ for the sweeper-data research
and ready-to-send public-records requests.
The headline finding — "the median block is ticketed within ~20 minutes" — is built offline by
scripts/build-enforcement-records.py + scripts/build-stats.mjs:
- Pull the citations. The 2024–2026 street-cleaning citations came via SFMTA public-records request
#26-5453 — about 1.35 million rows, with GPS coordinates from the handheld (the public DataSF feed
ab4h-6ztdplaces each ticket only by its typed address). The older address-only path — normalize tostripZeros(number)|UPPERCASE(street)and join to a block (CNN) via the Enterprise Addressing System3mea-di5p, inscripts/build-enforcement.mjs— survives only as a pre-2024 fallback. - Match by GPS, not address strings. Each citation's GPS point is matched to the nearest street
segment (CNN) within ≤40 m (
yhqp-riqsgeometry), recovering ~1,050,000 of ~1,350,000 tickets — far more, and more accurately, than the lossy address join. - Reduce to a per-block-side distribution.
data/enforcement.jsonstores, per block side,[count, mean-minute-of-day, earliest, latest]. The window is really ~20 minutes — the median ticket lands ~25 min in, and ~77% of tickets fall within the first 45 minutes (~90% within the first hour). A companion dataset — the city's actual sweeper GPS (request #26-5451 →data/sweeps.jsonviascripts/build-sweeps.py) — shows tickets land a median of ~19 minutes after the sweeper passes.
Known limits (also stated on the About page): it's a GPS point snapped to the nearest street segment (within 40 m), so any single block can be off; the distribution is conditional on enforcement happening — it shows when tickets land, not whether a block is swept (survivorship); the ticket times are a June 2026 snapshot rebuilt by hand, not by the monthly refresh; and it's predictive from history, never live (SF publishes no real-time sweeper GPS). The posted sign is always the source of truth.
The inferred truck-route layer is a Beta estimate: blocks sharing a corridor + sweep window are
ordered by their mean ticket time; runs whose time-vs-position correlation is weak (|r| < 0.35) are
not drawn.
npm run og # rebuild og.png (1200x630 social card) from og/template.html
npm run build:enforcement # rebuild data/enforcement.json from ~2yr of citations (~2 min)