尚未偵測到更新。GitHub 發布版本後會顯示在這裡。
個人 GitHub 帳號下公開儲存庫的擁有者可以登入並驗證所有權,自行管理介紹,無需管理員核准。
登入並產生驗證檔案,在 30 分鐘內將其提交至此儲存庫的預設分支。其他分支或分叉儲存庫中的檔案無效。然後在下方檢查檔案,即可無需管理員核准直接管理專案。請保留檔案以維持管理權限。
如需登入,將前往登入頁面。登入後請回到此處,再次點擊所選的驗證按鈕。
在專案資料夾中執行 /nomorevibe verify,即可驗證所有權並自行管理介紹。
一分鐘完成驗證SF street-parking rules on a map — street sweeping, meters & permit zones, with calendar reminders + Web Push move-your-car alerts. Vanilla JS PWA.
翻譯準備中
正在載入完整 README。
檢視完整 README ↗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 # = vercel
The "🔔 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):
npx web-push generate-vapid-keys. The public key is embedded in
index.html (VAPID_PUBLIC_KEY); both keys go in env (see .env.example). If you
rotate keys, update the constant in index.html too.KV_REST_API_URL / KV_REST_API_TOKEN automatically. api/_store.js
also accepts UPSTASH_REDIS_REST_URL / _TOKEN for a standalone Upstash DB.VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT,
CRON_SECRET, QSTASH_CURRENT_SIGNING_KEY, QSTASH_NEXT_SIGNING_KEY, optional HC_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.vercel.json runs at :07/:22/:37/:52 (needs Vercel Pro;
remove the crons block before any move to Hobby, which rejects sub-daily crons).
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 needs CRON_SECRET..github/workflows/sweep-alerts-cron.yml curls the endpoint with
Authorization: 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.HC_PING_URL = a healthchecks.io check (period 15 min, grace ~30 min); the
endpoint pings it after every successful QStash run and /fail on errors.
GET /api/send-notifications?status=1 with 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.iOS — two paths:
APNS_KEY_P8_B64 (or APNS_KEY_P8), APNS_KEY_ID, APNS_TEAM_ID, APNS_BUNDLE_ID on Vercel
(see .env.example / docs/native-push-plan.md).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
http://localhost:3077/*, https://*.vercel.app/*, and
your domain).The key is kept out of this public repo:
config.example.js → config.js (gitignored) and paste your key.GMAPS_KEY in your Vercel env. api/config.js serves it to the client and
vercel.json rewrites /config.js → /api/config, so nothing changes in index.html.yhqp-riqs8vzz-qzz9hi6h-neyh (2017 set; may be incomplete)3mea-di5p (Enterprise Addressing System, nightly)6cqg-dxku (Meter Operating Schedules) ⋈ metersnpm run build:whitezones (data DataSF excludes)ab4h-6ztd) — precomputed into
data/enforcement.json by npm run build:enforcement (see scripts/build-enforcement-records.py)npm run build:stats → data/stats.json (yearly fines,
violations, hour histograms, neighborhood totals + five-year surge)j2bu-swwd (Analysis Neighborhoods) — /tickets + the per-hood pagesAll 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:
ab4h-6ztd places each ticket only by its typed address). The older address-only path — normalize to
stripZeros(number)|UPPERCASE(street) and join to a block (CNN) via the Enterprise Addressing System
3mea-di5p, in scripts/build-enforcement.mjs — survives only as a pre-2024 fallback.yhqp-riqs geometry), recovering ~1,050,000 of ~1,350,000 tickets — far
more, and more accurately, than the lossy address join.data/enforcement.json stores, 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.json
via scripts/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)
.github/workflows/monitor.yml