Context

I keep leaving for rides and discovering mid-route that a road is closed for construction or an event, then improvising a detour. The wish: upload my planned GPX (the same file I’d send to a Wahoo Roam or Garmin Edge) to a small web app and find out before clipping in whether the route runs into any active closures.

This is a personal-utility app first. It doesn’t need to be polished, multi-user, or pretty — it needs to answer one question: “will I hit any closures on this route today?”

Scope

GeographyDurham Region (where I actually ride). Expansion to GTA / Ontario possible later; not goals for v0.
OutputA yes/no status + sidebar list of conflicts. No map rendering in the MVP.
HostingFrontend on Firebase Hosting (already in use). Backend as a Cloud Run Service. Cron jobs as Cloud Run Jobs.
UsersJust me to start.

The decision to skip map rendering for the MVP is deliberate — it removes a whole class of frontend work (mapping libraries, tile providers, GeoJSON layer management) without losing answer quality.

High-level architecture

[ source-specific fetcher ]   ← API call / scrape / RSS, depending on source
        ↓ Cloud Run Job (hourly cron)
[ raw_records (BigQuery, append-only, partitioned by source+date) ]
        ↓ Cloud Run Job (normalizer)
[ closures (BigQuery, canonical schema, latest-per-source_id) ]
        ↓ derived view
[ active_closures (currently active or starting within N days) ]
        ↓
[ Cloud Run Service: GET /closures?bbox=... ]
        ↓
[ Static frontend on Firebase Hosting ]
   1. Upload GPX
   2. Parse to GeoJSON LineString (@tmcw/togeojson)
   3. Compute bbox + buffer the trace by ~15m (turf.js)
   4. Fetch active closures in bbox via API
   5. Intersect buffered trace against each closure geometry
   6. Render: "✅ clear" or "🚨 N closures" + cards listing each

This shape reuses the Cloud Run Jobs + BigQuery pattern from the Python cron jobs scaffold — same compute, different tables. The new piece is the Cloud Run Service (not Job) for the API, which is cheap (scales to zero between requests).

Canonical closure schema

The same source-agnostic shape regardless of how many feeds we end up wiring in:

closures
─────────────────
id                 # stable: hash(source, source_id)
source             # "durham-region" | "ontario-511" | ...
source_id          # original ID in the feed (for idempotency)
closure_type       # full | partial | lane | construction | event
geometry           # GeoJSON LineString or Polygon
                   #   (Point + radius is a fallback when only a road
                   #    name exists and we have to geocode)
road_name          # human-readable
description        # source's own description text
start_time         # ISO timestamp
end_time           # ISO timestamp, nullable for indefinite
affects_cyclists   # boolean, derived heuristic — most feeds don't tag this
severity           # null for v1
raw_payload        # JSONB of the original record (kept forever)
fetched_at         # ingestion timestamp

Keeping raw and normalized side-by-side means a schema change in the source — or a bug in the normalizer — can be reprocessed against history without re-fetching.

Data sources — the unresolved problem

This is the hard part, and the bottleneck for the whole project.

Durham’s open data portal (opendata.durham.ca, ArcGIS Hub, permissive Open Data License) publishes 385 datasets including the road network, Primary Cycling Network, AADT counts, and cycle tour routes. It does not currently publish real-time road closures.

Durham’s Traffic Watch map (apps.durham.ca/Applications/Traffic/TrafficWatch) displays exactly the data I want — but it’s backed by Municipal 511, a SaaS run by Transnomis Solutions. Their terms of service explicitly prohibit redistribution and reverse-engineering of the service or its data. The municipality owns the data; the vendor owns the delivery mechanism.

Three plausible paths, in order of cleanliness:

  1. Ask Durham to publish closures via their open data portal. They own the data; nothing stops them from offering a Feature Service the same way they do for the cycling network. A polite, specific request to the open data team is the right first move and costs nothing. Expected timeline: weeks to months, uncertain outcome.
  2. Use Ontario 511’s documented public API for provincial highway closures that pass through Durham (Hwy 7, 7A, 35/115). Real and lawful but covers only a narrow slice of where I actually ride.
  3. Audit lower-tier municipal open data portals (Oshawa, Whitby, Ajax, Pickering, Clarington) to see if any publish closures independently of the regional Traffic Watch. Coverage varies wildly; worth ~30 minutes of survey work.

The first path is the most valuable but least controllable. The second is the most immediately achievable but limited. Practical plan: pursue 1 and 3 in parallel, and accept that the MVP might cover a smaller slice of geography than ideal until 1 lands.

Frontend (sidebar-only UI)

A single HTML page, no SPA framework needed:

  • File input — accepts .gpx.
  • Parse with @tmcw/togeojson (~10KB, browser-side).
  • Compute bbox; buffer the trace by ~15m with turf.js to absorb GPS noise.
  • GET /closures?bbox=lon1,lat1,lon2,lat2&active_at=<now> → JSON list.
  • For each closure, turf.booleanIntersects(bufferedTrace, closure.geometry).
  • Render either “✅ Your route looks clear” or “🚨 3 closures on your route” with a card per intersecting closure (road name, type, description, end time).

That’s the entire frontend at MVP — ~100 lines of JavaScript and no map.

Backend (Cloud Run Service)

A small FastAPI or Django service exposing one endpoint, deployed alongside the cron jobs. Reads from the active_closures view in BigQuery and returns features intersecting the supplied bbox.

BigQuery is fine for MVP scale (hundreds of active records at any time, ST_INTERSECTS available). Migrate to PostGIS only if/when query latency becomes a real complaint.

Open questions

  • Will Durham publish closures via open data on request? — pending email
  • What do lower-tier Durham municipalities offer? — pending audit
  • Do I need map matching for v0, or is buffering the raw GPX trace enough? — likely “buffer is enough” since the MVP only needs intersection, not “describe where on the route”
  • Should the frontend be public (connor-sheehan.com/closures/) or private (Firebase Auth)? — depends on the data-source path: if we end up using only openly-licensed feeds, public is fine; otherwise private until that resolves

Adjacent ideas worth knowing about

  • Komoot surfaces some closure warnings on routes, mostly trail/path. Patchy in this geography.
  • Strava routing, Ride with GPS, BikeMap — don’t meaningfully expose closure data.
  • Google Maps cycling directions — uses closure data internally but doesn’t accept your own GPX as input.

The “upload your own planned GPX and check it for closures” niche is genuinely underserved.

Next steps

  1. Send the email to Durham’s open-data team asking for road closures to be added to the portal.
  2. Audit Pickering / Whitby / Oshawa / Ajax / Clarington open data portals (~30 min) — note formats, license, update frequency.
  3. Wire up Ontario 511 as the first data source — well-documented public API, simplest adapter, fastest path to a working pipeline end-to-end even if the cycling-relevant coverage is partial.
  4. Once one source is flowing into the canonical schema, the rest is mechanical: more adapters, same shape.