Waterloo Region pothole tracker. Report a pothole, confirm others, and track it through to resolution.
Live at fillthehole.ca
Potholes in Kitchener, Waterloo, and Cambridge often sit unfilled for weeks. This app gives residents a way to:
- Report a pothole at their GPS location (or search by address / drop a pin), with an optional photo
- Confirm reports from others — 2 independent confirmations are required before a pothole goes live on the public map
- Watch potholes you care about — saved locally in your browser, no account needed
- Celebrate when the city finally fills it
Each pothole page shows the ward councillor's contact info and a direct link to submit a service request. The /stats page tracks ward accountability grades, resolution times, and fill rate trends. All data is available as open data feeds for journalists and researchers.
- SvelteKit + TypeScript + Svelte 5 runes
- Tailwind CSS v4 via
@tailwindcss/vite - Supabase — Postgres with Row Level Security
- Leaflet +
leaflet.markerclusterfor the map - @fontsource/barlow-condensed for local OG image font loading
- @sentry/sveltekit — error tracking (server + client)
- Netlify for deployment
- Node 22+
- A Supabase project (free tier works)
git clone https://github.com/BreakableHoodie/filltheholedotca.git
cd filltheholedotca
npm install
cp .env.example .env
# Fill in your Supabase credentials in .envRun the migration files against your Supabase project in order:
schema.sql— initial tablesschema_update.sql— confirmation systemschema_actions.sql— pothole_actions table + increment_confirmation RPCschema_admin.sql— admin users, sessions, trusted devicesschema_photos.sql— photo uploadsschema_photo_publishing.sql— per-pothole photo publishing toggleschema_site_settings.sql— site settings table + 3-param increment_confirmation RPCschema_pr61_fixes.sql— RLS hardeningschema_security_hardening.sql— revoke public RPC access, deferred photo statusschema_sprint3.sql— pothole expiry pg_cron jobs, drop public pothole_actions SELECTschema_pushover_settings.sql— Pushover notification toggles (default: all enabled)schema_hits.sql— "I Hit This" signal tableschema_push.sql— web push subscription storageschema_push_unsubscribe_ratelimit.sql— push unsubscribe rate limit scopeschema_review_fixes.sql— RLS hardening; drops public read onpothole_confirmationsschema_pothole_reported_at.sql— addsreported_atcolumn + redefinesincrement_confirmationto stamp it, so polling can detectpending → reportedtransitionsschema_grants.sql— explicit Data APIGRANTstatements foranon/service_roleon every public-schema tableschema_votes.sql—pothole_votestable for community upvote/downvote signalschema_vote_ratelimit.sql— addsvote_submitscope toapi_rate_limit_eventsconstraintschema_votes_ttl.sql— pg_cron purge job forpothole_votesolder than 90 daysschema_polling_indexes.sql— partial indexes onfilled_at/expired_atfor the/api/potholes/recentpoll filter (#205)schema_revoke_public_writes.sql— security: drop leftover public write RLS policies + revoke anon/authenticated write grants (writes are service-role only) (#200)
npm run devApp runs at http://localhost:5173.
| Variable | Description |
|---|---|
PUBLIC_SUPABASE_URL |
Supabase project URL |
PUBLIC_SUPABASE_ANON_KEY |
Supabase anon key (public) |
SUPABASE_SERVICE_ROLE_KEY |
Server-only Supabase key for admin/moderation routes |
SIGHTENGINE_API_USER |
Image moderation — optional |
SIGHTENGINE_API_SECRET |
Image moderation — optional |
SIGHTENGINE_WORKFLOW_ID |
SightEngine workflow ID for automated moderation rules — optional |
IP_HASH_SECRET |
Server-only HMAC key for immediate IP hashing on ingestion |
ADMIN_SESSION_SECRET |
32-byte hex key for signing admin CSRF tokens |
TOTP_ENCRYPTION_KEY |
32-byte hex AES-GCM key for encrypting TOTP secrets at rest |
ADMIN_BOOTSTRAP_SECRET |
One-time secret for creating the first admin account — see section below |
PUSHOVER_APP_TOKEN |
Pushover app token — optional, disables push notifications if absent |
PUSHOVER_USER_KEY |
Pushover user/group key — required alongside PUSHOVER_APP_TOKEN |
PUBLIC_SENTRY_DSN |
Sentry project DSN — optional, omit to disable error tracking |
VAPID_PUBLIC_KEY |
VAPID public key for web push — generate with npx web-push generate-vapid-keys |
VAPID_PRIVATE_KEY |
VAPID private key for web push — server-only |
PUBLIC_VAPID_PUBLIC_KEY |
Same as VAPID_PUBLIC_KEY, exposed to the browser for subscription UI |
BLUESKY_HANDLE |
Bluesky account handle for the bot — optional (e.g. fillthehole.bsky.social) |
BLUESKY_APP_PASSWORD |
Bluesky app password — generate in Settings → Privacy → App Passwords |
DISABLE_API_RATE_LIMIT |
Set to any value to disable rate limiting in dev/test (never in production) |
See .env.example for the full list with generation instructions. Pushover notification categories (photos, community events, security alerts) can be toggled on/off per-category from Admin → Settings → Site without a deployment.
Before any admin users exist, /admin/signup enters bootstrap mode, allowing you to create the first admin account without a manual database invite.
- Generate a strong random secret:
openssl rand -hex 32
- Add it to your
.env:ADMIN_BOOTSTRAP_SECRET=<the generated value> - Visit
/admin/signup— you will see the bootstrap form. - Enter your details and the bootstrap secret to create the first admin account (active immediately, no activation step required).
- Once the first admin exists,
/admin/signupautomatically switches to invite-only mode. The bootstrap secret is ignored from that point on.
To prevent spam, a new report starts as pending and only becomes public after independent confirmations from different IPs. The confirmation threshold defaults to 2 and can be adjusted in site settings. IPs are HMAC-SHA-256 hashed immediately with a server-only secret — no raw addresses are ever stored.
pending → reported → filled
↓
expired (auto after 90 days with no action)
- pending — awaiting the confirmation threshold
- reported — live on the map, needs city attention
- filled — city patched it
- expired — auto-closed after 90 days with no fill event
The map includes an optional ward heatmap showing pothole density by ward across all three cities. Hovering a ward shows the councillor's name and the active hole count. Clicking through the pothole detail page lets you email the councillor directly.
Users can attach a photo when reporting or confirming a pothole. Photos are compressed client-side (max 800px, JPEG), then run through SightEngine automated moderation before landing in a queue for admin review. Photos are never shown publicly until an admin explicitly approves and publishes them per-pothole.
Potholes can be added to a personal watchlist stored in your browser's local storage — no account needed.
The pothole detail page has an "I hit this" button for drivers to signal they physically drove over the pothole. Hit counts are shown publicly and help surface high-impact holes.
Next to "I hit this," a "Prioritize" button lets residents flag that a pothole needs attention. It's upvote-only — a second tap removes your vote rather than casting a downvote — so the signal can't be used to suppress legitimate hazards.
The /stats page shows resolution time, fill rate trends, hotspot streets, and a letter-grade accountability score (A–F) per ward based on fill rate and average response time.
/api/feed.json— JSON feed of recent confirmed potholes/api/export.csv— full dataset download/api/feed.xml— RSS 2.0 feed of recent confirmations and fills/api/embed/[id]— embeddable iframe card for any pothole
When a pothole is confirmed or filled, the bot (@fillthehole.bsky.social) auto-posts to Bluesky. Requires BLUESKY_HANDLE and BLUESKY_APP_PASSWORD env vars — silently disabled if absent.
Users can subscribe to browser push notifications for fill events. Requires VAPID keys — silently disabled if absent.
Sentry captures server and client errors. Requires PUBLIC_SENTRY_DSN — silently disabled if absent.
Reports are restricted to the Waterloo Region boundary (lat 43.32–43.53, lng -80.59 to -80.22). Submissions outside that range are rejected.
- No accounts, no cookies, no tracking
- GPS coordinates are stored only to place the pin — no movement data
- IP addresses are HMAC-SHA-256 hashed on arrival and never stored in raw form
- Map tiles served by OpenStreetMap; reverse geocoding by Nominatim
See fillthehole.ca/about#privacy for the full policy.
npm run check # svelte-check type checking
npm run lint # ESLint
npm run test # Playwright E2E (tests/e2e/)
npm run test:a11y # axe-core accessibility checksCI runs the E2E suite against a self-contained fixture server (no real Supabase needed) with a single worker. Running the same suite locally needs a bit more setup, or a handful of tests fail for reasons that have nothing to do with the code under test:
ADMIN_SESSION_SECRET/TOTP_ENCRYPTION_KEYmust be non-empty. Both are optional fornpm run dev, but the admin-mfa and admin photo-upload E2E specs hard-fail (Zero-length key is not supported) without them. Generate real values (see.env.example) even if you're not using admin features locally.- Supabase-dependent tests need
PUBLIC_SUPABASE_URL/PUBLIC_SUPABASE_ANON_KEY/SUPABASE_SERVICE_ROLE_KEYexported in your shell, not just present in.env.playwright.config.ts'swebServer.envonly forwards these fromprocess.env; otherwise it injects a closed-port placeholder so Supabase-backed tests fail fast instead of hanging. - Kill anything already listening on port 4173 before running
npm run testlocally.reuseExistingServeristrueoutside CI — if a stalenpm run previewfrom an earlier session is still up, Playwright reuses it as-is instead of starting a fresh one with the fixture env (PLAYWRIGHT_E2E_FIXTURES=true,CI=true) injected, so tests silently fall through to real Supabase calls instead of the fixture store. - A handful of tests can still flake locally with real Supabase configured
(data-dependent assertions against whatever's actually in the table, plus
parallel workers racing on shared DB state — CI pins
workers: 1). Treat CI as the authoritative gate; a local failure that doesn't reproduce in CI usually isn't a real regression.
Issues and PRs welcome. If you're adding a feature, open an issue first to discuss it — this is a focused civic tool, not a general platform.
GNU Affero General Public License v3.0 (AGPL-3.0)
Open source. Anyone can use, modify, and run this — but if you distribute a modified version or run it as a hosted service, you must also publish your source code under the same license.