Know where to park in SF. Live at curb.guide.
San Francisco posts a 2-hour street-cleaning window. We matched 650,000+ real citations to their exact blocks: on the median block, every ticket lands inside a 22-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 3000 (http://localhost:3000)Open http://localhost:3000. 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 Vercel cron pushes "move your car" ~30 min 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(required — the cron refuses to run without it), plus the KV vars from step 2. - Cron —
vercel.jsonruns/api/send-notificationsevery 15 min. Note: the 15-min cadence needs Vercel Pro; on Hobby, Vercel throttles crons to ~once/day. As a fallback, point any external scheduler (e.g. cron-job.org) at the endpoint with headerAuthorization: Bearer <CRON_SECRET>.
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:3000/*,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: DataSF
ab4h-6ztd(parking citations) — precomputed intodata/enforcement.jsonbynpm run build:enforcement(seescripts/build-enforcement.mjs) - /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 ~22 minutes" — is built offline by
scripts/build-enforcement.mjs + scripts/build-stats.mjs:
- Pull the citations. SFMTA's citation dataset (
ab4h-6ztd, ~23.8M rows since 2008) carries a minute-resolution timestamp and the officer-typed address — but no coordinates since ~2021, so you get raw strings like0121 STEINER ST. Paged with a keyset cursor on:id(deep$offsettimes out on a table that size). - Geocode by joining, not coordinates. Each address is normalized to
stripZeros(number)|UPPERCASE(street)and matched to a block segment (CNN) via the Enterprise Addressing System (3mea-di5p), then validated against that block's posted sweep schedule (yhqp-riqs) to drop bad matches. ~659k street-cleaning citations match over the last ~2 years. - Reduce to a per-block-side distribution.
data/enforcement.jsonstores, per block side,[count, mean-minutes-into-window, earliest, latest]. The "22 minutes" is the median, across ~18,000 block-sides, oflatest − earliest(the window its whole 2-year ticket history falls in); ~87% of block-sides land all their tickets within 45 minutes.
Known limits (also stated on the About page): it's an address-string→block join, not GPS, 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); data is refreshed monthly; 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)