diff --git a/docs/WARD_LOOKUP_API_SPEC.md b/docs/WARD_LOOKUP_API_SPEC.md new file mode 100644 index 0000000..62db028 --- /dev/null +++ b/docs/WARD_LOOKUP_API_SPEC.md @@ -0,0 +1,199 @@ +# Postal code → ward lookup — backend spec + +The Toronto 2026 election page (`/toronto/elections/2026`) wants a "find your +ward" input: someone types a postal code, we show the ward they vote in and link +to `/toronto/elections/2026/wards/`. + +Everything needed already exists in york_factory except two things: **Toronto +ward geometry has never been ingested**, and **no endpoint joins a postal code +to a ward**. This doc covers both, plus one loader bug that blocks the ingest. + +## What already works + +Confirmed against production (`https://yorkfactory.buildcanada.com/api/v1`): + +- `warehouse.postal_codes` is populated with full 6-character codes and + lat/long. `GET /elections/toronto-2026/pledges/eligibility?postal_code=M4C1S9` + returns `{"eligible":true,"reason":"inside_boundary","city":"TORONTO"}`, which + only resolves that way if the postal record was found *and* its centroid + tested against PostGIS geometry. +- `Warehouse::GeoBoundary` holds `geography(MultiPolygon,4326)` geometry, and + `Warehouse::Election::PledgeEligibility#contains?` already does exactly the + `ST_Intersects` we need — just against `csd` rather than ward boundaries. +- `Warehouse::Source` `ward_toronto` is seeded (`db/seeds.rb:150`) pointing at + Toronto's 25-ward-model shapefile, with `BoundaryLoader` field mappings + already written for it (`uid: "AREA_S_CD"`, `name_en: "AREA_NAME"`, + `province_code: "35"`). + +## Blocker 1 — boundary_type mismatch (must fix before ingest) + +`BoundaryLoader::BOUNDARY_TYPE_MAP` maps the municipal ward sources to types +that `GeoBoundary`'s enum does not define: + +| source | `BOUNDARY_TYPE_MAP` emits | `GeoBoundary::BOUNDARY_TYPES` has | +|---|---|---| +| `ward_toronto` | `"med"` | `"ward"` | +| `sbw_tdsb`, `sbw_tcdsb`, `sbw_viamonde`, `sbw_monavenir` | `"sbed"` | `"school_board_ward"` | + +`import_shapefile` writes via `upsert_all`, which bypasses enum casting, so rows +would land in the table with `boundary_type = 'med'` and then be invisible to +`GeoBoundary.by_type("ward")` — and `by_type("med")` raises `ArgumentError`, +since the enum has no such member. Either rename the map values to `ward` / +`school_board_ward`, or add `med` / `sbed` to `BOUNDARY_TYPES`. **The map values +are the wrong ones** — `ward` is the documented type and what the crosswalk and +`geo/boundaries` callers already use. Note `code_system` derives from the same +string (`"#{boundary_type}_#{CENSUS_YEAR}"`), so it changes with it. + +Production currently has `boundary_type=ward` → `count: 0`, versus `csd` 5142, +`fsa` 1641, `fed` 343. The school board ward sources are presumably in the same +state. + +## Blocker 2 — geo_uid collision across cities + +`ward_toronto` takes `geo_uid` straight from `AREA_S_CD`, which is the +zero-padded ward number, `"01"`–`"25"`. The unique index is +`(boundary_type, geo_uid, census_year)`, so the first other municipality's ward +layer we load will collide on `"01"` and silently upsert over Toronto's ward 1. + +Brampton and Hamilton elections are already in the system, so this will bite. +Suggest a `uid_prefix` in `CUSTOM_FIELD_MAP` for `ward_toronto` — the mechanism +already exists and is used by the PED sources — keyed by CSD so it stays +meaningful: `"3520005-01"`. Whatever shape you choose, **the endpoint below must +return the bare ward number separately**, because the frontend routes on it. + +## Ingest + +Once the type mapping is fixed: + +```ruby +Warehouse::Source.find_by(name: "ward_toronto").fetcher.fetch +``` + +`geo:pipeline` already picks up `ward_%` sources, but the source is +`fetch_frequency: "manual"`, so this needs running deliberately. Expect 25 rows +at `boundary_type: "ward"`, `province_code: "35"`, `census_year` = the loader's +`CENSUS_YEAR`. + +Sanity check after loading — every ward present, no null geometry: + +``` +GET /api/v1/geo/boundaries?boundary_type=ward&province_code=ON +``` + +## The endpoint + +``` +GET /api/v1/geo/ward_lookup?postal_code=M4C1S9 +``` + +Add to the existing `namespace :geo` block in `config/routes.rb`, alongside +`crosswalk`, `boundaries`, `addresses`. Public and unauthenticated, matching the +rest of that namespace and the eligibility endpoint. + +`postal_code` is required and accepts any spacing/casing — +`Warehouse::PostalCode.normalize` already handles `"m4c1s9"`, `"M4C 1S9"`, etc. + +Optional `boundary_type` param defaulting to `ward`, so the same endpoint can +answer school board wards later without a second route. + +### Response — resolved + +```json +{ + "postal_code": "M4C 1S9", + "city": "TORONTO", + "found": true, + "reason": "resolved", + "ward": { + "geo_uid": "3520005-01", + "ward_number": 19, + "name_en": "Beaches-East York", + "boundary_type": "ward", + "census_year": 2018 + } +} +``` + +`ward_number` as an **integer**, parsed from the ward code — the frontend builds +`/toronto/elections/2026/wards/19` from it and the existing ward routes use +unpadded numbers. Don't make the client parse `"01"` out of a `geo_uid` whose +format we may change. + +### Response — not resolved + +Same envelope, `found: false`, `ward: null`, and a `reason` the client can +branch on. Please keep these as stable strings; the UI copy differs per case: + +| `reason` | meaning | UI intent | +|---|---|---| +| `malformed_postal_code` | failed `normalize` | "check what you typed" | +| `unknown_postal_code` | not in `warehouse.postal_codes` | "we don't recognize that code" | +| `outside_boundary` | geocoded fine, no ward contains it | "looks like you're outside Toronto" | +| `boundary_data_unavailable` | zero ward boundaries loaded | generic failure, and it's on us | + +This mirrors `PledgeEligibility::REASONS` and its `indeterminate?` distinction +between "we couldn't judge" and "we judged you outside" — worth reusing that +split rather than collapsing everything into `found: false`. + +HTTP status `200` for all of the above including `found: false` — these are +answers, not errors. `400` only for a missing `postal_code` param. + +### Implementation note + +The query is `PledgeEligibility#contains?` with the boundary scope swapped: + +```ruby +Warehouse::GeoBoundary + .by_type("ward") + .where.not(geometry: nil) + .where( + "ST_Intersects(geometry, ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography)", + lon: record.longitude, lat: record.latitude + ) + .order(census_year: :desc) + .first +``` + +Ordering by `census_year: :desc` matters once a post-2026 ward model lands +beside the 2018 one. If a point somehow matches two wards of the same vintage, +returning the first is fine — see accuracy below. + +Cache-friendly: response depends only on `postal_code`, so a long +`Cache-Control` is safe. The frontend will fetch through a Next route with ISR +either way. + +## Accuracy — please don't hide this + +A postal code's stored point is the centroid of its delivery points, so codes +straddling a ward line can resolve to the neighbouring ward. +`pledge_eligibility.rb` already measures this at 0.08%–0.9% of a city's codes +for municipal boundaries, and ward lines are far more numerous than city ones, +so the rate here will be higher. + +That's acceptable for this feature as long as the API doesn't overstate +certainty. The frontend will present the result as "looks like Ward 19" with a +link to browse all wards, never a hard redirect. If it's cheap, an optional +`distance_to_boundary_m` (via `ST_Distance` to the matched ward's boundary) +would let the UI say "this postal code sits on the edge of Wards 19 and 14" — +nice to have, not required for v1. + +## Out of scope + +- Address-level lookup. `warehouse.addresses` exists and `geo/addresses#index` + serves it, but production returns zero rows, so full-address autocomplete is + a separate project. +- School board wards. The endpoint should accept `boundary_type` so they slot in + later, but the `sbw_*` sources have the same loader bug and aren't needed now. +- Any change to the pledge flow. `PledgeEligibility` keeps using `csd` for + residency; this endpoint is read-only and independent. + +## Frontend contract summary + +What TradingPost needs, minimally: + +1. One unauthenticated GET taking a postal code in any format. +2. An integer ward number when resolved. +3. A distinguishable `reason` when not, so we can tell "typo" from "outside + Toronto" from "our data is down." + +Ping me when the endpoint is on staging and I'll wire up the input. diff --git a/next.config.ts b/next.config.ts index 1a0a862..2485710 100644 --- a/next.config.ts +++ b/next.config.ts @@ -52,6 +52,37 @@ const nextConfig: NextConfig = { destination: "/state-of-the-nation/:path*", permanent: true, }, + // Election coverage lives under /vote: the index at /vote, and each + // region at //vote/. Two earlier shapes are still out in the + // world — Toronto's /toronto/elections/2026 (indexed, and the target of + // shared pledge links) and Brampton's /elections/brampton/2026 — so both + // redirect. Each points straight at its final destination; none of these + // chain through another redirect. + { + source: "/elections", + destination: "/vote", + permanent: true, + }, + { + source: "/elections/brampton/2026", + destination: "/brampton/vote/2026", + permanent: true, + }, + { + source: "/elections/brampton/2026/:path*", + destination: "/brampton/vote/2026/:path*", + permanent: true, + }, + { + source: "/:city(toronto|brampton|hamilton|ottawa)/elections", + destination: "/:city/vote", + permanent: true, + }, + { + source: "/:city(toronto|brampton|hamilton|ottawa)/elections/:path*", + destination: "/:city/vote/:path*", + permanent: true, + }, ]; }, async rewrites() { diff --git a/public/elections/hamilton/2026/hamilton-stamp-og.png b/public/elections/hamilton/2026/hamilton-stamp-og.png new file mode 100644 index 0000000..4be7319 Binary files /dev/null and b/public/elections/hamilton/2026/hamilton-stamp-og.png differ diff --git a/scripts/gen-ward-geo.mjs b/scripts/gen-ward-geo.mjs new file mode 100644 index 0000000..1de23ae --- /dev/null +++ b/scripts/gen-ward-geo.mjs @@ -0,0 +1,205 @@ +// Generate a region's ward-map geometry (wardGeo.ts) from a GeoJSON boundary +// file published by that city's open-data portal. +// +// node scripts/gen-ward-geo.mjs \ +// --input ottawa-wards.geojson \ +// --out src/app/ottawa/elections/2026/wardGeo.ts \ +// --city Ottawa \ +// --source "https://open.ottawa.ca/datasets/ottawa::wards-2022-2026" \ +// --ward-field WARD --name-field NAME +// +// What it does, and why: +// · Projects lon/lat to a plain spherical Mercator, then fits the whole city +// into a 300-unit-wide viewBox. Locator maps are a few dozen pixels across, +// so the projection only has to look right, not measure right. +// · Simplifies each ring with Douglas–Peucker. Raw municipal boundaries carry +// survey-grade vertex counts (Ottawa's raw file is ~470 KB); at locator size +// that detail is invisible but would ship in every page's HTML. +// · Emits paths rounded to 1 decimal, plus each ward's projected centroid. +// +// Deliberately dependency-free — this runs by hand when a city redraws its +// wards (roughly once a term), so it isn't worth a d3 dependency in the app. + +import { readFileSync, writeFileSync } from "node:fs"; + +// ── Args ─────────────────────────────────────────────────────────────────── + +const args = {}; +for (let i = 2; i < process.argv.length; i += 2) { + args[process.argv[i].replace(/^--/, "")] = process.argv[i + 1]; +} + +const { + input, + out, + city, + source, + "ward-field": wardField = "WARD", + "name-field": nameField = "NAME", + width: widthArg = "300", + tolerance: toleranceArg = "0.35", +} = args; + +if (!input || !out || !city) { + console.error( + "usage: node scripts/gen-ward-geo.mjs --input --out --city [--source ] [--ward-field WARD] [--name-field NAME] [--width 300] [--tolerance 0.35]", + ); + process.exit(1); +} + +const WIDTH = Number(widthArg); +/** Douglas–Peucker tolerance, in projected units (same space as WIDTH). */ +const TOLERANCE = Number(toleranceArg); + +// ── Projection ───────────────────────────────────────────────────────────── + +/** Spherical Mercator, in radians-ish units; scaled to the viewBox below. */ +function mercator([lon, lat]) { + const x = (lon * Math.PI) / 180; + const y = Math.log(Math.tan(Math.PI / 4 + (lat * Math.PI) / 360)); + return [x, y]; +} + +/** Every ring of a Polygon / MultiPolygon, as arrays of [lon, lat]. */ +function ringsOf(geometry) { + if (geometry.type === "Polygon") return geometry.coordinates; + if (geometry.type === "MultiPolygon") return geometry.coordinates.flat(); + throw new Error(`unsupported geometry: ${geometry.type}`); +} + +// ── Simplification ───────────────────────────────────────────────────────── + +/** Perpendicular distance from p to the segment ab. */ +function pointToSegment(p, a, b) { + const [px, py] = p; + const [ax, ay] = a; + const [bx, by] = b; + const dx = bx - ax; + const dy = by - ay; + if (dx === 0 && dy === 0) return Math.hypot(px - ax, py - ay); + const t = Math.max( + 0, + Math.min(1, ((px - ax) * dx + (py - ay) * dy) / (dx * dx + dy * dy)), + ); + return Math.hypot(px - (ax + t * dx), py - (ay + t * dy)); +} + +/** Douglas–Peucker, iterative so a dense ring can't blow the stack. */ +function simplify(points, tolerance) { + if (points.length < 3) return points; + const keep = new Uint8Array(points.length); + keep[0] = keep[points.length - 1] = 1; + + const stack = [[0, points.length - 1]]; + while (stack.length > 0) { + const [first, last] = stack.pop(); + let maxDist = -1; + let index = -1; + for (let i = first + 1; i < last; i++) { + const dist = pointToSegment(points[i], points[first], points[last]); + if (dist > maxDist) { + maxDist = dist; + index = i; + } + } + if (maxDist > tolerance && index !== -1) { + keep[index] = 1; + stack.push([first, index], [index, last]); + } + } + + return points.filter((_, i) => keep[i]); +} + +// ── Build ────────────────────────────────────────────────────────────────── + +const geojson = JSON.parse(readFileSync(input, "utf8")); + +const wards = geojson.features + .map((feature) => { + const props = feature.properties; + const number = parseInt(props[wardField], 10); + if (Number.isNaN(number)) { + throw new Error( + `feature has no numeric "${wardField}": ${JSON.stringify(props).slice(0, 200)}`, + ); + } + return { + number, + name: String(props[nameField]).trim(), + rings: ringsOf(feature.geometry).map((ring) => ring.map(mercator)), + }; + }) + .sort((a, b) => a.number - b.number); + +// Fit every ward into the viewBox with one uniform scale, so shapes stay true. +const all = wards.flatMap((w) => w.rings.flat()); +const minX = Math.min(...all.map((p) => p[0])); +const maxX = Math.max(...all.map((p) => p[0])); +const minY = Math.min(...all.map((p) => p[1])); +const maxY = Math.max(...all.map((p) => p[1])); + +const scale = WIDTH / (maxX - minX); +const height = Math.round((maxY - minY) * scale * 10) / 10; + +/** Projected space → viewBox space. SVG y grows downward, so y is flipped. */ +function toViewBox([x, y]) { + return [(x - minX) * scale, (maxY - y) * scale]; +} + +const round = (n) => Math.round(n * 10) / 10; + +const shapes = wards.map((ward) => { + const rings = ward.rings + .map((ring) => simplify(ring.map(toViewBox), TOLERANCE)) + // A ring simplified below a triangle no longer encloses anything; islands + // and river slivers land here and are dropped rather than drawn as spikes. + .filter((ring) => ring.length >= 4); + + const d = rings + .map( + (ring) => + `M${ring + .map(([x, y]) => `${round(x)} ${round(y)}`) + .join("L")}Z`, + ) + .join(""); + + // Area-weighted centroid over the largest ring — good enough to hang a label. + const largest = rings.reduce( + (best, ring) => (ring.length > best.length ? ring : best), + rings[0] ?? [], + ); + const cx = round(largest.reduce((sum, p) => sum + p[0], 0) / largest.length); + const cy = round(largest.reduce((sum, p) => sum + p[1], 0) / largest.length); + + return { n: String(ward.number).padStart(2, "0"), name: ward.name, d, cx, cy }; +}); + +const file = `// AUTO-GENERATED — do not edit by hand. +// ${city} council ward boundaries, projected (spherical Mercator, fitted to the +// viewBox) and simplified (Douglas–Peucker) for a compact locator map. +${source ? `// Source: ${source}\n` : ""}// Regenerate with scripts/gen-ward-geo.mjs — see that file for the command. + +import type { WardGeo, WardShape } from "@/components/elections/WardMap"; + +export const WARD_MAP_VIEWBOX = "0 0 ${WIDTH} ${height}"; + +export const WARD_SHAPES: WardShape[] = ${JSON.stringify(shapes, null, 2)}; + +/** Everything needs to draw ${city}. The id namespaces the shared + * geometry, so it must be unique across regions. */ +export const WARD_GEO: WardGeo = { + id: "${city.toLowerCase()}-ward-map", + viewBox: WARD_MAP_VIEWBOX, + shapes: WARD_SHAPES, + regionLabel: "City of ${city}", +}; +`; + +writeFileSync(out, file); + +const bytes = Buffer.byteLength(file); +console.log( + `${out}: ${shapes.length} wards, viewBox 0 0 ${WIDTH} ${height}, ${(bytes / 1024).toFixed(1)} KB`, +); diff --git a/src/app/api/elections/ward-lookup/route.ts b/src/app/api/elections/ward-lookup/route.ts new file mode 100644 index 0000000..e6afb4d --- /dev/null +++ b/src/app/api/elections/ward-lookup/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { API_URL } from "@/lib/api/client"; + +// Thin proxy for york_factory's public ward lookup, so the browser never needs +// the API base URL and the response can be cached at our edge. +// +// The postal code is forwarded exactly as typed: york_factory normalizes it and +// distinguishes "not a postal code" from "not in our data", which we'd lose by +// validating here. +// +// Cache-Control comes from upstream rather than being set here. The TTL varies +// by outcome on purpose — a day for a resolved ward, an hour for an unknown +// postal code (a real new code may appear in a later import), no-store for an +// upstream data outage — and second-guessing it would cache the wrong things. +export async function GET(req: NextRequest) { + const postalCode = req.nextUrl.searchParams.get("postal_code")?.trim(); + + if (!postalCode) { + return NextResponse.json( + { error: "postal_code is required" }, + { status: 400 }, + ); + } + + const url = new URL(`${API_URL}/geo/ward_lookup`); + url.searchParams.set("postal_code", postalCode); + + try { + const res = await fetch(url, { cache: "no-store" }); + const body = await res.text(); + + return new NextResponse(body, { + status: res.status, + headers: { + "Content-Type": "application/json", + "Cache-Control": res.headers.get("Cache-Control") ?? "no-store", + }, + }); + } catch (error) { + // A network failure reaching york_factory is ours, not a lookup outcome, so + // it stays a 5xx rather than borrowing the boundary_data_unavailable reason. + console.error("[ward-lookup] upstream unreachable:", error); + return NextResponse.json( + { error: "Ward lookup is unavailable" }, + { status: 502 }, + ); + } +} diff --git a/src/app/brampton/vote/2026/data.ts b/src/app/brampton/vote/2026/data.ts new file mode 100644 index 0000000..f029103 --- /dev/null +++ b/src/app/brampton/vote/2026/data.ts @@ -0,0 +1,38 @@ +// Brampton 2026 municipal election — this region's binding of the shared +// election data layer (@/lib/elections/election-data). +// +// Brampton has no hand-maintained enrichment and no local fallback roster, so +// this is thin: the ward roster, its names and its counts are all derived from +// the API. +// +// The one shape worth knowing: Brampton's ten wards are paired into five +// districts, and each district elects BOTH a city councillor and a regional +// councillor. So a ward page here lists two council races where Toronto's and +// Hamilton's list one — the shared ward page handles that on its own. + +import { + getElectionView, + getWardDetail, + type ElectionView, + type WardDetail, +} from "@/lib/elections/election-data"; +import { getElection } from "@/lib/elections/registry"; + +export const ELECTION = getElection("brampton-2026"); + +/** Nominations close at 2pm on the API's `nomination_close_date` — the time + * isn't in the payload, so it lives here as page copy. */ +export const NOMINATION_CLOSE_TIME = "2 p.m."; + +/** The election, or null when York Factory is unreachable — there is no local + * fallback roster for Brampton, so the page says so rather than invent one. */ +export function getBrampton2026(): Promise { + return getElectionView(ELECTION.slug); +} + +/** One ward's races, or null for a ward outside 1–10 (or an API outage). */ +export function getBrampton2026Ward( + wardToken: string, +): Promise { + return getWardDetail(ELECTION.slug, wardToken); +} diff --git a/src/app/brampton/vote/2026/page.tsx b/src/app/brampton/vote/2026/page.tsx new file mode 100644 index 0000000..b93ab63 --- /dev/null +++ b/src/app/brampton/vote/2026/page.tsx @@ -0,0 +1,79 @@ +import type { Metadata } from "next"; +import { ElectionLanding } from "@/components/elections/ElectionLanding"; +import { ELECTION, NOMINATION_CLOSE_TIME, getBrampton2026 } from "./data"; + +export const metadata: Metadata = { + title: "Brampton 2026 Election", + description: + "Brampton elects its mayor, five city councillors, five regional councillors and its school board trustees on October 26, 2026. See every registered candidate, ward by ward.", + alternates: { canonical: ELECTION.basePath }, + openGraph: { + title: "Brampton 2026 Election — Build Canada", + description: + "Every race and every registered candidate in Brampton's 2026 municipal election.", + type: "website", + }, +}; + +export default async function Brampton2026ElectionPage() { + const view = await getBrampton2026(); + + // No local fallback roster for Brampton, so an API outage says so plainly + // rather than rendering a page that looks like an empty field. + if (view === null) { + return ( +
+
+

+ The 2026 Brampton Municipal Election +

+

+ The candidate list is temporarily unavailable. It comes from the + City of Brampton’s registered-candidate listing — please check + back shortly. +

+
+
+ ); + } + + return ( + + On October 26, 2026, Brampton elects a mayor and, in each of + its five districts, both a city councillor and a regional councillor + — two council seats on every ballot. Explore who is running for + mayor and for council in your ward. + + ), + wardsBlurb: + "Ten wards, paired into five districts. Select your ward to see both council races you vote in, plus the school board trustees who share the district.", + closingHeadline: ( + <>The Brampton you know is possible doesn’t vote itself in. + ), + closingBlurb: ( + <> + Brampton votes {ELECTION.voteDayLabel}. Add your name — then bring + someone with you. + + ), + sourceNote: ( + <> + Candidates come from the City of Brampton’s official + registered-candidate listing and are updated daily, so someone who + filed today may not appear until tomorrow. Withdrawn candidates stay + listed, as the city lists them. + {view.nominationCloseLabel + ? ` The field is not final until nominations close on ${view.nominationCloseLabel} at ${NOMINATION_CLOSE_TIME}.` + : " The field is not final until nominations close."} + + ), + }} + /> + ); +} diff --git a/src/app/elections/brampton/2026/pledge/PledgeClient.tsx b/src/app/brampton/vote/2026/pledge/PledgeClient.tsx similarity index 100% rename from src/app/elections/brampton/2026/pledge/PledgeClient.tsx rename to src/app/brampton/vote/2026/pledge/PledgeClient.tsx diff --git a/src/app/elections/brampton/2026/pledge/[slug]/SharedPledgeClient.tsx b/src/app/brampton/vote/2026/pledge/[slug]/SharedPledgeClient.tsx similarity index 100% rename from src/app/elections/brampton/2026/pledge/[slug]/SharedPledgeClient.tsx rename to src/app/brampton/vote/2026/pledge/[slug]/SharedPledgeClient.tsx diff --git a/src/app/elections/brampton/2026/pledge/[slug]/opengraph-image.tsx b/src/app/brampton/vote/2026/pledge/[slug]/opengraph-image.tsx similarity index 100% rename from src/app/elections/brampton/2026/pledge/[slug]/opengraph-image.tsx rename to src/app/brampton/vote/2026/pledge/[slug]/opengraph-image.tsx diff --git a/src/app/elections/brampton/2026/pledge/[slug]/page.tsx b/src/app/brampton/vote/2026/pledge/[slug]/page.tsx similarity index 100% rename from src/app/elections/brampton/2026/pledge/[slug]/page.tsx rename to src/app/brampton/vote/2026/pledge/[slug]/page.tsx diff --git a/src/app/elections/brampton/2026/pledge/brampton-stamp.png b/src/app/brampton/vote/2026/pledge/brampton-stamp.png similarity index 100% rename from src/app/elections/brampton/2026/pledge/brampton-stamp.png rename to src/app/brampton/vote/2026/pledge/brampton-stamp.png diff --git a/src/app/elections/brampton/2026/pledge/og-template.tsx b/src/app/brampton/vote/2026/pledge/og-template.tsx similarity index 100% rename from src/app/elections/brampton/2026/pledge/og-template.tsx rename to src/app/brampton/vote/2026/pledge/og-template.tsx diff --git a/src/app/elections/brampton/2026/pledge/opengraph-image.tsx b/src/app/brampton/vote/2026/pledge/opengraph-image.tsx similarity index 100% rename from src/app/elections/brampton/2026/pledge/opengraph-image.tsx rename to src/app/brampton/vote/2026/pledge/opengraph-image.tsx diff --git a/src/app/elections/brampton/2026/pledge/page.tsx b/src/app/brampton/vote/2026/pledge/page.tsx similarity index 91% rename from src/app/elections/brampton/2026/pledge/page.tsx rename to src/app/brampton/vote/2026/pledge/page.tsx index 10713cc..3456505 100644 --- a/src/app/elections/brampton/2026/pledge/page.tsx +++ b/src/app/brampton/vote/2026/pledge/page.tsx @@ -5,7 +5,7 @@ export const metadata: Metadata = { title: "I Pledge to Vote — Brampton 2026", description: "You pledged to vote in Brampton's 2026 municipal election. Here's your ballot for Monday, October 26, 2026.", - alternates: { canonical: "/elections/brampton/2026/pledge" }, + alternates: { canonical: "/brampton/vote/2026/pledge" }, openGraph: { title: "I Pledge to Vote — Brampton 2026 | Build Canada", description: diff --git a/src/app/brampton/vote/2026/wards/[ward]/page.tsx b/src/app/brampton/vote/2026/wards/[ward]/page.tsx new file mode 100644 index 0000000..468c762 --- /dev/null +++ b/src/app/brampton/vote/2026/wards/[ward]/page.tsx @@ -0,0 +1,54 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { WardDetail } from "@/components/elections/WardDetail"; +import { ELECTION, getBrampton2026, getBrampton2026Ward } from "../../data"; + +/** Brampton's ten wards. Listed here so the routes prerender without a build + * -time API call; a ward the API doesn't know about still 404s at request + * time via getBrampton2026Ward. */ +const WARDS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]; + +export function generateStaticParams() { + return WARDS.map((ward) => ({ ward })); +} + +export async function generateMetadata({ + params, +}: { + params: Promise<{ ward: string }>; +}): Promise { + const { ward } = await params; + const data = await getBrampton2026Ward(ward); + if (!data) return { title: "Ward not found" }; + return { + title: `Ward ${data.ward.n} — Brampton 2026 Election`, + description: `The council races in Ward ${data.ward.n} for Brampton's October 26, 2026 municipal election — the city councillor, the regional councillor, and the school board trustees on this ballot.`, + alternates: { canonical: `${ELECTION.basePath}/wards/${data.ward.n}` }, + openGraph: { + title: `Ward ${data.ward.n} — Brampton 2026 Election`, + description: `Candidates running in Ward ${data.ward.n}.`, + type: "website", + }, + }; +} + +export default async function BramptonWardPage({ + params, +}: { + params: Promise<{ ward: string }>; +}) { + const { ward } = await params; + const [data, view] = await Promise.all([ + getBrampton2026Ward(ward), + getBrampton2026(), + ]); + if (!data) notFound(); + + return ( + + ); +} diff --git a/src/app/elections/brampton/2026/RaceList.tsx b/src/app/elections/brampton/2026/RaceList.tsx deleted file mode 100644 index 299f342..0000000 --- a/src/app/elections/brampton/2026/RaceList.tsx +++ /dev/null @@ -1,206 +0,0 @@ -"use client"; - -import { useMemo, useState } from "react"; -import { ArrowUpRight } from "lucide-react"; -import type { CandidateView, RaceView } from "./data"; - -/** - * Every race, with Brampton's 1–10 ward filter. Selecting a ward shows the - * races that ward votes in — its city councillor, regional councillor and - * trustees — plus every at-large race (mayor, the two French-board trustees), - * which everyone votes in. - */ -export default function RaceList({ - races, - wards, - nominationCloseLabel, -}: { - races: RaceView[]; - wards: number[]; - nominationCloseLabel: string | null; -}) { - const [ward, setWard] = useState(null); - - const visible = useMemo( - () => - ward === null - ? races - : races.filter((race) => race.atLarge || race.wardNumbers?.includes(ward)), - [races, ward], - ); - - return ( - <> - {/* ── Ward filter ──────────────────────────────────────── */} -
-

- Filter by ward -

-
- setWard(null)}> - All wards - - {wards.map((n) => ( - setWard(n)}> - {n} - - ))} -
-

- {ward === null - ? `${races.length} races on the ballot` - : `${visible.length} races on a Ward ${ward} ballot`} -

-
- - {/* ── Races ────────────────────────────────────────────── */} - {visible.map((race) => ( - - ))} - - ); -} - -function WardButton({ - active, - onClick, - children, -}: { - active: boolean; - onClick: () => void; - children: React.ReactNode; -}) { - return ( - - ); -} - -function Race({ - race, - nominationCloseLabel, -}: { - race: RaceView; - nominationCloseLabel: string | null; -}) { - const registered = race.candidates.filter((c) => !c.withdrawn).length; - - return ( -
-
-
- {race.officeBody && ( -

{race.officeBody}

- )} -

- {race.label} -

-
-

- {registered} {registered === 1 ? "candidate" : "candidates"} - {race.atLarge && " · all wards vote"} -

-
- - {race.candidates.length === 0 ? ( -

- No one has filed for this seat yet. - {nominationCloseLabel - ? ` Nominations close ${nominationCloseLabel} — check back as candidates register.` - : " Check back as candidates register."} -

- ) : ( -
    - {race.candidates.map((candidate) => ( - - ))} -
- )} -
- ); -} - -function Candidate({ candidate }: { candidate: CandidateView }) { - return ( -
  • - {/* No upstream portraits yet — initials stand in. */} - -
    -
    -

    - {candidate.name} -

    - {candidate.withdrawn && ( - - Withdrawn - - )} -
    - {candidate.socialLinks.length > 0 && ( -
    - {candidate.socialLinks.map((link) => ( - - {socialLabel(link.name)} - - ))} -
    - )} -
    - {candidate.website ? ( - - Campaign site - - - ) : ( - - No site listed - - )} -
  • - ); -} - -/** `social_links[].name` is an open vocabulary ("web", "facebook", "tiktok", - * …), so unknown names are title-cased rather than dropped. */ -function socialLabel(name: string): string { - if (name.toLowerCase() === "web") return "Website"; - return name.charAt(0).toUpperCase() + name.slice(1); -} diff --git a/src/app/elections/brampton/2026/data.ts b/src/app/elections/brampton/2026/data.ts deleted file mode 100644 index 0a2849b..0000000 --- a/src/app/elections/brampton/2026/data.ts +++ /dev/null @@ -1,210 +0,0 @@ -// Brampton 2026 municipal election — data layer. -// -// One call does it all: GET /elections/brampton-2026 returns every race and -// candidate (~21 races / ~71 candidates today), so this module fetches it -// once and reshapes it for rendering. See york_factory/docs/api/elections.md. -// -// Notable shape differences from Toronto: Brampton's 10 wards are paired into -// 5 districts, each electing BOTH a city councillor and a regional -// councillor — so a race is identified by (office_type, office_body, -// district_number), never by district alone. There is no local fallback -// roster and no photos upstream; both are handled in the UI. - -import { differenceInCalendarDays } from "date-fns"; -import { fetchElection, type ApiCandidate, type ApiRace } from "@/lib/api/elections"; - -export const ELECTION_SLUG = "brampton-2026"; - -/** Brampton's ward numbers, for the ward filter. The API exposes wards only - * per-district (`ward_numbers`), so the filter is built from the races - * themselves; this is the fallback if none carry ward numbers. */ -const FALLBACK_WARDS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; - -/** Nominations close at 2pm on the API's `nomination_close_date` — the time - * isn't in the payload, so it lives here as page copy. */ -export const NOMINATION_CLOSE_TIME = "2 p.m."; - -export type CandidateView = { - /** stable within a race — `full_name` is unique there (no candidate IDs) */ - key: string; - /** display name: "First Last", or the surname alone when mononymous */ - name: string; - initials: string; - withdrawn: boolean; - website: string | null; - socialLinks: { name: string; url: string }[]; -}; - -export type RaceView = { - /** (office_type, office_body, district) — the real race identity */ - id: string; - /** e.g. "City Councillor — Wards 1, 5" */ - label: string; - /** e.g. "Brampton City Council"; null for mayor */ - officeBody: string | null; - /** e.g. "Wards 1, 5"; null when at-large */ - districtName: string | null; - /** wards this race covers; null when at-large (shown for every ward) */ - wardNumbers: number[] | null; - atLarge: boolean; - candidates: CandidateView[]; -}; - -export type BramptonElection = { - name: string; - electionDateIso: string; - /** e.g. "Mon, Oct 26, 2026" */ - electionDateLabel: string; - /** e.g. "Aug 21, 2026"; null when unpublished */ - nominationCloseLabel: string | null; - daysUntil: number; - races: RaceView[]; - raceCount: number; - /** registered (non-withdrawn) candidates across every race */ - candidateCount: number; - /** ward numbers offered by the ward filter, ascending */ - wards: number[]; -}; - -const LONG_DATE = new Intl.DateTimeFormat("en-CA", { - weekday: "short", - month: "short", - day: "numeric", - year: "numeric", -}); - -const SHORT_DATE = new Intl.DateTimeFormat("en-CA", { - month: "short", - day: "numeric", - year: "numeric", -}); - -/** Parse "YYYY-MM-DD" as local midnight, so labels and day math don't shift - * a day the way `new Date(iso)` does in negative-offset timezones. */ -function parseDateOnly(iso: string): Date { - const [y, m, d] = iso.split("-").map(Number); - return new Date(y, m - 1, d); -} - -/** - * Display name. `first_name` is null for mononymous candidates, so render the - * parts we have rather than the published "Last, First" `full_name`. - */ -function displayName(candidate: ApiCandidate): string { - const parts = [candidate.first_name, candidate.last_name].filter(Boolean); - if (parts.length > 0) return parts.join(" "); - // Last resort: un-invert "Last, First". - const [last, first] = candidate.full_name.split(",").map((s) => s.trim()); - return first ? `${first} ${last}` : candidate.full_name; -} - -/** Initials for the portrait placeholder — every Brampton `photo_url` is null - * today, so this is what actually renders. */ -function initialsFor(name: string): string { - const parts = name.trim().split(/\s+/).filter(Boolean); - const first = parts[0]?.[0] ?? ""; - const last = parts.length > 1 ? parts[parts.length - 1][0] : ""; - return (first + last).toUpperCase(); -} - -/** - * The seat being contested, e.g. "City Councillor". The API hands over the - * parts, not the sentence: a councillor race is a city or a regional seat - * depending only on `office_body`, which is the body the page shows above the - * heading — so it isn't repeated here. - */ -function seatName(race: ApiRace): string { - if (race.office_type === "mayor") return "Mayor"; - if (race.office_type === "trustee") return "Trustee"; - if (race.office_type === "councillor") { - return /region/i.test(race.office_body ?? "") - ? "Regional Councillor" - : "City Councillor"; - } - // mp/mpp — future federal/provincial elections. - return race.office_type.toUpperCase(); -} - -/** The race heading: the seat plus its district, e.g. - * "City Councillor — Wards 1, 5". At-large races carry no district. */ -function raceLabel(race: ApiRace): string { - const seat = seatName(race); - return race.district_name ? `${seat} — ${race.district_name}` : seat; -} - -/** Wards a race covers. `ward_numbers` is authoritative; single-ward races - * (Toronto-style) carry the ward in `district_number` instead. */ -function wardsFor(race: ApiRace): number[] | null { - if (race.ward_numbers && race.ward_numbers.length > 0) return race.ward_numbers; - if (race.district_type !== "at_large" && race.district_number !== null) { - return [race.district_number]; - } - return null; -} - -function toRaceView(race: ApiRace): RaceView { - // Withdrawn candidates stay listed (Brampton keeps them) but sort last; - // the API already orders candidates by last name within each group. - const candidates = [...race.candidates] - .sort((a, b) => Number(a.status === "withdrawn") - Number(b.status === "withdrawn")) - .map((candidate): CandidateView => { - const name = displayName(candidate); - return { - key: candidate.full_name, - name, - initials: initialsFor(name), - withdrawn: candidate.status === "withdrawn", - website: candidate.website, - socialLinks: candidate.social_links ?? [], - }; - }); - - return { - id: [race.office_type, race.office_body ?? "", race.district_number ?? "at-large"].join( - "|", - ), - label: raceLabel(race), - officeBody: race.office_body, - districtName: race.district_name, - wardNumbers: wardsFor(race), - atLarge: race.district_type === "at_large", - candidates, - }; -} - -/** - * The Brampton 2026 election, reshaped for the page — or null when the API is - * unreachable (there is no local fallback roster for Brampton). - * - * Races are returned in API order (mayor → councillor → trustee, then by body, - * then by district), which is the order the page renders. - */ -export async function getBramptonElection(): Promise { - const election = await fetchElection(ELECTION_SLUG); - if (!election) return null; - - const races = election.races.map(toRaceView); - const wards = [ - ...new Set(races.flatMap((race) => race.wardNumbers ?? [])), - ].sort((a, b) => a - b); - - return { - name: election.name, - electionDateIso: election.election_date, - electionDateLabel: LONG_DATE.format(parseDateOnly(election.election_date)), - nominationCloseLabel: election.nomination_close_date - ? SHORT_DATE.format(parseDateOnly(election.nomination_close_date)) - : null, - daysUntil: Math.max( - 0, - differenceInCalendarDays(parseDateOnly(election.election_date), new Date()), - ), - races, - raceCount: races.length, - candidateCount: races.reduce( - (total, race) => total + race.candidates.filter((c) => !c.withdrawn).length, - 0, - ), - wards: wards.length > 0 ? wards : FALLBACK_WARDS, - }; -} diff --git a/src/app/elections/brampton/2026/page.tsx b/src/app/elections/brampton/2026/page.tsx deleted file mode 100644 index 0824d5b..0000000 --- a/src/app/elections/brampton/2026/page.tsx +++ /dev/null @@ -1,186 +0,0 @@ -import type { Metadata } from "next"; -import { Suspense } from "react"; -import Link from "next/link"; -import { ArrowRight } from "lucide-react"; -import { PledgeButton } from "@/components/elections/PledgeButton"; -import { ResidencyModal } from "@/components/elections/ResidencyModal"; -import RaceList from "./RaceList"; -import { getBramptonElection, NOMINATION_CLOSE_TIME } from "./data"; - -export const metadata: Metadata = { - title: "Brampton 2026 Election", - description: - "Brampton elects its mayor, five city councillors, five regional councillors and its school board trustees on October 26, 2026. See every registered candidate, filtered by ward.", - alternates: { canonical: "/elections/brampton/2026" }, - openGraph: { - title: "Brampton 2026 Election — Build Canada", - description: - "Every race and every registered candidate in Brampton's 2026 municipal election.", - type: "website", - }, -}; - -/** One number-plus-label cell in the stat band. */ -function Stat({ - value, - label, - className, -}: { - value: string; - label: string; - className?: string; -}) { - return ( -
    -
    - {value} -
    -
    - {label} -
    -
    - ); -} - -export default async function Brampton2026ElectionPage() { - const election = await getBramptonElection(); - - return ( -
    - - - - - {/* ── Breadcrumb ───────────────────────────────────────── */} -
    - - Elections - - / - Brampton 2026 -
    - - {/* ── Hero ─────────────────────────────────────────────── */} -
    -

    - Municipal Election · City of Brampton -

    -

    - Brampton 2026 Election -

    -

    - On October 26, 2026 Brampton elects a mayor and, in each of - its five districts, both a city councillor and a regional councillor — - two seats per ballot, plus the school board trustees who share the same - districts. Below is every race, with every candidate registered so far. - Pick your ward to see just the races you vote in. -

    - - Pledge to vote - - -
    - - {election === null ? ( -
    -

    - The candidate list is temporarily unavailable. It comes from the - City of Brampton’s registered-candidate listing — please check - back shortly. -

    -
    - ) : ( - <> - {/* ── Stat band ────────────────────────────────────── */} -
    - - - -
    -
    -
    - {election.electionDateLabel} -
    -
    -
    - Election day -
    -
    -
    - - {/* ── Races ────────────────────────────────────────── */} -
    -
    -

    - Who’s running -

    -

    - {election.nominationCloseLabel - ? `Nominations close ${election.nominationCloseLabel} at ${NOMINATION_CLOSE_TIME}, so the field is still growing.` - : "The field is still growing."}{" "} - Brampton’s ten wards are paired into five districts — - voters in each district elect both a city and a regional - councillor. -

    -
    - - -
    - - {/* ── Closing CTA ──────────────────────────────────── */} -
    -

    - The Brampton you know is possible doesn’t vote itself in. -

    -

    - Brampton votes {election.electionDateLabel}. Add your name — then - bring someone with you. -

    - - Pledge to vote - - -
    - - {/* ── Source note ──────────────────────────────────── */} -
    -

    - Candidates come from the City of Brampton’s official - registered-candidate listing and are updated daily, so someone who - filed today may not appear until tomorrow. Withdrawn candidates - stay listed, as the city lists them. The field is not final until - nominations close. -

    -
    - - )} -
    - ); -} diff --git a/src/app/hamilton/vote/2026/data.ts b/src/app/hamilton/vote/2026/data.ts new file mode 100644 index 0000000..94fc706 --- /dev/null +++ b/src/app/hamilton/vote/2026/data.ts @@ -0,0 +1,28 @@ +// Hamilton 2026 municipal election — this region's binding of the shared +// election data layer (@/lib/elections/election-data). +// +// Hamilton is the simplest shape we cover: fifteen wards, one councillor each, +// so the ward roster, names and counts all come straight from the API. There +// is no hand-maintained enrichment and no local fallback roster. + +import { + getElectionView, + getWardDetail, + type ElectionView, + type WardDetail, +} from "@/lib/elections/election-data"; +import { getElection } from "@/lib/elections/registry"; + +export const ELECTION = getElection("hamilton-2026"); + +/** The election, or null when York Factory is unreachable. */ +export function getHamilton2026(): Promise { + return getElectionView(ELECTION.slug); +} + +/** One ward's races, or null for a ward outside 1–15 (or an API outage). */ +export function getHamilton2026Ward( + wardToken: string, +): Promise { + return getWardDetail(ELECTION.slug, wardToken); +} diff --git a/src/app/hamilton/vote/2026/page.tsx b/src/app/hamilton/vote/2026/page.tsx new file mode 100644 index 0000000..e657dc1 --- /dev/null +++ b/src/app/hamilton/vote/2026/page.tsx @@ -0,0 +1,77 @@ +import type { Metadata } from "next"; +import { ElectionLanding } from "@/components/elections/ElectionLanding"; +import { ELECTION, getHamilton2026 } from "./data"; + +export const metadata: Metadata = { + title: "Hamilton 2026 Election", + description: + "Hamilton elects its mayor, fifteen ward councillors and its school board trustees on October 26, 2026. See every registered candidate, ward by ward.", + alternates: { canonical: ELECTION.basePath }, + openGraph: { + title: "Hamilton 2026 Election — Build Canada", + description: + "Tracking every race in Hamilton's 2026 municipal election: the candidates for mayor and the 15 council wards.", + type: "website", + }, +}; + +export default async function Hamilton2026ElectionPage() { + const view = await getHamilton2026(); + + // No local fallback roster for Hamilton, so an API outage says so plainly + // rather than rendering a page that looks like an empty field. + if (view === null) { + return ( +
    +
    +

    + The 2026 Hamilton Municipal Election +

    +

    + The candidate list is temporarily unavailable. It comes from the + City of Hamilton’s registered-candidate listing — please check + back shortly. +

    +
    +
    + ); + } + + return ( + + On October 26, 2026, Hamilton will elect its mayor and fifteen + ward councillors. Explore who is running for mayor and for + councillor in your ward. + + ), + wardsBlurb: + "Fifteen wards, fifteen council races. Select a ward to see the candidates running to represent it, plus the school board trustees on the same ballot.", + closingHeadline: ( + <>The Hamilton you know is possible doesn’t vote itself in. + ), + closingBlurb: ( + <> + Hamilton votes {ELECTION.voteDayLabel}. Add your name — then bring + someone with you. + + ), + sourceNote: ( + <> + Candidates come from the City of Hamilton’s official + registered-candidate listing and are updated daily, so someone who + filed today may not appear until tomorrow. + {view.nominationCloseLabel + ? ` The field is not final until nominations close on ${view.nominationCloseLabel}.` + : " The field is not final until nominations close."} + + ), + }} + /> + ); +} diff --git a/src/app/hamilton/vote/2026/pledge/PledgeClient.tsx b/src/app/hamilton/vote/2026/pledge/PledgeClient.tsx new file mode 100644 index 0000000..2c329a2 --- /dev/null +++ b/src/app/hamilton/vote/2026/pledge/PledgeClient.tsx @@ -0,0 +1,76 @@ +"use client"; + +import dynamic from "next/dynamic"; +import Link from "next/link"; +import { ArrowRight } from "lucide-react"; +import { PledgeButton } from "@/components/elections/PledgeButton"; +import { getElection } from "@/lib/elections/registry"; +import stampImage from "./hamilton-stamp.png"; + +const ELECTION = getElection("hamilton-2026"); + +const StampScene = dynamic(() => import("@/components/elections/StampScene"), { + ssr: false, + loading: () => ( +
    + + Printing your stamp… + +
    + ), +}); + +export default function PledgeClient({ + region, +}: { + /** e.g. "ward-5" from a ward-scoped pledge link; defaults to city-wide */ + region?: string; +}) { + return ( +
    + {/* ── The stamp, full bleed ────────────────────────────── */} +
    + +
    + + {/* ── Overlaid header ──────────────────────────────────── */} +
    +
    +

    {ELECTION.eyebrow}

    +

    + My pledge to vote. +

    +
    + +
    +

    + {ELECTION.cityLabel} votes {ELECTION.voteDayLabel} +

    + + Pledge to vote + + +
    +
    + + {/* ── Overlaid footer ──────────────────────────────────── */} +
    + + Explore the Candidates + + +

    + Drag the stamp around · Polls open {ELECTION.pollHoursLabel} +

    +
    +
    + ); +} diff --git a/src/app/hamilton/vote/2026/pledge/[slug]/SharedPledgeClient.tsx b/src/app/hamilton/vote/2026/pledge/[slug]/SharedPledgeClient.tsx new file mode 100644 index 0000000..75e3a86 --- /dev/null +++ b/src/app/hamilton/vote/2026/pledge/[slug]/SharedPledgeClient.tsx @@ -0,0 +1,107 @@ +"use client"; + +import dynamic from "next/dynamic"; +import Link from "next/link"; +import { useState } from "react"; +import { ArrowLeft, ArrowRight, Check, Link2 } from "lucide-react"; +import { getElection } from "@/lib/elections/registry"; +import stampImage from "../hamilton-stamp.png"; + +const ELECTION = getElection("hamilton-2026"); + +const StampScene = dynamic(() => import("@/components/elections/StampScene"), { + ssr: false, + loading: () => ( +
    + + Printing the stamp… + +
    + ), +}); + +export default function SharedPledgeClient({ name }: { name: string }) { + const [copied, setCopied] = useState(false); + + async function share() { + const url = window.location.href; + if (navigator.share) { + try { + await navigator.share({ + title: `${name} pledged to vote — Hamilton 2026`, + url, + }); + return; + } catch { + // fall through to the clipboard if the user dismissed the sheet + } + } + await navigator.clipboard.writeText(url); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + + return ( +
    + {/* ── The stamp, full bleed ────────────────────────────── */} +
    + +
    + + {/* ── Overlaid header ──────────────────────────────────── */} +
    +
    +

    {ELECTION.eyebrow}

    +

    + {name} pledged to vote. +

    +
    + + {/* ── Share + join in ────────────────────────────────── */} +
    +

    + {ELECTION.cityLabel} votes {ELECTION.voteDayLabel} +

    + + Pledge to vote too + + + +
    +
    + + {/* ── Overlaid footer ──────────────────────────────────── */} +
    + + + Back to the election tracker + +

    + Drag the stamp around · Polls open {ELECTION.pollHoursLabel} +

    +
    +
    + ); +} diff --git a/src/app/hamilton/vote/2026/pledge/[slug]/opengraph-image.tsx b/src/app/hamilton/vote/2026/pledge/[slug]/opengraph-image.tsx new file mode 100644 index 0000000..b08a980 --- /dev/null +++ b/src/app/hamilton/vote/2026/pledge/[slug]/opengraph-image.tsx @@ -0,0 +1,25 @@ +import { ImageResponse } from "next/og"; +import { resolvePledgeName } from "@/lib/elections/pledge-record"; +import { OG_SIZE, PledgeOGImage, stampDataUri, logoDataUri } from "../og-template"; + +export const alt = "A pledge to vote in Hamilton's 2026 municipal election"; +export const size = OG_SIZE; +export const contentType = "image/png"; + +/* Query params aren't available to OG image routes; the name resolves from + the pledge record via the slug's share token, else from the slug itself. */ +export default async function Image({ + params, +}: { + params: Promise<{ slug: string }>; +}) { + const { slug } = await params; + return new ImageResponse( + , + { ...size }, + ); +} diff --git a/src/app/hamilton/vote/2026/pledge/[slug]/page.tsx b/src/app/hamilton/vote/2026/pledge/[slug]/page.tsx new file mode 100644 index 0000000..23d54c2 --- /dev/null +++ b/src/app/hamilton/vote/2026/pledge/[slug]/page.tsx @@ -0,0 +1,36 @@ +import type { Metadata } from "next"; +import { resolvePledgeName } from "@/lib/elections/pledge-record"; +import SharedPledgeClient from "./SharedPledgeClient"; + +type Props = { + params: Promise<{ slug: string }>; + searchParams: Promise<{ n?: string }>; +}; + +export async function generateMetadata({ + params, + searchParams, +}: Props): Promise { + const { slug } = await params; + const { n } = await searchParams; + const name = await resolvePledgeName("hamilton-2026", slug, n); + return { + title: `${name} pledged to vote — Hamilton 2026`, + description: `${name} is on the record for Hamilton's 2026 municipal election, Monday, October 26. Will you be?`, + openGraph: { + title: `${name} pledged to vote — Hamilton 2026 | Build Canada`, + description: `${name} pledged to vote in Hamilton's 2026 municipal election on October 26, 2026. Join them on the record.`, + type: "website", + }, + }; +} + +export default async function SharedPledgePage({ params, searchParams }: Props) { + const { slug } = await params; + const { n } = await searchParams; + return ( + + ); +} diff --git a/src/app/toronto/elections/2026/pledge/hamilton-stamp.png b/src/app/hamilton/vote/2026/pledge/hamilton-stamp.png similarity index 100% rename from src/app/toronto/elections/2026/pledge/hamilton-stamp.png rename to src/app/hamilton/vote/2026/pledge/hamilton-stamp.png diff --git a/src/app/hamilton/vote/2026/pledge/og-template.tsx b/src/app/hamilton/vote/2026/pledge/og-template.tsx new file mode 100644 index 0000000..f8ac5b0 --- /dev/null +++ b/src/app/hamilton/vote/2026/pledge/og-template.tsx @@ -0,0 +1,69 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { + CityLockup, + PledgeOGImage as PledgeOG, + type PledgeOGTheme, +} from "@/components/elections/pledge-og"; + +export { OG_SIZE, logoDataUri } from "@/components/elections/pledge-og"; + +/* Hamilton's binding of the shared pledge OG template + (@/components/elections/pledge-og): the site's default linen-and-auburn + palette, the Hamilton stamp, and the nav lockup in the corner. */ + +const PAPER = "#f6ece3"; +const AUBURN = "#932f2f"; + +const HAMILTON_THEME: PledgeOGTheme = { + paper: PAPER, + dark: "#272727", + ink: AUBURN, + muted: "#4c4c4c", + postmarkDate: "26.10.2026", + postmarkCity: "HAMILTON · #02026", + voteDayLine: "Hamilton votes Monday, October 26", +}; + +/* The stamp artwork lives under public/ — the only asset directory shipped + into the production (Docker) runtime image, where this renders on demand for + shared-pledge URLs. Reading from src/ here 500's in production. */ +export async function stampDataUri(): Promise { + try { + const data = await readFile( + join(process.cwd(), "public/elections/hamilton/2026/hamilton-stamp-og.png"), + "base64", + ); + return `data:image/png;base64,${data}`; + } catch { + return ""; // degraded stamp-less image beats a broken share card + } +} + +export function PledgeOGImage({ + stampSrc, + name, + logoSrc = "", +}: { + stampSrc: string; + /** when set, the stamp is postmarked and the headline names the pledger */ + name?: string; + /** data URI from logoDataUri(); the corner lockup falls back to text without it */ + logoSrc?: string; +}) { + return ( + + } + /> + ); +} diff --git a/src/app/hamilton/vote/2026/pledge/opengraph-image.tsx b/src/app/hamilton/vote/2026/pledge/opengraph-image.tsx new file mode 100644 index 0000000..5047f94 --- /dev/null +++ b/src/app/hamilton/vote/2026/pledge/opengraph-image.tsx @@ -0,0 +1,16 @@ +import { ImageResponse } from "next/og"; +import { OG_SIZE, PledgeOGImage, stampDataUri, logoDataUri } from "./og-template"; + +export const alt = "I pledge to vote — Hamilton's 2026 municipal election"; +export const size = OG_SIZE; +export const contentType = "image/png"; + +export default async function Image() { + return new ImageResponse( + , + { ...size }, + ); +} diff --git a/src/app/hamilton/vote/2026/pledge/page.tsx b/src/app/hamilton/vote/2026/pledge/page.tsx new file mode 100644 index 0000000..b18e21d --- /dev/null +++ b/src/app/hamilton/vote/2026/pledge/page.tsx @@ -0,0 +1,24 @@ +import type { Metadata } from "next"; +import PledgeClient from "./PledgeClient"; + +export const metadata: Metadata = { + title: "I Pledge to Vote — Hamilton 2026", + description: + "You pledged to vote in Hamilton's 2026 municipal election. Here's your ballot for Monday, October 26, 2026.", + alternates: { canonical: "/hamilton/vote/2026/pledge" }, + openGraph: { + title: "I Pledge to Vote — Hamilton 2026 | Build Canada", + description: + "Pledge to vote in Hamilton's 2026 municipal election on October 26, 2026.", + type: "website", + }, +}; + +export default async function PledgePage({ + searchParams, +}: { + searchParams: Promise<{ region?: string }>; +}) { + const { region } = await searchParams; + return ; +} diff --git a/src/app/hamilton/vote/2026/wards/[ward]/page.tsx b/src/app/hamilton/vote/2026/wards/[ward]/page.tsx new file mode 100644 index 0000000..ece4750 --- /dev/null +++ b/src/app/hamilton/vote/2026/wards/[ward]/page.tsx @@ -0,0 +1,54 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { WardDetail } from "@/components/elections/WardDetail"; +import { ELECTION, getHamilton2026, getHamilton2026Ward } from "../../data"; + +/** Hamilton's fifteen wards. Listed here so the routes prerender without a + * build-time API call; a ward the API doesn't know about still 404s at + * request time via getHamilton2026Ward. */ +const WARDS = Array.from({ length: 15 }, (_, i) => String(i + 1)); + +export function generateStaticParams() { + return WARDS.map((ward) => ({ ward })); +} + +export async function generateMetadata({ + params, +}: { + params: Promise<{ ward: string }>; +}): Promise { + const { ward } = await params; + const data = await getHamilton2026Ward(ward); + if (!data) return { title: "Ward not found" }; + return { + title: `Ward ${data.ward.n} — Hamilton 2026 Election`, + description: `The council race in Ward ${data.ward.n} for Hamilton's October 26, 2026 municipal election. See every candidate registered to represent it.`, + alternates: { canonical: `${ELECTION.basePath}/wards/${data.ward.n}` }, + openGraph: { + title: `Ward ${data.ward.n} — Hamilton 2026 Election`, + description: `Candidates for councillor in Ward ${data.ward.n}.`, + type: "website", + }, + }; +} + +export default async function HamiltonWardPage({ + params, +}: { + params: Promise<{ ward: string }>; +}) { + const { ward } = await params; + const [data, view] = await Promise.all([ + getHamilton2026Ward(ward), + getHamilton2026(), + ]); + if (!data) notFound(); + + return ( + + ); +} diff --git a/src/app/ottawa/vote/2026/data.ts b/src/app/ottawa/vote/2026/data.ts new file mode 100644 index 0000000..ca85584 --- /dev/null +++ b/src/app/ottawa/vote/2026/data.ts @@ -0,0 +1,59 @@ +// Ottawa 2026 municipal election — this region's binding of the shared +// election data layer (@/lib/elections/election-data). +// +// Ottawa is wired ahead of its roster: York Factory has no ottawa-2026 yet, so +// getOttawa2026() returns null today and the page renders its pre-roster state +// (see ./page). The moment the election appears upstream, the full landing and +// ward pages light up with no further changes here. +// +// Ward names and the zero-padded route tokens ("01".."24") come from the City +// of Ottawa's published ward boundaries in ./wardGeo, which also draw the +// locator map — the same arrangement Toronto uses. + +import { + getElectionView, + getWardDetail, + type ElectionDataOptions, + type ElectionView, + type WardDetail, + type WardRosterEntry, + type WardView, +} from "@/lib/elections/election-data"; +import { getElection } from "@/lib/elections/registry"; +import { WARD_SHAPES } from "./wardGeo"; + +export const ELECTION = getElection("ottawa-2026"); + +/** Zero-padded ward numbers ("01".."24") for static route generation. */ +export const WARD_NUMBERS: string[] = WARD_SHAPES.map((w) => w.n); + +const WARD_ROSTER: WardRosterEntry[] = WARD_SHAPES.map((w) => ({ + n: w.n, + number: parseInt(w.n, 10), + name: w.name, +})); + +const OPTIONS: ElectionDataOptions = { wardRoster: WARD_ROSTER }; + +/** + * The wards as the city draws them, with no candidate counts — what the page + * shows before the roster is published. `count` is zero here and the pre-roster + * page suppresses it rather than claiming nobody has registered. + */ +export const WARDS: WardView[] = WARD_ROSTER.map((ward) => ({ + ...ward, + count: 0, +})); + +/** The election, or null until York Factory publishes ottawa-2026. */ +export function getOttawa2026(): Promise { + return getElectionView(ELECTION.slug, OPTIONS); +} + +/** One ward's races, or null for a ward outside 1–24 (or before the roster + * is published). */ +export function getOttawa2026Ward( + wardToken: string, +): Promise { + return getWardDetail(ELECTION.slug, wardToken, OPTIONS); +} diff --git a/src/app/ottawa/vote/2026/page.tsx b/src/app/ottawa/vote/2026/page.tsx new file mode 100644 index 0000000..adb0657 --- /dev/null +++ b/src/app/ottawa/vote/2026/page.tsx @@ -0,0 +1,168 @@ +import type { Metadata } from "next"; +import { ElectionLanding } from "@/components/elections/ElectionLanding"; +import { WardCard } from "@/components/elections/WardCard"; +import { WardMap, WardMapDefs } from "@/components/elections/WardMap"; +import CountdownDays from "@/components/elections/CountdownDays"; +import { daysUntil } from "@/lib/elections/dates"; +import { WARD_GEO } from "./wardGeo"; +import { ELECTION, WARDS, getOttawa2026 } from "./data"; + +export const metadata: Metadata = { + title: "Ottawa 2026 Election", + description: + "Ottawa elects its mayor and 24 ward councillors on October 26, 2026. Find your ward and, once nominations open, everyone running to represent it.", + alternates: { canonical: ELECTION.basePath }, + openGraph: { + title: "Ottawa 2026 Election — Build Canada", + description: + "Tracking Ottawa's 2026 municipal election: the race for mayor and all 24 council wards.", + type: "website", + }, +}; + +export default async function Ottawa2026ElectionPage() { + const view = await getOttawa2026(); + + // Ottawa's ward boundaries are published; its candidate roster is not yet. + // Rather than hide the page, show the wards the city has drawn and say + // plainly that the field is still to come. + if (view === null) return ; + + return ( + } + renderWardMap={(ward) => ( + + )} + content={{ + heroTitle: "The 2026 Ottawa Municipal Election", + heroBlurb: ( + <> + On October 26, 2026, Ottawa will elect its mayor and 24 ward + councillors. Explore who is running for mayor and for councillor in + your ward. + + ), + wardsBlurb: + "Twenty-four wards, twenty-four council races. Select a ward to see the candidates running to represent it.", + closingHeadline: ( + <>The Ottawa you know is possible doesn’t vote itself in. + ), + closingBlurb: ( + <> + Ottawa votes {ELECTION.voteDayLabel}. Add your name — then bring + someone with you. + + ), + sourceNote: ( + <> + Candidates come from the City of Ottawa’s official + registered-candidate listing and are updated daily. + {view.nominationCloseLabel + ? ` The field is not final until nominations close on ${view.nominationCloseLabel}.` + : " The field is not final until nominations close."}{" "} + Ward boundaries are the City’s own 2022–2026 wards. + + ), + }} + /> + ); +} + +/** The page before Ottawa's candidate roster is published: the countdown, the + * ward map, and an honest note about what isn't here yet. */ +function PreRoster() { + return ( +
    +
    + {/* ── Hero ─────────────────────────────────────────────── */} +
    +

    + The 2026 Ottawa Municipal Election +

    +

    + On October 26, 2026, Ottawa will elect its mayor and 24 ward + councillors. Nominations haven’t opened yet — find your ward + below, and we’ll fill in the field as candidates register. +

    +
    + + {/* ── Countdown ────────────────────────────────────────── */} +
    +
    + + + Days until +
    + polls open +
    +
    +

    + Polls open{" "} + {ELECTION.voteDayLabel}, 2026 + , {ELECTION.pollHoursLabel}. +

    +
    + + {/* ── Wards ────────────────────────────────────────────── */} +
    +
    +
    +

    City Council

    +

    + Find your ward +

    +

    + Twenty-four wards, twenty-four council races. These are the + City’s 2022–2026 ward boundaries, the ones the 2026 + election will be run on. +

    +
    +

    + {WARDS.length} wards +

    +
    + + +
    + {WARDS.map((ward) => ( + + } + className="border-b border-r border-border-light" + /> + ))} +
    +
    + + {/* ── Source note ──────────────────────────────────────── */} +
    +

    + Ward boundaries are the City of Ottawa’s published 2022–2026 + wards. The candidate list will follow the City Clerk’s official + registered-candidate listing once nominations open. +

    +
    +
    +
    + ); +} diff --git a/src/app/ottawa/vote/2026/wardGeo.ts b/src/app/ottawa/vote/2026/wardGeo.ts new file mode 100644 index 0000000..f0ec044 --- /dev/null +++ b/src/app/ottawa/vote/2026/wardGeo.ts @@ -0,0 +1,189 @@ +// AUTO-GENERATED — do not edit by hand. +// Ottawa council ward boundaries, projected (spherical Mercator, fitted to the +// viewBox) and simplified (Douglas–Peucker) for a compact locator map. +// Source: https://open.ottawa.ca/datasets/ottawa::wards-2022-2026 +// Regenerate with scripts/gen-ward-geo.mjs — see that file for the command. + +import type { WardGeo, WardShape } from "@/components/elections/WardMap"; + +export const WARD_MAP_VIEWBOX = "0 0 300 221.1"; + +export const WARD_SHAPES: WardShape[] = [ + { + "n": "01", + "name": "Orléans East-Cumberland", + "d": "M276.3 4.7L278.7 10.7L273.6 12.7L273 12.1L271.8 12.5L271.2 11.7L267.6 13.1L262.3 13.8L259 13.7L258.5 13.2L255.4 14.6L248.1 15L245.2 16.3L248.3 24.1L233.3 30L231.9 28.7L230.1 28.7L226.7 25.8L226.6 25.1L225.2 24.9L224.5 23.8L223.9 24.1L223.8 23.6L226.6 22.1L224.2 16L242.1 7.2L249.5 6.2L252.3 6.4L254.8 5.6L260.8 4.9L274.4 0L276.3 4.7Z", + "cx": 249.9, + "cy": 15.5 + }, + { + "n": "02", + "name": "Orléans West-Innes", + "d": "M224.3 16L226.6 22.1L223.8 23.6L223.9 24.1L224.5 23.8L225.2 24.9L226.6 25.1L226.7 25.8L230.1 28.7L231.9 28.7L233.3 30L220.9 36.7L219.5 39.5L213.5 42.9L210.1 43L205.6 45.4L204.5 42.7L202.9 40.7L206.6 37.5L209.5 31.8L210.5 31L210.2 30.6L210.9 30.5L210.3 28.9L210.7 28.2L210.1 27.6L211.2 27.5L210 26.6L211.5 26.1L211.7 25.3L212.5 25.3L213 24.3L212.3 23.5L215.1 21.1L218.7 19.8L224.3 16Z", + "cx": 216.5, + "cy": 29 + }, + { + "n": "03", + "name": "Barrhaven West", + "d": "M153.5 94.1L155.7 99.6L161.9 95.9L164.8 102.7L166.9 101.4L168 103.7L167.8 105.2L168.2 106L169.3 104.8L170.2 105.2L171.4 104.6L173.6 106.8L175.6 106.6L175.8 111L174.3 111.7L174 114L164.1 119.6L161.2 113.5L156.6 111.9L155.3 110.8L152.9 105.1L148.9 98.5L148.9 96.3L150.5 94.3L152.9 92.9L153.5 94.1Z", + "cx": 162.9, + "cy": 104.2 + }, + { + "n": "04", + "name": "Kanata North", + "d": "M112.8 63.7L116 67.2L117.2 66.1L122 71.5L127.1 83.5L120.1 89.2L115.2 92L111.3 95.5L109.3 93.2L112.8 90.1L112.1 89.3L114.2 87.5L116.2 88.6L117.1 87.8L115.5 87.5L112.9 86L110.4 83.8L109.7 82.2L109.6 79.8L110.2 78.3L112.8 75.9L109.3 72L111.9 69.7L111.8 69.2L110.3 68.1L109.7 68.7L107.8 66.2L109.5 64.6L110.5 65.8L112.8 63.7Z", + "cx": 113.3, + "cy": 78.2 + }, + { + "n": "05", + "name": "West Carleton-March", + "d": "M71.7 6.9L74.1 8.2L78.5 12.1L82.3 14.4L94 20.3L98.4 21.7L106.2 27L109.3 30L116.5 42.7L119.1 46.6L121.8 49.1L110.9 59.1L117.2 66.1L116 67.2L112.8 63.7L110.5 65.8L109.5 64.6L107.8 66.2L109.7 68.7L110.3 68.1L111.8 69.2L111.9 69.7L109.3 72L112.8 75.9L110 78.7L109.6 80.2L109.9 82.9L112.9 86L115.5 87.5L117.1 87.8L116.2 88.6L114.2 87.5L112.1 89.3L112.8 90.1L109.3 93.2L111.3 95.5L107.8 98.6L110.7 101.9L104.1 108L104.3 110.7L103.4 112.9L84.9 129.4L84.2 131.4L83.6 138.6L82.6 140.9L39.2 90.2L38.2 91.1L0 49.3L12 38.3L12 32.6L15.4 28.1L16.8 27.5L21.9 27.8L23.5 27L31.4 26.7L32.1 11.6L33.3 10.3L39.3 8.1L43.5 7.4L53.8 8.4L57.7 7.1L62.3 7.2L67.9 6.1L71.7 6.9Z", + "cx": 84.5, + "cy": 59.2 + }, + { + "n": "06", + "name": "Stittsville", + "d": "M127.4 97.5L129.6 100L131.6 101.2L124 107.9L125.6 109.7L120 114.6L119.8 114L118.9 114.9L109.1 103.5L110.7 101.9L107.8 98.6L114.6 92.4L117.2 90.8L118.5 92.2L127.4 97.5Z", + "cx": 120.1, + "cy": 102.5 + }, + { + "n": "07", + "name": "Bay", + "d": "M143 75.1L140.1 76L127.1 83.5L122 71.5L110.9 59.1L121.8 49.1L131.7 58.9L140.1 62.9L146.2 63.4L153.5 60.1L158.1 55L159.1 52.8L160.6 55.9L161.7 56.7L160.4 57.8L161.6 60.9L162.2 60.7L162.6 61.7L163.8 60.9L164.3 62.1L161.4 65.9L151.8 73.2L143 75.1Z", + "cx": 148.1, + "cy": 63.4 + }, + { + "n": "08", + "name": "College", + "d": "M129.2 88.9L127.1 83.5L140.3 75.9L151.8 73.2L161.4 65.9L164.3 62.1L170.1 76.2L147.8 79.3L148.9 84.8L152.1 90.5L152.9 92.9L150.5 94.3L149.1 95.8L143.9 98.7L143.7 98L140.6 99.8L136.3 89.3L130.6 92.6L129.2 88.9Z", + "cx": 145.8, + "cy": 85.8 + }, + { + "n": "09", + "name": "Knoxdale-Merivale", + "d": "M176.2 71.4L177 71.6L178.3 74.7L177.6 77.1L178.7 78.2L177.4 80.1L178.5 83.8L177.8 85.1L178.6 88.2L178.2 89.5L177.7 89.3L172.7 92.1L173.5 94.1L170.4 95.8L168.2 90.2L166.5 93.3L155.7 99.6L152.1 90.5L148.9 84.8L147.8 79.3L170.1 76.2L166.6 67.7L172.9 63.9L176.2 71.4Z", + "cx": 170.7, + "cy": 82.8 + }, + { + "n": "10", + "name": "Gloucester-Southgate", + "d": "M208.4 78.3L206.4 78.4L204.5 79.4L179 94.6L178.8 91.1L177.8 91.6L178.6 88.2L177.8 85.1L178.5 83.8L177.4 80.1L178.4 78.3L189.8 71.9L187.9 67.3L190.9 65.9L189.7 62.9L206.3 53.6L204 48.4L213.6 46.3L222.2 68.3L210 75.9L208.4 78.3Z", + "cx": 193.7, + "cy": 74.7 + }, + { + "n": "11", + "name": "Beacon Hill-Cyrville", + "d": "M206.5 44.9L205.4 46.5L205.5 47.3L204.8 47.4L205.4 48.1L204.5 48.3L201.7 47.5L200.1 48.4L195.6 47L193.3 41.1L201 36.7L197 26.5L209.9 24.4L212.3 23.5L213 24.3L212.5 25.3L211.7 25.3L211.5 26.1L210 26.7L211.2 27.5L210.1 27.6L210.7 28.2L210.3 28.9L210.9 30.5L210.2 30.6L210.5 31L209.5 31.8L206.6 37.5L202.9 40.7L204.5 42.7L205.6 45.4L206.5 44.9Z", + "cx": 206.6, + "cy": 36 + }, + { + "n": "12", + "name": "Rideau-Vanier", + "d": "M187.4 46.3L184.5 46.5L177.6 42.5L176.4 42.3L176.3 39.3L177.2 36.2L179.3 37.2L179.6 38.3L180.4 38.7L181.7 38L183.1 38.4L185.1 36.5L186.1 34.4L190.4 33.7L191.7 37L190.4 37.7L191.3 39.6L191.1 41.2L185.2 42.1L187.4 46.3Z", + "cx": 184.1, + "cy": 39.6 + }, + { + "n": "13", + "name": "Rideau-Rockcliffe", + "d": "M199.7 33.5L201 36.7L193.3 41.1L194.9 45.1L187.4 46.3L185.2 42.1L191.1 41.2L191.3 39.6L190.4 37.7L191.7 37L190.4 33.7L186.1 34.4L185.1 36.5L183.1 38.4L181.7 38L180.4 38.7L179.6 38.3L179.3 37.2L177.2 36.2L179.9 31.2L181 30.3L197 26.5L199.7 33.5Z", + "cx": 188.1, + "cy": 37.1 + }, + { + "n": "14", + "name": "Somerset", + "d": "M181 44.6L182.6 45.7L181.8 48.1L175.8 51.3L176.5 53L174.9 54.1L173.8 51.7L170.7 48.7L169.5 46.1L171.2 46L171.4 44.8L175.4 43.5L176.4 42.3L177.6 42.5L181 44.6Z", + "cx": 176, + "cy": 47.1 + }, + { + "n": "15", + "name": "Kitchissippi", + "d": "M174.9 54.1L168.5 58L168 57.1L166.4 59.3L162.6 61.7L162.2 60.7L161.6 60.9L160.4 57.8L161.7 56.7L160.6 55.9L159.1 52.8L160.3 50.2L162.1 48.5L169.5 46.1L170.7 48.7L173.8 51.7L174.9 54.1Z", + "cx": 165.7, + "cy": 55 + }, + { + "n": "16", + "name": "River", + "d": "M175.2 54.8L177.1 56.1L177.4 58.8L176.8 62.3L177.3 62.2L177.4 61L178.4 60L180.4 59.4L181.8 62L186.4 64.8L189.7 62.9L190.9 65.9L187.9 67.3L189.8 71.9L178.7 78.2L177.6 77.1L178.3 74.5L177 71.6L176.3 71.6L175.7 70.8L172.9 63.9L166.6 67.7L163.8 60.9L166.4 59.3L168 57.1L168.5 58L175.3 53.8L175.2 54.8Z", + "cx": 177.4, + "cy": 63.9 + }, + { + "n": "17", + "name": "Capital", + "d": "M184.4 46.5L189.1 46.1L186.5 53.3L185.2 53.1L183.3 57L185.9 60.4L187.3 64.3L186.4 64.8L181.8 62L180.4 59.4L178.4 60L177.4 61L177.3 62.2L176.8 62.3L177.4 58.8L177.1 56.1L175.2 54.8L175.3 53.8L176.5 53L175.8 51.3L181.8 48.1L182.6 45.7L184.4 46.5Z", + "cx": 181.1, + "cy": 55.7 + }, + { + "n": "18", + "name": "Alta Vista", + "d": "M199.9 48.3L201.7 47.5L204.3 48.6L206.3 53.6L187.3 64.3L185.8 60.3L183.3 57L185.2 53.1L186.5 53.3L189.1 46.1L194.9 45.1L195.6 47L199.9 48.3Z", + "cx": 193.8, + "cy": 51.7 + }, + { + "n": "19", + "name": "Orléans South-Navan", + "d": "M278.7 10.7L294 51.3L291.8 52.5L286.1 55.1L280.9 56.5L266.6 62.3L265.5 63.2L254.3 67.2L247 47.3L242.7 49.3L241.4 46L225.7 43.9L205 48.1L204.8 47.4L205.5 47.3L205.4 46.5L206.5 44.9L210.1 43L213.5 42.9L219.5 39.5L220.9 36.7L230.2 31.4L248.3 24.1L245.2 16.3L250 14.5L255.4 14.6L258.5 13.2L259 13.7L262.3 13.8L267.6 13.1L271.2 11.7L271.8 12.5L273 12.1L273.6 12.7L278.7 10.7Z", + "cx": 248.9, + "cy": 34.7 + }, + { + "n": "20", + "name": "Osgoode", + "d": "M235.8 45.2L241.4 46L242.7 49.3L247 47.3L254.3 67.2L265.5 63.2L266.6 62.3L280.9 56.5L286.3 55L294 51.3L300 66.6L293.9 69.1L294.1 69.8L277.4 76.7L251.5 86.6L257.8 104.7L271.2 136.7L248.9 149.9L232.6 157.8L205.7 174.2L196.7 158.3L194.8 156.8L194.8 154.8L192.6 151.8L193.1 147.4L197.7 141.6L197.1 139.5L194.9 137L195 134.9L193.9 132.2L194.2 131.2L193.7 129.5L190.1 126L188.2 126L187.1 123.3L185.9 122.1L184.9 118.3L184.3 117.7L182.8 117.6L180.6 115.9L176.8 110.7L178.5 109.7L179.1 110.1L182.5 108.2L185.5 106.4L185.2 105.7L188.1 103.9L186.2 98.8L196.7 92.5L198.8 92.2L201.1 92.7L204.8 91.5L205.9 90.2L214 84.9L210.3 75.7L222.2 68.3L213.6 46.3L225.2 44L235.8 45.2Z", + "cx": 218, + "cy": 101.6 + }, + { + "n": "21", + "name": "Rideau-Jock", + "d": "M149 98.6L152.9 105.1L155.3 110.8L156.6 111.9L161.2 113.5L164.1 119.6L174 114L174.3 111.7L175.8 111L176 108.1L176.8 110.8L180.6 115.9L182.8 117.6L184.3 117.7L184.9 118.3L185.9 122.1L187.1 123.3L188.2 126L190.1 126L193.5 129.1L194.2 130.7L193.9 132.2L195 134.9L194.9 137L197.1 139.5L197.7 141.6L193.1 147.4L192.6 151.8L194.8 154.8L194.8 156.8L196.7 158.4L196.8 160.1L199.3 166.4L198.4 169.5L194.3 173.3L193.7 178.3L192.6 180.7L189.9 183.1L185.3 185.2L181.6 188.4L180.2 188.4L179.6 190.3L177.9 192.4L178.1 193.7L176.5 196.3L172.4 198.8L170.8 198.8L168.2 201.4L164.2 203.3L163.4 204.5L161.5 205.4L161.4 206.3L159.9 207.2L159.8 208.7L158.6 210.3L154.1 212.9L151.3 213L148.7 214.3L146.9 216.3L146.6 218.1L144.6 219L145 221.1L131.3 205.7L108.8 178.7L112.2 175.8L82.6 140.9L83.5 138.8L84.2 131.4L85.1 129.1L103.2 113.1L104.3 111L104.1 108L109.1 103.5L118.9 114.9L119.8 114L120 114.6L125.6 109.7L124 107.9L131.6 101.2L133.6 103.1L136.2 102.6L143.7 98L143.9 98.7L149.1 95.8L149 98.6Z", + "cx": 160.5, + "cy": 150.6 + }, + { + "n": "22", + "name": "Riverside South-Findlay Creek", + "d": "M210.7 76.8L214 84.9L205.9 90.2L204.8 91.5L201.1 92.7L198.8 92.2L196.7 92.5L186.2 98.8L188.1 103.9L185.2 105.7L185.5 106.4L182.5 108.2L179.1 110.1L178.5 109.7L176.8 110.7L175.7 105.9L175.8 103.8L177 102.1L176.8 100.5L178.2 96.3L177.5 93.8L177.8 91.6L178.8 91.1L178.7 93.3L179 94.6L179.3 94.4L204.5 79.4L206.4 78.4L208.2 78.4L210.3 75.7L210.7 76.8Z", + "cx": 189.9, + "cy": 94.5 + }, + { + "n": "23", + "name": "Kanata South", + "d": "M130.5 92.3L130.6 92.6L136.3 89.3L140.8 100.1L136.2 102.6L133.5 103L131.1 100.7L129.6 100L126.1 96.4L118.5 92.2L117.2 90.8L120.6 88.8L127.1 83.5L130.5 92.3Z", + "cx": 129.2, + "cy": 94.6 + }, + { + "n": "24", + "name": "Barrhaven East", + "d": "M178 89.9L177.5 93.8L178.2 96.3L176.8 100.5L177 102.1L175.8 103.8L175.6 106.6L173.6 106.8L171.4 104.6L170.2 105.2L169.3 104.8L168.2 106L167.8 105.2L168 103.7L166.9 101.4L164.8 102.7L161.9 95.9L166.5 93.3L168.2 90.2L170.4 95.8L173.5 94.1L172.7 92.1L177.7 89.3L178.2 89.5L178 89.9Z", + "cx": 172.2, + "cy": 98.5 + } +]; + +/** Everything needs to draw Ottawa. The id namespaces the shared + * geometry, so it must be unique across regions. */ +export const WARD_GEO: WardGeo = { + id: "ottawa-ward-map", + viewBox: WARD_MAP_VIEWBOX, + shapes: WARD_SHAPES, + regionLabel: "City of Ottawa", +}; diff --git a/src/app/ottawa/vote/2026/wards/[ward]/page.tsx b/src/app/ottawa/vote/2026/wards/[ward]/page.tsx new file mode 100644 index 0000000..0f794d6 --- /dev/null +++ b/src/app/ottawa/vote/2026/wards/[ward]/page.tsx @@ -0,0 +1,150 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { WardDetail } from "@/components/elections/WardDetail"; +import { WardMap, WardMapDefs } from "@/components/elections/WardMap"; +import { WARD_GEO, WARD_SHAPES } from "../../wardGeo"; +import { + ELECTION, + WARD_NUMBERS, + WARDS, + getOttawa2026, + getOttawa2026Ward, +} from "../../data"; + +export function generateStaticParams() { + return WARD_NUMBERS.map((n) => ({ ward: n })); +} + +/** The ward as the City draws it, by route token ("01" or "1"). */ +function shapeFor(token: string) { + return WARD_SHAPES.find((w) => parseInt(w.n, 10) === parseInt(token, 10)); +} + +export async function generateMetadata({ + params, +}: { + params: Promise<{ ward: string }>; +}): Promise { + const { ward } = await params; + const w = shapeFor(ward); + if (!w) return { title: "Ward not found" }; + return { + title: `Ward ${w.n} — ${w.name}`, + description: `The council race in Ward ${w.n} (${w.name}) for Ottawa's October 26, 2026 municipal election.`, + alternates: { canonical: `${ELECTION.basePath}/wards/${w.n}` }, + openGraph: { + title: `Ward ${w.n} — ${w.name} — Ottawa 2026 Election`, + description: `Candidates for councillor in ${w.name}.`, + type: "website", + }, + }; +} + +export default async function OttawaWardPage({ + params, +}: { + params: Promise<{ ward: string }>; +}) { + const { ward } = await params; + const [data, view] = await Promise.all([ + getOttawa2026Ward(ward), + getOttawa2026(), + ]); + + // Before the roster is published there are no races to list, but the ward + // itself is real and mapped — so show it rather than 404. + if (!data) { + const w = shapeFor(ward); + if (!w) notFound(); + return ; + } + + return ( + } + wardMap={ + + } + /> + ); +} + +function PreRosterWard({ n, name }: { n: string; name: string }) { + const idx = WARDS.findIndex((w) => w.n === n); + const prev = WARDS[(idx + WARDS.length - 1) % WARDS.length]; + const next = WARDS[(idx + 1) % WARDS.length]; + + return ( +
    +
    + + +
    + + All wards + + / + Ward {n} +
    + +
    +
    +

    City Council · Ward {n}

    +

    + {name} +

    +
    + +
    + +
    +

    + Nominations for Ottawa’s 2026 election haven’t opened + yet, so there are no registered candidates in {name} to show. + We’ll list them here as they file. +

    +
    + +
    + +
    + Ward {prev.n} +
    +
    + {prev.name} +
    + + +
    + Ward {next.n} +
    +
    + {next.name} +
    + +
    +
    +
    + ); +} diff --git a/src/app/toronto/elections/2026/WardMap.tsx b/src/app/toronto/elections/2026/WardMap.tsx deleted file mode 100644 index 6077367..0000000 --- a/src/app/toronto/elections/2026/WardMap.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { WARD_SHAPES, WARD_MAP_VIEWBOX } from "./wardGeo"; - -const BASE_ID = "toronto-ward-map-base"; - -/** - * Defines the full 25-ward Toronto map once as a reusable . Render this - * a single time on the page; each WardMap references it via so the - * geometry is not duplicated per card. - */ -export function WardMapDefs() { - return ( - - ); -} - -/** - * A compact locator map of Toronto with `activeWard` filled in the accent - * colour. Requires to be present once on the page. - */ -export function WardMap({ - activeWard, - className, -}: { - activeWard: string; - className?: string; -}) { - const active = WARD_SHAPES.find((w) => w.n === activeWard); - - return ( - - - {active && ( - - )} - - ); -} diff --git a/src/app/toronto/elections/2026/api.ts b/src/app/toronto/elections/2026/api.ts deleted file mode 100644 index 1deac96..0000000 --- a/src/app/toronto/elections/2026/api.ts +++ /dev/null @@ -1,24 +0,0 @@ -// Toronto 2026 election — York Factory API access. -// -// The shared client lives in @/lib/api/elections; this module pins it to the -// toronto-2026 slug. Callers fall back to the hand-maintained data in -// ./candidates when the API is unreachable. - -import { fetchElection } from "@/lib/api/elections"; - -export type { - ApiSocialLink, - ApiCandidate, - ApiRace, - ApiElection, -} from "@/lib/api/elections"; - -const ELECTION_SLUG = "toronto-2026"; - -/** - * The toronto-2026 election with all races and candidates, or null when the - * API is unreachable. - */ -export function fetchToronto2026() { - return fetchElection(ELECTION_SLUG); -} diff --git a/src/app/toronto/elections/2026/data.ts b/src/app/toronto/elections/2026/data.ts deleted file mode 100644 index b6c466c..0000000 --- a/src/app/toronto/elections/2026/data.ts +++ /dev/null @@ -1,198 +0,0 @@ -// Toronto 2026 municipal election — data layer. -// The candidate roster comes from the York Factory API (which mirrors the -// City Clerk's registered-candidate feeds daily); the hand-maintained data -// in ./candidates enriches it with photos, bios, tags, and verified campaign -// sites, and doubles as the fallback when the API is unreachable. - -import { differenceInCalendarDays } from "date-fns"; -import { WARD_SHAPES } from "./wardGeo"; -import { fetchToronto2026, type ApiCandidate, type ApiElection } from "./api"; - -export const ELECTION_DATE_ISO = "2026-10-26"; -/** Display strings for the key election-calendar dates. */ -export const NOMINATION_CLOSE_LABEL = "Sept 18, 2026"; -export const ELECTION_DAY_LABEL = "Mon, Oct 26"; - -/** - * Whole calendar days from `now` until election day. Counts calendar days - * (not remaining 24h periods) so the counter reads the same all day, e.g. - * "103 days" throughout Jul 15 rather than ticking to 102 by lunchtime. - */ -export function daysUntilElection(now: Date = new Date()): number { - const [y, m, d] = ELECTION_DATE_ISO.split("-").map(Number); - const electionDay = new Date(y, m - 1, d); - return Math.max(0, differenceInCalendarDays(electionDay, now)); -} - -// Hand-maintained enrichment (photos, bios, tags, verified sites) lives in -// ./candidates — see that file to fill in a candidate's profile. -import { - MAYORAL_CANDIDATES as RAW_MAYORAL_CANDIDATES, - WARD_CANDIDATES, - type MayoralCandidate, - type CouncillorCandidate, - type CouncillorTag, -} from "./candidates"; - -export type { MayoralCandidate, CouncillorCandidate, CouncillorTag }; - -/** Generational suffixes ignored when deriving a last-name sort key, so e.g. - * "Kannan S'ree Jr" sorts under "s", not "j". */ -const NAME_SUFFIXES = new Set(["jr", "jr.", "sr", "sr.", "ii", "iii", "iv", "v"]); - -/** Last-name sort key from a full name, e.g. "Eleanor Voss" → "voss". - * Used to order candidate lists alphabetically by last name. Trailing - * generational suffixes (Jr, Sr, III, …) are skipped. */ -export function lastNameKey(name: string): string { - const parts = name.trim().split(/\s+/).filter(Boolean); - while (parts.length > 1 && NAME_SUFFIXES.has(parts[parts.length - 1].toLowerCase())) { - parts.pop(); - } - return (parts[parts.length - 1] ?? "").toLowerCase(); -} - -/** Sort candidates alphabetically by last name (stable, non-mutating). */ -function byLastName(candidates: T[]): T[] { - return [...candidates].sort((a, b) => - lastNameKey(a.name).localeCompare(lastNameKey(b.name)), - ); -} - -/** Initials from a name, e.g. "Eleanor Voss" → "EV" (used when a candidate - * has no explicit `initials` and no photo). */ -export function initialsFor(name: string): string { - const parts = name.trim().split(/\s+/).filter(Boolean); - const first = parts[0]?.[0] ?? ""; - const last = parts.length > 1 ? parts[parts.length - 1][0] : ""; - return (first + last).toUpperCase(); -} - -// ── API roster + local enrichment ────────────────────────────────────────── - -/** Matching key for enrichment lookups: lowercase, diacritics and - * punctuation stripped, so the Clerk's "Ala'a Adib" matches a local entry - * written "Alaa Adib". Also used as the stable candidate key in analytics - * events, since the Clerk's feed has no candidate IDs. */ -export function nameKey(name: string): string { - return name - .normalize("NFD") - .replace(/[\u0300-\u036f]/g, "") - .toLowerCase() - .replace(/[^a-z0-9 ]+/g, "") - .replace(/\s+/g, " ") - .trim(); -} - -/** "First Last" display name from an API candidate ("Last, First"). */ -function displayName(candidate: ApiCandidate): string { - if (candidate.first_name && candidate.last_name) { - return `${candidate.first_name} ${candidate.last_name}`; - } - const [last, first] = candidate.full_name.split(",").map((s) => s.trim()); - return first ? `${first} ${last}` : candidate.full_name; -} - -const MAYORAL_ENRICHMENT = new Map( - RAW_MAYORAL_CANDIDATES.map((c) => [nameKey(c.name), c]), -); - -function councillorEnrichment(wardNumber: number) { - const key = String(wardNumber).padStart(2, "0"); - return new Map((WARD_CANDIDATES[key] ?? []).map((c) => [nameKey(c.name), c])); -} - -function activeCandidates(election: ApiElection, wardNumber?: number): ApiCandidate[] { - const race = election.races.find((r) => - wardNumber === undefined - ? r.office_type === "mayor" - : r.office_type === "councillor" && r.district_number === wardNumber, - ); - return (race?.candidates ?? []).filter((c) => c.status === "active"); -} - -/** Mayoral candidates — API roster merged with local enrichment, sorted by - * last name. Falls back to the local list when the API is unreachable. */ -export async function getMayoralCandidates(): Promise { - const election = await fetchToronto2026(); - if (!election) return byLastName(RAW_MAYORAL_CANDIDATES); - - return byLastName( - activeCandidates(election).map((api): MayoralCandidate => { - const name = displayName(api); - const curated = MAYORAL_ENRICHMENT.get(nameKey(name)); - return { - name, - tag: curated?.tag ?? "Declared", - bio: curated?.bio ?? "", - image: curated?.image ?? api.photo_url ?? undefined, - website: curated?.website ?? api.website ?? undefined, - initials: curated?.initials, - }; - }), - ); -} - -/** - * Councillor candidates for a ward (0-based index) — API roster merged with - * local enrichment, sorted by last name. Falls back to the local list when - * the API is unreachable; empty for wards with no registered candidates yet. - */ -export async function getCouncillorCandidates( - wardIndex: number, -): Promise { - const election = await fetchToronto2026(); - const wardNumber = wardIndex + 1; - if (!election) { - return byLastName(WARD_CANDIDATES[String(wardNumber).padStart(2, "0")] ?? []); - } - - const enrichment = councillorEnrichment(wardNumber); - return byLastName( - activeCandidates(election, wardNumber).map((api): CouncillorCandidate => { - const name = displayName(api); - const curated = enrichment.get(nameKey(name)); - return { - name, - tag: curated?.tag ?? "Registered", - bio: curated?.bio ?? "", - image: curated?.image ?? api.photo_url ?? undefined, - website: curated?.website ?? api.website ?? undefined, - initials: curated?.initials, - }; - }), - ); -} - -// ── Wards ────────────────────────────────────────────────────────────────── - -export type Ward = { - n: string; - name: string; - count: number; -}; - -/** Zero-padded ward numbers ("01".."25") for static route generation. */ -export const WARD_NUMBERS: string[] = WARD_SHAPES.map((w) => w.n); - -/** - * All 25 wards with live candidate counts from the API (local counts when it - * is unreachable). Ward names/numbers come from the official City of Toronto - * ward geometry (see wardGeo.ts). - */ -export async function getWards(): Promise { - const election = await fetchToronto2026(); - return WARD_SHAPES.map((w, i) => ({ - n: w.n, - name: w.name, - count: election - ? activeCandidates(election, i + 1).length - : (WARD_CANDIDATES[String(i + 1).padStart(2, "0")] ?? []).length, - })); -} - -/** Look up a ward by its number ("01".."25" or "1".."25"). */ -export function findWardIndex(param: string): number { - const num = parseInt(param, 10); - if (Number.isNaN(num) || num < 1 || num > WARD_SHAPES.length) return -1; - return num - 1; -} diff --git a/src/app/toronto/elections/2026/page.tsx b/src/app/toronto/elections/2026/page.tsx deleted file mode 100644 index d51764c..0000000 --- a/src/app/toronto/elections/2026/page.tsx +++ /dev/null @@ -1,253 +0,0 @@ -import type { Metadata } from "next"; -import { Suspense } from "react"; -import Link from "next/link"; -import Image from "next/image"; -import { ArrowUpRight, ArrowRight } from "lucide-react"; -import CountdownDays from "./CountdownDays"; -import { CandidateSiteLink } from "./CandidateSiteLink"; -import { WardMap, WardMapDefs } from "./WardMap"; -import { PledgeButton } from "@/components/elections/PledgeButton"; -import { ResidencyModal } from "@/components/elections/ResidencyModal"; -import { - getMayoralCandidates, - getWards, - daysUntilElection, - initialsFor, - nameKey, - NOMINATION_CLOSE_LABEL, -} from "./data"; - -export const metadata: Metadata = { - title: "Toronto 2026 Election", - description: - "Toronto elects its mayor and 25 city councillors on October 26, 2026. Build Canada tracks every race: the candidates for mayor, what they intend to build, and who is running in your ward.", - alternates: { canonical: "/toronto/elections/2026" }, - openGraph: { - title: "Toronto 2026 Election — Build Canada", - description: - "Tracking every race in Toronto's 2026 municipal election: the candidates for mayor and the 25 council wards.", - type: "website", - }, -}; - -export default async function Toronto2026ElectionPage() { - const [mayoralCandidates, wards] = await Promise.all([ - getMayoralCandidates(), - getWards(), - ]); - const initialDays = daysUntilElection(); - - return ( -
    - - - -
    - {/* ── Hero ─────────────────────────────────────────────── */} -
    -

    - Municipal Election · City of Toronto -

    -

    - Toronto 2026 Election -

    -

    - Toronto elects its mayor and 25 city councillors on - October 26, 2026 — the largest civic decision Canada makes - this year. Build Canada is tracking every race: who is running, - what they intend to build, and how each ward will shape the - direction of the country’s largest city. Meet the candidates - for mayor below, then find your ward to see who is competing to - represent it. -

    -
    - - {/* ── Countdown + how to vote ──────────────────────────── */} -
    -
    -
    - - - Days until -
    - polls open -
    -
    -
    -
    -

    Ready to vote?

    -

    - Put your name on the record. Pledging takes ten seconds — and it’s - the first step to showing up on election day. -

    - - Pledge to vote - - -

    - Polls open{" "} - - Monday, October 26, 2026 - - , 10:00 a.m. to 8:00 p.m. -

    -
    -
    - - {/* ── Candidates for mayor ─────────────────────────────── */} -
    -
    -
    -

    The Mayoralty

    -

    - Candidates for Mayor -

    -
    -

    - {mayoralCandidates.length} declared · field open -

    -
    - -
    - {mayoralCandidates.map((cand, i) => ( -
    -
    - {cand.image ? ( - {cand.name} - ) : ( - (cand.initials ?? initialsFor(cand.name)) - )} -
    -
    -
    -

    - {cand.name} -

    - {cand.tag === "Incumbent" && ( - - {cand.tag} - - )} -
    -

    - {cand.bio} -

    -
    - {cand.website ? ( - - Campaign site - - - ) : ( - - Profile to come - - )} -
    - ))} -
    -

    - Registered candidates from the City Clerk’s list; photographs - and profiles are added as they become available. The field is not - final until nominations close on {NOMINATION_CLOSE_LABEL}. -

    -
    - - {/* ── Wards ────────────────────────────────────────────── */} -
    -
    -
    -

    City Council

    -

    - Find your ward -

    -

    - Twenty-five wards, twenty-five council races. Select a ward to - see the candidates running to represent it. -

    -
    -

    - {wards.length} wards -

    -
    - - -
    - {wards.map((ward) => ( - -
    - - Ward {ward.n} - - -
    - - {ward.name} - -
    - - {ward.count} candidates - - -
    - - ))} -
    -
    - - {/* ── Closing CTA (soft-linen band, full bleed) ────────── */} -
    -

    - “We shall not err for want of boldness.” -
    — Sir Wilfrid Laurier -

    -

    - The Toronto you know is possible doesn’t vote itself in. -

    -

    - Toronto votes Monday, October 26. Add your name — then bring someone - with you. -

    - - Pledge to vote - - -

    - Data shown is illustrative and for demonstration only. Official - candidate lists are certified by the City Clerk after nomination - day. -

    -
    -
    -
    - ); -} diff --git a/src/app/toronto/elections/2026/wards/[ward]/page.tsx b/src/app/toronto/elections/2026/wards/[ward]/page.tsx deleted file mode 100644 index 5cbae54..0000000 --- a/src/app/toronto/elections/2026/wards/[ward]/page.tsx +++ /dev/null @@ -1,254 +0,0 @@ -import type { Metadata } from "next"; -import Link from "next/link"; -import Image from "next/image"; -import { notFound } from "next/navigation"; -import { ArrowLeft, ArrowRight, ArrowUpRight } from "lucide-react"; -import CountdownDays from "../../CountdownDays"; -import { CandidateSiteLink } from "../../CandidateSiteLink"; -import { WardMap, WardMapDefs } from "../../WardMap"; -import { - WARD_NUMBERS, - getWards, - getCouncillorCandidates, - findWardIndex, - initialsFor, - nameKey, - daysUntilElection, - NOMINATION_CLOSE_LABEL, - ELECTION_DAY_LABEL, -} from "../../data"; -import { WARD_SHAPES } from "../../wardGeo"; - -export function generateStaticParams() { - return WARD_NUMBERS.map((n) => ({ ward: n })); -} - -export async function generateMetadata({ - params, -}: { - params: Promise<{ ward: string }>; -}): Promise { - const { ward } = await params; - const idx = findWardIndex(ward); - if (idx === -1) return { title: "Ward not found" }; - const w = WARD_SHAPES[idx]; - return { - title: `Ward ${w.n} — ${w.name}`, - description: `The council race in Ward ${w.n} (${w.name}) for Toronto's October 26, 2026 municipal election. See every candidate registered to represent it.`, - alternates: { canonical: `/toronto/elections/2026/wards/${w.n}` }, - openGraph: { - title: `Ward ${w.n} — ${w.name} — Toronto 2026 Election`, - description: `Candidates for councillor in ${w.name}.`, - type: "website", - }, - }; -} - -export default async function WardDetailPage({ - params, -}: { - params: Promise<{ ward: string }>; -}) { - const { ward } = await params; - const idx = findWardIndex(ward); - if (idx === -1) notFound(); - - const [wards, candidates] = await Promise.all([ - getWards(), - getCouncillorCandidates(idx), - ]); - const w = wards[idx]; - const initialDays = daysUntilElection(); - - const prev = wards[(idx + wards.length - 1) % wards.length]; - const next = wards[(idx + 1) % wards.length]; - - return ( -
    -
    - - - {/* ── Breadcrumb ─────────────────────────────────────── */} -
    - - All wards - - / - Ward {w.n} -
    - - {/* ── Hero ───────────────────────────────────────────── */} -
    -
    -

    - City Council · Ward {w.n} -

    -

    - {w.name} -

    -

    - One seat on Toronto city council, decided on - October 26, 2026. Below are the candidates registered to - represent {w.name} — the people who will set the direction on - housing, transit and local growth for this part of the city. -

    -
    - -
    - - {/* ── Key stats ──────────────────────────────────────── */} -
    -
    -
    - {w.count} -
    -
    - Candidates registered -
    -
    -
    - -
    - Days until polls open -
    -
    -
    -
    -
    - {ELECTION_DAY_LABEL} -
    -
    -
    - Election day -
    -
    -
    - - {/* ── Candidates ─────────────────────────────────────── */} -
    -
    -

    - Candidates for Councillor -

    -
    -
    - {candidates.length === 0 && ( -

    - No candidates have registered in {w.name} yet. Nominations close - on {NOMINATION_CLOSE_LABEL} — check back as more candidates - register. -

    - )} - {candidates.map((cand, i) => ( -
    -
    - {cand.image ? ( - {cand.name} - ) : ( - (cand.initials ?? initialsFor(cand.name)) - )} -
    -
    -
    -

    - {cand.name} -

    - {cand.tag === "Incumbent" && ( - - {cand.tag} - - )} -
    -

    - {cand.bio} -

    -
    - {cand.website ? ( - - Campaign site - - - ) : ( - - Profile to come - - )} -
    - ))} -
    -

    - Registered candidates from the City Clerk’s list. The field is - not final until nominations close on {NOMINATION_CLOSE_LABEL}. -

    -
    - - {/* ── Prev / next ward ───────────────────────────────── */} -
    - -
    - Ward {prev.n} -
    -
    - {prev.name} -
    - - -
    - Ward {next.n} -
    -
    - {next.name} -
    - -
    - - {/* ── Footer band ────────────────────────────────────── */} -
    -

    - Toronto 2026 Election Tracker -
    A Build Canada project -

    - - Back to all wards - -
    -
    -
    - ); -} diff --git a/src/app/toronto/opengraph-image.tsx b/src/app/toronto/opengraph-image.tsx index cbf9b8e..bf03b6a 100644 --- a/src/app/toronto/opengraph-image.tsx +++ b/src/app/toronto/opengraph-image.tsx @@ -3,7 +3,7 @@ import { ElectionOGImage, OG_SIZE, logoDataUri, -} from "./elections/2026/election-og"; +} from "./vote/2026/election-og"; export const alt = "Build Canada Toronto"; export const size = OG_SIZE; diff --git a/src/app/toronto/elections/2026/candidates.ts b/src/app/toronto/vote/2026/candidates.ts similarity index 100% rename from src/app/toronto/elections/2026/candidates.ts rename to src/app/toronto/vote/2026/candidates.ts diff --git a/src/app/toronto/vote/2026/data.ts b/src/app/toronto/vote/2026/data.ts new file mode 100644 index 0000000..fc27ab8 --- /dev/null +++ b/src/app/toronto/vote/2026/data.ts @@ -0,0 +1,163 @@ +// Toronto 2026 municipal election — this region's binding of the shared +// election data layer (@/lib/elections/election-data). +// +// Two things are Toronto's alone and live here: +// · the hand-maintained enrichment in ./candidates — photos, bios, +// Incumbent/Challenger tags and verified campaign sites, matched to the +// API roster by name; +// · a local fallback roster, so the page still lists a field when York +// Factory is unreachable. No other region has one. +// +// Ward names and the zero-padded route tokens ("01".."25") come from the +// official ward geometry in ./wardGeo, which also draws the locator map. + +import { WARD_SHAPES } from "./wardGeo"; +import { + getElectionView, + getWardDetail, + initialsFor, + nameKey, + type CandidateView, + type ElectionDataOptions, + type ElectionView, + type WardDetail, + type WardRosterEntry, +} from "@/lib/elections/election-data"; +import { getElection } from "@/lib/elections/registry"; +import { + MAYORAL_CANDIDATES, + WARD_CANDIDATES, + type MayoralCandidate, + type CouncillorCandidate, +} from "./candidates"; + +export type { MayoralCandidate, CouncillorCandidate }; +export { initialsFor, nameKey }; + +export const ELECTION = getElection("toronto-2026"); + +/** + * Mayoral candidates given the prominent front-runner treatment, keyed by + * `nameKey` (full name, not last name — the field has both an Olivia and a + * Braeden Chow). Hand-maintained: update as the race develops. + */ +export const MAYORAL_FRONT_RUNNER_KEYS = ["brad bradford", "olivia chow"]; + +/** Caption under the front-runner heading — edit to match the current race. */ +export const FRONT_RUNNER_NOTE = + "The incumbent mayor and the leading declared challenger."; + +/** Zero-padded ward numbers ("01".."25") for static route generation. */ +export const WARD_NUMBERS: string[] = WARD_SHAPES.map((w) => w.n); + +const WARD_ROSTER: WardRosterEntry[] = WARD_SHAPES.map((w) => ({ + n: w.n, + number: parseInt(w.n, 10), + name: w.name, +})); + +/** The local roster for one ward, keyed as candidates.ts stores it ("01"). */ +function localWardCandidates(wardNumber: number): CouncillorCandidate[] { + return WARD_CANDIDATES[String(wardNumber).padStart(2, "0")] ?? []; +} + +const OPTIONS: ElectionDataOptions = { + wardRoster: WARD_ROSTER, + mayoralEnrichment: new Map( + MAYORAL_CANDIDATES.map((c) => [nameKey(c.name), c]), + ), + councillorEnrichment: (wardNumber) => + new Map(localWardCandidates(wardNumber).map((c) => [nameKey(c.name), c])), +}; + +// ── Fallback ─────────────────────────────────────────────────────────────── + +/** Shape a hand-maintained entry like an API-derived candidate. */ +function toView( + candidate: MayoralCandidate | CouncillorCandidate, +): CandidateView { + return { + key: nameKey(candidate.name), + name: candidate.name, + initials: candidate.initials ?? initialsFor(candidate.name), + tag: candidate.tag, + bio: candidate.bio, + image: candidate.image, + website: candidate.website, + withdrawn: false, + socialLinks: [], + }; +} + +function byLastName(list: T[]): T[] { + const key = (name: string) => + (name.trim().split(/\s+/).pop() ?? "").toLowerCase(); + return [...list].sort((a, b) => key(a.name).localeCompare(key(b.name))); +} + +/** The page as rendered from the local roster alone, for when York Factory is + * unreachable. Counts and dates come from the same hand-maintained data. */ +function fallbackView(): ElectionView { + return { + slug: ELECTION.slug, + name: "Toronto 2026 General Municipal Election", + electionDateIso: ELECTION.electionDateIso, + electionDateLabel: "Mon, Oct 26, 2026", + nominationCloseLabel: null, + daysUntil: 0, + mayoral: byLastName(MAYORAL_CANDIDATES).map(toView), + wards: WARD_ROSTER.map((ward) => ({ + ...ward, + count: localWardCandidates(ward.number).length, + })), + atLargeRaces: [], + raceCount: WARD_ROSTER.length + 1, + candidateCount: + MAYORAL_CANDIDATES.length + + WARD_ROSTER.reduce( + (total, ward) => total + localWardCandidates(ward.number).length, + 0, + ), + }; +} + +// ── Public API ───────────────────────────────────────────────────────────── + +/** The election, falling back to the local roster when the API is down. */ +export async function getToronto2026(): Promise { + return (await getElectionView(ELECTION.slug, OPTIONS)) ?? fallbackView(); +} + +/** One ward's races, or null for a ward number outside 1–25. Falls back to + * the local roster when the API is unreachable. */ +export async function getToronto2026Ward( + wardToken: string, +): Promise { + const live = await getWardDetail(ELECTION.slug, wardToken, OPTIONS); + if (live) return live; + + const number = parseInt(wardToken, 10); + const view = fallbackView(); + const ward = view.wards.find((w) => w.number === number); + if (!ward) return null; + + const candidates = byLastName(localWardCandidates(number)).map(toView); + return { + ward, + wards: view.wards, + councilRaces: [ + { + id: `councillor||${number}`, + seat: "Councillor", + label: `Councillor — ${ward.name}`, + officeBody: null, + districtName: ward.name, + wardNumbers: [number], + atLarge: false, + candidates, + registeredCount: candidates.length, + }, + ], + trusteeRaces: [], + }; +} diff --git a/src/app/toronto/elections/2026/election-og.tsx b/src/app/toronto/vote/2026/election-og.tsx similarity index 100% rename from src/app/toronto/elections/2026/election-og.tsx rename to src/app/toronto/vote/2026/election-og.tsx diff --git a/src/app/toronto/elections/2026/opengraph-image.tsx b/src/app/toronto/vote/2026/opengraph-image.tsx similarity index 100% rename from src/app/toronto/elections/2026/opengraph-image.tsx rename to src/app/toronto/vote/2026/opengraph-image.tsx diff --git a/src/app/toronto/vote/2026/page.tsx b/src/app/toronto/vote/2026/page.tsx new file mode 100644 index 0000000..33a2522 --- /dev/null +++ b/src/app/toronto/vote/2026/page.tsx @@ -0,0 +1,67 @@ +import type { Metadata } from "next"; +import { ElectionLanding } from "@/components/elections/ElectionLanding"; +import { WardMap, WardMapDefs } from "@/components/elections/WardMap"; +import { WARD_GEO } from "./wardGeo"; +import { + ELECTION, + FRONT_RUNNER_NOTE, + MAYORAL_FRONT_RUNNER_KEYS, + getToronto2026, +} from "./data"; + +export const metadata: Metadata = { + title: "Toronto 2026 Election", + description: + "Toronto elects its mayor and 25 city councillors on October 26, 2026. Build Canada tracks every race: the candidates for mayor, what they intend to build, and who is running in your ward.", + alternates: { canonical: ELECTION.basePath }, + openGraph: { + title: "Toronto 2026 Election — Build Canada", + description: + "Tracking every race in Toronto's 2026 municipal election: the candidates for mayor and the 25 council wards.", + type: "website", + }, +}; + +export default async function Toronto2026ElectionPage() { + const view = await getToronto2026(); + + return ( + } + renderWardMap={(ward) => ( + + )} + content={{ + heroTitle: "The 2026 Toronto Municipal Election", + heroBlurb: ( + <> + On October 26th, 2026, Toronto will elect its mayor and 25 city + councillors. Explore who is running for mayor and for councillor in + your ward. + + ), + wardsBlurb: + "Twenty-five wards, twenty-five council races. Select a ward to see the candidates running to represent it.", + frontRunnerKeys: MAYORAL_FRONT_RUNNER_KEYS, + frontRunnerNote: FRONT_RUNNER_NOTE, + closingHeadline: ( + <>The Toronto you know is possible doesn’t vote itself in. + ), + closingBlurb: ( + <> + Toronto votes Monday, October 26. Add your name — then bring someone + with you. + + ), + sourceNote: + "Candidates come from the City Clerk's official registered-candidate list and refresh daily. The field is not final until nominations close.", + }} + /> + ); +} diff --git a/src/app/toronto/elections/2026/pledge/PledgeClient.tsx b/src/app/toronto/vote/2026/pledge/PledgeClient.tsx similarity index 98% rename from src/app/toronto/elections/2026/pledge/PledgeClient.tsx rename to src/app/toronto/vote/2026/pledge/PledgeClient.tsx index 3b461aa..1e056cb 100644 --- a/src/app/toronto/elections/2026/pledge/PledgeClient.tsx +++ b/src/app/toronto/vote/2026/pledge/PledgeClient.tsx @@ -60,7 +60,7 @@ export default function PledgeClient({ {/* ── Overlaid footer ────────────────────────────────── */}
    Explore the Candidates diff --git a/src/app/toronto/elections/2026/pledge/[slug]/SharedPledgeClient.tsx b/src/app/toronto/vote/2026/pledge/[slug]/SharedPledgeClient.tsx similarity index 97% rename from src/app/toronto/elections/2026/pledge/[slug]/SharedPledgeClient.tsx rename to src/app/toronto/vote/2026/pledge/[slug]/SharedPledgeClient.tsx index 8fdc5d2..ed0efe0 100644 --- a/src/app/toronto/elections/2026/pledge/[slug]/SharedPledgeClient.tsx +++ b/src/app/toronto/vote/2026/pledge/[slug]/SharedPledgeClient.tsx @@ -63,7 +63,7 @@ export default function SharedPledgeClient({ name }: { name: string }) { Toronto votes Monday, October 26

    Pledge to vote too @@ -92,7 +92,7 @@ export default function SharedPledgeClient({ name }: { name: string }) { {/* ── Overlaid footer ────────────────────────────────── */}
    diff --git a/src/app/toronto/elections/2026/pledge/[slug]/opengraph-image.tsx b/src/app/toronto/vote/2026/pledge/[slug]/opengraph-image.tsx similarity index 100% rename from src/app/toronto/elections/2026/pledge/[slug]/opengraph-image.tsx rename to src/app/toronto/vote/2026/pledge/[slug]/opengraph-image.tsx diff --git a/src/app/toronto/elections/2026/pledge/[slug]/page.tsx b/src/app/toronto/vote/2026/pledge/[slug]/page.tsx similarity index 100% rename from src/app/toronto/elections/2026/pledge/[slug]/page.tsx rename to src/app/toronto/vote/2026/pledge/[slug]/page.tsx diff --git a/src/app/toronto/elections/2026/pledge/og-template.tsx b/src/app/toronto/vote/2026/pledge/og-template.tsx similarity index 100% rename from src/app/toronto/elections/2026/pledge/og-template.tsx rename to src/app/toronto/vote/2026/pledge/og-template.tsx diff --git a/src/app/toronto/elections/2026/pledge/opengraph-image.tsx b/src/app/toronto/vote/2026/pledge/opengraph-image.tsx similarity index 100% rename from src/app/toronto/elections/2026/pledge/opengraph-image.tsx rename to src/app/toronto/vote/2026/pledge/opengraph-image.tsx diff --git a/src/app/toronto/elections/2026/pledge/ottawa.png b/src/app/toronto/vote/2026/pledge/ottawa.png similarity index 100% rename from src/app/toronto/elections/2026/pledge/ottawa.png rename to src/app/toronto/vote/2026/pledge/ottawa.png diff --git a/src/app/toronto/elections/2026/pledge/page.tsx b/src/app/toronto/vote/2026/pledge/page.tsx similarity index 91% rename from src/app/toronto/elections/2026/pledge/page.tsx rename to src/app/toronto/vote/2026/pledge/page.tsx index f9410d5..2b535f8 100644 --- a/src/app/toronto/elections/2026/pledge/page.tsx +++ b/src/app/toronto/vote/2026/pledge/page.tsx @@ -5,7 +5,7 @@ export const metadata: Metadata = { title: "I Pledge to Vote — Toronto 2026", description: "You pledged to vote in Toronto's 2026 municipal election. Here's your ballot for Monday, October 26, 2026.", - alternates: { canonical: "/toronto/elections/2026/pledge" }, + alternates: { canonical: "/toronto/vote/2026/pledge" }, openGraph: { title: "I Pledge to Vote — Toronto 2026 | Build Canada", description: diff --git a/src/app/toronto/elections/2026/pledge/toronto-stamp.png b/src/app/toronto/vote/2026/pledge/toronto-stamp.png similarity index 100% rename from src/app/toronto/elections/2026/pledge/toronto-stamp.png rename to src/app/toronto/vote/2026/pledge/toronto-stamp.png diff --git a/src/app/toronto/elections/2026/wardGeo.ts b/src/app/toronto/vote/2026/wardGeo.ts similarity index 94% rename from src/app/toronto/elections/2026/wardGeo.ts rename to src/app/toronto/vote/2026/wardGeo.ts index 63b2336..1faa9f0 100644 --- a/src/app/toronto/elections/2026/wardGeo.ts +++ b/src/app/toronto/vote/2026/wardGeo.ts @@ -3,21 +3,13 @@ // Toronto candidate-list map (d3.geoMercator().rotate([0,-13.45])) and // simplified (Douglas–Peucker) for a compact locator map. // Source: https://www.toronto.ca/resources/fepe_te_candidate_list/conf/map/COTGEO_WARD.json -// Regenerate with scratchpad/geocompute/gen.mjs. +// Regenerate with scripts/gen-ward-geo.mjs. Note that script uses an unrotated +// Mercator, so a regeneration will shift these paths slightly — Toronto's were +// rotated to sit square with the City's own candidate-list map. -export const WARD_MAP_VIEWBOX = "0 0 300 157"; +import type { WardGeo, WardShape } from "@/components/elections/WardMap"; -export type WardShape = { - /** zero-padded ward number, e.g. "01" */ - n: string; - /** City of Toronto ward name */ - name: string; - /** SVG path in the WARD_MAP_VIEWBOX coordinate space */ - d: string; - /** projected centroid */ - cx: number; - cy: number; -}; +export const WARD_MAP_VIEWBOX = "0 0 300 157"; export const WARD_SHAPES: WardShape[] = [ { @@ -196,3 +188,12 @@ export const WARD_SHAPES: WardShape[] = [ "cy": 39.6 } ]; + +/** Everything needs to draw Toronto. The id namespaces the shared + * geometry, so it must be unique across regions. */ +export const WARD_GEO: WardGeo = { + id: "toronto-ward-map", + viewBox: WARD_MAP_VIEWBOX, + shapes: WARD_SHAPES, + regionLabel: "City of Toronto", +}; diff --git a/src/app/toronto/elections/2026/wards/[ward]/opengraph-image.tsx b/src/app/toronto/vote/2026/wards/[ward]/opengraph-image.tsx similarity index 73% rename from src/app/toronto/elections/2026/wards/[ward]/opengraph-image.tsx rename to src/app/toronto/vote/2026/wards/[ward]/opengraph-image.tsx index df0cf7c..05b57f6 100644 --- a/src/app/toronto/elections/2026/wards/[ward]/opengraph-image.tsx +++ b/src/app/toronto/vote/2026/wards/[ward]/opengraph-image.tsx @@ -1,6 +1,6 @@ import { ImageResponse } from "next/og"; import { ElectionOGImage, OG_SIZE, logoDataUri } from "../../election-og"; -import { WARD_NUMBERS, findWardIndex, getWards } from "../../data"; +import { WARD_NUMBERS, getToronto2026 } from "../../data"; export const alt = "Toronto 2026 Election ward race — Build Canada"; export const size = OG_SIZE; @@ -16,10 +16,12 @@ export default async function Image({ params: Promise<{ ward: string }>; }) { const { ward } = await params; - const idx = findWardIndex(ward); const logoSrc = await logoDataUri(); + const w = (await getToronto2026()).wards.find( + (candidate) => candidate.number === parseInt(ward, 10), + ); - if (idx === -1) { + if (!w) { return new ImageResponse( ({ ward: n })); +} + +export async function generateMetadata({ + params, +}: { + params: Promise<{ ward: string }>; +}): Promise { + const { ward } = await params; + const w = WARD_SHAPES.find((shape) => parseInt(shape.n, 10) === parseInt(ward, 10)); + if (!w) return { title: "Ward not found" }; + return { + title: `Ward ${w.n} — ${w.name}`, + description: `The council race in Ward ${w.n} (${w.name}) for Toronto's October 26, 2026 municipal election. See every candidate registered to represent it.`, + alternates: { canonical: `${ELECTION.basePath}/wards/${w.n}` }, + openGraph: { + title: `Ward ${w.n} — ${w.name} — Toronto 2026 Election`, + description: `Candidates for councillor in ${w.name}.`, + type: "website", + }, + }; +} + +export default async function WardDetailPage({ + params, +}: { + params: Promise<{ ward: string }>; +}) { + const { ward } = await params; + const [data, view] = await Promise.all([ + getToronto2026Ward(ward), + getToronto2026(), + ]); + if (!data) notFound(); + + return ( + } + wardMap={ + + } + /> + ); +} diff --git a/src/app/toronto/elections/get-involved/opengraph-image.tsx b/src/app/toronto/vote/get-involved/opengraph-image.tsx similarity index 100% rename from src/app/toronto/elections/get-involved/opengraph-image.tsx rename to src/app/toronto/vote/get-involved/opengraph-image.tsx diff --git a/src/app/toronto/elections/get-involved/page.tsx b/src/app/toronto/vote/get-involved/page.tsx similarity index 98% rename from src/app/toronto/elections/get-involved/page.tsx rename to src/app/toronto/vote/get-involved/page.tsx index 60040f6..22cc898 100644 --- a/src/app/toronto/elections/get-involved/page.tsx +++ b/src/app/toronto/vote/get-involved/page.tsx @@ -7,7 +7,7 @@ export const metadata: Metadata = { title: "Get Involved — Toronto 2026 Election", description: "Toronto votes Monday, October 26. Pledge to vote, register, volunteer, or donate — the Toronto you know is possible doesn't vote itself in.", - alternates: { canonical: "/toronto/elections/get-involved" }, + alternates: { canonical: "/toronto/vote/get-involved" }, openGraph: { title: "Get Involved — Toronto 2026 Election | Build Canada", description: @@ -143,7 +143,7 @@ export default function GetInvolvedPage() {
    Explore the candidates diff --git a/src/app/elections/data.ts b/src/app/vote/data.ts similarity index 90% rename from src/app/elections/data.ts rename to src/app/vote/data.ts index 81fd533..c98f030 100644 --- a/src/app/elections/data.ts +++ b/src/app/vote/data.ts @@ -1,4 +1,4 @@ -// /elections — the index of elections Build Canada is tracking. +// /vote — the index of elections Build Canada is tracking. // // The roster comes from the York Factory elections list; "active" means the // vote hasn't happened yet. Each election is linked to its coverage on this @@ -10,13 +10,7 @@ import { fetchElections, type ApiElectionSummary, } from "@/lib/api/elections"; - -/** Coverage pages on this site, keyed by York Factory election slug. An - * election with no entry here is listed without a link. */ -const ELECTION_ROUTES: Record = { - "toronto-2026": "/toronto/elections/2026", - "brampton-2026": "/elections/brampton/2026", -}; +import { SUPPORTED_ELECTIONS } from "@/lib/elections/registry"; export type ActiveElection = { slug: string; @@ -89,7 +83,9 @@ async function describe(summary: ApiElectionSummary): Promise { ? SHORT_DATE.format(parseDateOnly(summary.nomination_close_date)) : null, daysUntil: daysUntil(summary.election_date), - href: ELECTION_ROUTES[summary.slug] ?? null, + // The registry is the list of elections we have pages for; anything else + // York Factory knows about is listed here without a link. + href: SUPPORTED_ELECTIONS[summary.slug]?.basePath ?? null, raceCount: races?.length ?? null, candidateCount: races?.reduce( diff --git a/src/app/elections/page.tsx b/src/app/vote/page.tsx similarity index 99% rename from src/app/elections/page.tsx rename to src/app/vote/page.tsx index b429d0e..6d1ce3a 100644 --- a/src/app/elections/page.tsx +++ b/src/app/vote/page.tsx @@ -7,7 +7,7 @@ export const metadata: Metadata = { title: "Elections", description: "Every election Build Canada is tracking — who is running, what they intend to build, and when the polls open.", - alternates: { canonical: "/elections" }, + alternates: { canonical: "/vote" }, openGraph: { title: "Elections — Build Canada", description: diff --git a/src/app/toronto/elections/2026/CandidateSiteLink.tsx b/src/components/elections/CandidateSiteLink.tsx similarity index 67% rename from src/app/toronto/elections/2026/CandidateSiteLink.tsx rename to src/components/elections/CandidateSiteLink.tsx index 24d0ac3..9ffd4fd 100644 --- a/src/app/toronto/elections/2026/CandidateSiteLink.tsx +++ b/src/components/elections/CandidateSiteLink.tsx @@ -3,11 +3,18 @@ import posthog from "posthog-js"; import type { ReactNode } from "react"; +/* An outbound link to a candidate's campaign site, instrumented so we can see + which candidates people actually click through to. Shared by every region's + election pages; `election` names which one, since candidate keys are only + unique within an election. */ + interface CandidateSiteLinkProps { href: string; candidate: string; candidateKey: string; - race: "mayor" | "councillor"; + race: "mayor" | "councillor" | "trustee"; + /** York Factory election slug, e.g. "hamilton-2026" */ + election: string; tag?: string; ward?: string; wardName?: string; @@ -20,6 +27,7 @@ export function CandidateSiteLink({ candidate, candidateKey, race, + election, tag, ward, wardName, @@ -37,6 +45,7 @@ export function CandidateSiteLink({ candidate, candidate_key: candidateKey, race, + election, website: href, tag, ward, diff --git a/src/app/toronto/elections/2026/CountdownDays.tsx b/src/components/elections/CountdownDays.tsx similarity index 50% rename from src/app/toronto/elections/2026/CountdownDays.tsx rename to src/components/elections/CountdownDays.tsx index cad75c9..e21dc29 100644 --- a/src/app/toronto/elections/2026/CountdownDays.tsx +++ b/src/components/elections/CountdownDays.tsx @@ -1,32 +1,36 @@ "use client"; import { useEffect, useState } from "react"; -import { daysUntilElection } from "./data"; +import { daysUntil } from "@/lib/elections/dates"; const DEFAULT_CLASS = "font-sans font-semibold leading-[0.8] tracking-[-0.05em] text-[clamp(6rem,20vw,15rem)] tabular-nums"; /** - * Renders the "days until polls open" number. The server passes an - * initialDays computed at request time so first paint matches hydration; - * the client then keeps it current across day boundaries. `className` - * overrides the default hero-sized styling (e.g. for the ward stat row). + * Renders a "days until " number. The server passes an initialDays + * computed at request time so first paint matches hydration; the client then + * keeps it current across day boundaries. `className` overrides the default + * hero-sized styling (e.g. for the ward stat row or the smaller advance-vote + * and vote-by-mail counters). */ export default function CountdownDays({ initialDays, + targetIso, className, }: { initialDays: number; + /** "YYYY-MM-DD" — the day being counted down to */ + targetIso: string; className?: string; }) { const [days, setDays] = useState(initialDays); useEffect(() => { - const tick = () => setDays(daysUntilElection()); + const tick = () => setDays(daysUntil(targetIso)); tick(); const id = setInterval(tick, 60_000); return () => clearInterval(id); - }, []); + }, [targetIso]); return {days}; } diff --git a/src/components/elections/ElectionLanding.tsx b/src/components/elections/ElectionLanding.tsx new file mode 100644 index 0000000..d4275d9 --- /dev/null +++ b/src/components/elections/ElectionLanding.tsx @@ -0,0 +1,561 @@ +import Image from "next/image"; +import { Suspense, type ReactNode } from "react"; +import { ArrowRight, ArrowUpRight } from "lucide-react"; +import CountdownDays from "./CountdownDays"; +import { CandidateSiteLink } from "./CandidateSiteLink"; +import { PledgeButton } from "./PledgeButton"; +import { ResidencyModal } from "./ResidencyModal"; +import { WardCard } from "./WardCard"; +import WardLookup from "./WardLookup"; +import { daysUntil, yearOf } from "@/lib/elections/dates"; +import type { SupportedElection } from "@/lib/elections/registry"; +import type { + CandidateView, + ElectionView, + RaceView, + WardView, +} from "@/lib/elections/election-data"; + +/* The election landing page every region shares. + + The structure is fixed — hero, countdowns, the mayoral field, the ward + finder, the closing pledge — and each region supplies its own copy plus, + where it has one, its ward locator map. Sections a region can't fill drop + out rather than render empty: no front-runner keys means no front-runner + band, no advance-vote date in the registry means no advance-vote counter. */ + +export type LandingContent = { + heroTitle: ReactNode; + heroBlurb: ReactNode; + /** the "Find your ward" blurb — mentions the region's ward and seat count */ + wardsBlurb: ReactNode; + closingHeadline: ReactNode; + closingBlurb: ReactNode; + /** the fine print about where the roster comes from */ + sourceNote: ReactNode; + /** + * Mayoral candidates given the prominent front-runner treatment, by + * `nameKey` (full name, not last name — Toronto's field has both an Olivia + * and a Braeden Chow). Omit where we aren't calling a front runner. + */ + frontRunnerKeys?: string[]; + /** the caption under the front-runner heading; required when keys are set */ + frontRunnerNote?: ReactNode; +}; + +export function ElectionLanding({ + election, + view, + content, + wardMapDefs, + renderWardMap, +}: { + election: SupportedElection; + view: ElectionView; + content: LandingContent; + /** rendered once, so per-ward maps can reference shared geometry */ + wardMapDefs?: ReactNode; + /** this region's locator map for a ward, when it has ward geometry */ + renderWardMap?: (ward: WardView) => ReactNode; +}) { + const keys = new Set(content.frontRunnerKeys ?? []); + const frontRunners = view.mayoral.filter((c) => keys.has(c.key)); + const field = view.mayoral.filter((c) => !keys.has(c.key)); + + // Pre-rendered here rather than inside the lookup, because the locator map + // is server-side geometry the client component can't build. + const wardCards: Record = {}; + for (const ward of view.wards) { + wardCards[ward.number] = ( + + ); + } + + return ( +
    + + + +
    + {/* ── Hero ─────────────────────────────────────────────── */} +
    +

    + {content.heroTitle} +

    +

    + {content.heroBlurb} +

    +
    + + {/* ── Countdown + how to vote ──────────────────────────── */} + + + {/* ── Candidates for mayor ─────────────────────────────── */} +
    +
    +

    + Candidates for Mayor +

    +
    + + {frontRunners.length > 0 && ( + <> +
    +

    Front runners

    +

    + {content.frontRunnerNote} +

    +
    +
    + {frontRunners.map((cand) => ( + + ))} +
    +

    + The rest of the field +

    + + )} + +
    + {field.map((cand) => ( + + ))} +
    +
    + + {/* ── Wards ────────────────────────────────────────────── */} +
    +
    +
    +

    City Council

    +

    + Find your ward +

    +

    + {content.wardsBlurb} +

    + {election.wardLookup && ( + + )} +
    +

    + {view.wards.length} wards +

    +
    + + {wardMapDefs} +
    + {view.wards.map((ward) => ( + + ))} +
    +
    + + {/* ── Also city-wide (French-language school boards) ────── */} + {view.atLargeRaces.length > 0 && ( +
    +
    +

    + Also on every ballot +

    +

    + City-wide races +

    +

    + These seats are elected across the whole city, so every voter + sees them regardless of ward. +

    +
    + {view.atLargeRaces.map((race) => ( + + ))} +
    + )} + + {/* ── Closing CTA ──────────────────────────────────────── */} +
    +

    + {content.closingHeadline} +

    +

    + {content.closingBlurb} +

    + + Pledge to vote + + +

    + {content.sourceNote} +

    +
    +
    +
    + ); +} + +// ── Countdown band ───────────────────────────────────────────────────────── + +/** + * Election-day countdown, the advance-vote and vote-by-mail counters, and the + * pledge CTA. Regions that haven't published their advance-vote or mail-in + * dates get a two-column band instead of three, rather than empty cells. + */ +function KeyDates({ election }: { election: SupportedElection }) { + const { advanceVote, mailIn } = election; + const hasMiddle = Boolean(advanceVote || mailIn); + + return ( +
    +
    +
    + + + Days until +
    + polls open +
    +
    +
    + + {hasMiddle && ( +
    + {advanceVote && ( +
    +
    + + + Days until +
    + advance polls +
    +
    +

    + {advanceVote.label} +

    +
    + )} + {mailIn && ( +
    +
    + + + Days to apply +
    + to vote by mail +
    +
    +

    + {mailIn.label}, 4:30 p.m. +

    +
    + )} +
    + )} + +
    +

    Ready to vote?

    +

    + Put your name on the record. Pledging takes ten seconds — and it’s + the first step to showing up on election day. +

    + + Pledge to vote + + +

    + Polls open{" "} + + {election.voteDayLabel}, {yearOf(election.electionDateIso)} + + , {election.pollHoursLabel}. +

    +
    +
    + ); +} + +// ── Candidate cards ──────────────────────────────────────────────────────── + +function FrontRunnerCard({ + candidate, + election, +}: { + candidate: CandidateView; + election: string; +}) { + return ( +
    +
    + {candidate.image ? ( + {candidate.name} + ) : ( + candidate.initials + )} +
    +
    +
    +

    + {candidate.name} +

    + {candidate.tag === "Incumbent" && } +
    + +
    +
    + ); +} + +function MayoralCard({ + candidate, + election, +}: { + candidate: CandidateView; + election: string; +}) { + return ( +
    +
    + {candidate.image ? ( + {candidate.name} + ) : ( + candidate.initials + )} +
    +
    +
    +

    + {candidate.name} +

    + {candidate.tag === "Incumbent" && } +
    + +
    +
    + ); +} + +/** A city-wide race listed under "Also on every ballot". */ +function RaceSection({ + race, + election, + nominationCloseLabel, +}: { + race: RaceView; + election: string; + nominationCloseLabel: string | null; +}) { + return ( +
    +
    +
    + {race.officeBody && ( +

    {race.officeBody}

    + )} +

    + {race.seat} +

    +
    +

    + {race.registeredCount}{" "} + {race.registeredCount === 1 ? "candidate" : "candidates"} · all wards + vote +

    +
    + {race.candidates.length === 0 ? ( +

    + No one has filed for this seat yet. + {nominationCloseLabel + ? ` Nominations close ${nominationCloseLabel} — check back as candidates register.` + : " Check back as candidates register."} +

    + ) : ( +
      + {race.candidates.map((candidate) => ( + + ))} +
    + )} +
    + ); +} + +/** A compact list row, used for the city-wide races. */ +export function CandidateRow({ + candidate, + election, + race, +}: { + candidate: CandidateView; + election: string; + race: "mayor" | "councillor" | "trustee"; +}) { + return ( +
  • + +
    +
    +

    + {candidate.name} +

    + {candidate.withdrawn && ( + + Withdrawn + + )} +
    + {candidate.socialLinks.length > 0 && ( +
    + {candidate.socialLinks.map((link) => ( + + {socialLabel(link.name)} + + ))} +
    + )} +
    +
    + +
    +
  • + ); +} + +export function IncumbentBadge() { + return ( + + Incumbent + + ); +} + +/** The campaign-site link, or the placeholder shown when we have no URL. */ +export function SiteLink({ + candidate, + election, + race, + ward, + wardName, +}: { + candidate: CandidateView; + election: string; + race: "mayor" | "councillor" | "trustee"; + ward?: string; + wardName?: string; +}) { + if (!candidate.website) { + return ( + Profile to come + ); + } + return ( + + Campaign site + + + ); +} + +/** `social_links[].name` is an open vocabulary ("web", "facebook", "tiktok", + * …), so unknown names are title-cased rather than dropped. */ +function socialLabel(name: string): string { + if (name.toLowerCase() === "web") return "Website"; + return name.charAt(0).toUpperCase() + name.slice(1); +} diff --git a/src/components/elections/WardCard.tsx b/src/components/elections/WardCard.tsx new file mode 100644 index 0000000..8c188cd --- /dev/null +++ b/src/components/elections/WardCard.tsx @@ -0,0 +1,57 @@ +import Link from "next/link"; +import type { ReactNode } from "react"; +import { ArrowRight } from "lucide-react"; +import type { WardView } from "@/lib/elections/election-data"; + +/** + * One ward tile: number, optional locator map, name, candidate count. Used + * both for the "Find your ward" grid and for the result of the postal-code + * lookup, so the two always look the same. + * + * `map` is the region's locator graphic for this ward — Toronto has ward + * geometry, the other regions don't, and the tile lays out either way. + * `className` carries placement, not appearance: the grid passes its lattice + * borders, a standalone card passes its own. + */ +export function WardCard({ + ward, + basePath, + map, + countLabel, + className, +}: { + ward: WardView; + /** the election's landing path, e.g. "/hamilton/vote/2026" */ + basePath: string; + map?: ReactNode; + /** + * Replaces the "N candidates" line. Used where a region's wards are drawn + * before its roster exists, so the tile doesn't read "0 candidates" — which + * says nobody registered, not that nobody could have yet. + */ + countLabel?: string; + className?: string; +}) { + return ( + +
    + + Ward {ward.n} + + {map} +
    + + {ward.name} + +
    + + {countLabel ?? `${ward.count} candidates`} + + +
    + + ); +} diff --git a/src/components/elections/WardDetail.tsx b/src/components/elections/WardDetail.tsx new file mode 100644 index 0000000..b9df38a --- /dev/null +++ b/src/components/elections/WardDetail.tsx @@ -0,0 +1,319 @@ +import Link from "next/link"; +import Image from "next/image"; +import type { ReactNode } from "react"; +import { ArrowLeft, ArrowRight } from "lucide-react"; +import CountdownDays from "./CountdownDays"; +import { IncumbentBadge, SiteLink } from "./ElectionLanding"; +import { daysUntil } from "@/lib/elections/dates"; +import type { SupportedElection } from "@/lib/elections/registry"; +import type { + CandidateView, + RaceView, + WardDetail as WardDetailData, +} from "@/lib/elections/election-data"; + +/* One ward's page, shared by every region. + + What a ward elects differs by city and the page follows: Toronto and + Hamilton each elect a single councillor, so the candidates list reads + straight down with no race heading; Brampton elects both a city and a + regional councillor, so each race gets its own heading. School-board races + the ward votes in follow beneath, where the city maps its trustee wards onto + city wards (Toronto's don't map, so Toronto shows none). */ + +export function WardDetail({ + election, + data, + nominationCloseLabel, + wardMapDefs, + wardMap, +}: { + election: SupportedElection; + data: WardDetailData; + /** e.g. "Aug 21, 2026"; null when the city hasn't published it */ + nominationCloseLabel: string | null; + /** rendered once so the locator map can reference shared geometry */ + wardMapDefs?: ReactNode; + /** this region's locator map for this ward, when it has ward geometry */ + wardMap?: ReactNode; +}) { + const { ward, wards, councilRaces, trusteeRaces } = data; + const idx = wards.findIndex((w) => w.number === ward.number); + const prev = wards[(idx + wards.length - 1) % wards.length]; + const next = wards[(idx + 1) % wards.length]; + + // With one council race the heading would only repeat the page title, so the + // candidates run straight down — which is how Toronto's page has always read. + const showRaceHeadings = councilRaces.length > 1; + + return ( +
    +
    + {wardMapDefs} + + {/* ── Breadcrumb ─────────────────────────────────────── */} +
    + + All wards + + / + Ward {ward.n} +
    + + {/* ── Hero ───────────────────────────────────────────── */} +
    +
    +

    + City Council · Ward {ward.n} +

    +

    + {ward.name} +

    +
    + {wardMap} +
    + + {/* ── Key stats ──────────────────────────────────────── */} +
    +
    +
    + {ward.count} +
    +
    + Candidates registered +
    +
    +
    + +
    + Days until polls open +
    +
    +
    +
    +
    + {election.electionDayLabel} +
    +
    +
    + Election day +
    +
    +
    + + {/* ── Council candidates ─────────────────────────────── */} +
    +
    +

    + Candidates +

    +
    + + {councilRaces.length === 0 && ( + + )} + + {councilRaces.map((race) => ( +
    + {showRaceHeadings && } + {race.candidates.length === 0 ? ( + + ) : ( + race.candidates.map((cand) => ( + + )) + )} +
    + ))} + +

    + Registered candidates from the City Clerk’s list. The field is + not final until nominations close + {nominationCloseLabel ? ` on ${nominationCloseLabel}` : ""}. +

    +
    + + {/* ── School board races ─────────────────────────────── */} + {trusteeRaces.length > 0 && ( +
    +
    +

    School boards

    +

    + Trustees on this ballot +

    +
    + {trusteeRaces.map((race) => ( +
    + + {race.candidates.length === 0 ? ( +

    + No one has filed for this seat yet. +

    + ) : ( + race.candidates.map((cand) => ( + + )) + )} +
    + ))} +
    + )} + + {/* ── Prev / next ward ───────────────────────────────── */} +
    + +
    + Ward {prev.n} +
    +
    + {prev.name} +
    + + +
    + Ward {next.n} +
    +
    + {next.name} +
    + +
    +
    +
    + ); +} + +function RaceHeading({ race }: { race: RaceView }) { + return ( +
    +
    + {race.officeBody && ( +

    {race.officeBody}

    + )} +

    + {race.seat} +

    +
    +

    + {race.registeredCount}{" "} + {race.registeredCount === 1 ? "candidate" : "candidates"} +

    +
    + ); +} + +function EmptyRace({ + wardName, + nominationCloseLabel, +}: { + wardName: string; + nominationCloseLabel: string | null; +}) { + return ( +

    + No candidates have registered in {wardName} yet. + {nominationCloseLabel + ? ` Nominations close on ${nominationCloseLabel} — check back as more candidates register.` + : " Check back as more candidates register."} +

    + ); +} + +function CouncilCandidate({ + candidate, + election, + race = "councillor", + ward, + wardName, +}: { + candidate: CandidateView; + election: string; + race?: "councillor" | "trustee"; + ward: string; + wardName: string; +}) { + return ( +
    +
    + {candidate.image ? ( + {candidate.name} + ) : ( + candidate.initials + )} +
    +
    +
    +

    + {candidate.name} +

    + {candidate.tag === "Incumbent" && } + {candidate.withdrawn && ( + + Withdrawn + + )} +
    + {candidate.bio && ( +

    + {candidate.bio} +

    + )} +
    +
    + +
    +
    + ); +} diff --git a/src/components/elections/WardLookup.tsx b/src/components/elections/WardLookup.tsx new file mode 100644 index 0000000..3d6abc1 --- /dev/null +++ b/src/components/elections/WardLookup.tsx @@ -0,0 +1,175 @@ +"use client"; + +import { useState, type ReactNode } from "react"; +import type { WardView } from "@/lib/elections/election-data"; +import type { WardLookupResponse } from "@/lib/elections/ward-lookup"; + +type State = + | { status: "idle" } + | { status: "loading" } + | { status: "done"; result: WardLookupResponse } + | { status: "failed" }; + +/** + * Postal code → ward lookup for the wards section. The result is a best guess + * — postal centroids sit off-line near ward boundaries — so it reads as "looks + * like Ward 19", always offers the full ward list beside it, and never + * navigates on its own. See docs/WARD_LOOKUP_API_SPEC.md. + * + * `cards` holds this region's ward tiles pre-rendered on the server, keyed by + * ward number, because the tile's locator map is server-rendered geometry that + * can't be built here. + */ +export default function WardLookup({ + wards, + cards, + cityLabel, +}: { + wards: WardView[]; + cards: Record; + /** e.g. "Toronto" — names the city in the out-of-boundary message */ + cityLabel: string; +}) { + const [postalCode, setPostalCode] = useState(""); + const [state, setState] = useState({ status: "idle" }); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + const typed = postalCode.trim(); + if (!typed) return; + + setState({ status: "loading" }); + try { + // Sent exactly as typed — the API tolerates any spacing and casing, and + // tells malformed input apart from an unrecognized code. + const res = await fetch( + `/api/elections/ward-lookup?postal_code=${encodeURIComponent(typed)}`, + ); + if (!res.ok) throw new Error(`ward-lookup ${res.status}`); + setState({ status: "done", result: await res.json() }); + } catch (error) { + console.error("[ward-lookup]", error); + setState({ status: "failed" }); + } + }; + + return ( +
    +
    +
    + + setPostalCode(e.target.value)} + autoComplete="postal-code" + placeholder="M4C 1S9" + aria-describedby="ward-lookup-result" + className="w-[13ch] border border-dark bg-bg px-4 py-3 font-sans text-[1.05rem] tracking-[0.02em] uppercase placeholder:text-text-muted placeholder:normal-case focus:outline-none focus:ring-1 focus:ring-accent" + /> +
    + +
    + +
    + {state.status === "done" && ( + + )} + {state.status === "failed" && } +
    +
    + ); +} + +function Result({ + result, + wards, + cards, + cityLabel, +}: { + result: WardLookupResponse; + wards: WardView[]; + cards: Record; + cityLabel: string; +}) { + switch (result.reason) { + case "resolved": { + // ward_number is never null for numbered municipal wards, but the type + // allows null for named ones — without it there is nothing to match. + const number = result.ward?.ward_number; + // Matched against our own roster so the card shows the live candidate + // count and the same ward name as the grid below it. + const ward = number ? wards.find((w) => w.number === number) : undefined; + const card = number ? cards[number] : undefined; + if (!ward || !card) return ; + return ( +
    +

    + Looks like you’re in{" "} + Ward {number}. +

    + {card} + + Not right? Browse all {wards.length} wards + +
    + ); + } + case "malformed_postal_code": + return That doesn’t look like a postal code.; + case "unknown_postal_code": + return ( + + We don’t recognize that postal code. Double-check it? + + ); + case "outside_boundary": + return ( +

    + That postal code looks like it’s outside {cityLabel} — so there + won’t be a {cityLabel} ward for it. +

    + ); + // boundary_data_unavailable, plus any reason added later. + default: + return ; + } +} + +function FieldError({ children }: { children: React.ReactNode }) { + return ( +

    + {children} +

    + ); +} + +function Unavailable() { + return ( +

    + We can’t look that up right now. Try again shortly, or find your ward + in the list below. +

    + ); +} diff --git a/src/components/elections/WardMap.tsx b/src/components/elections/WardMap.tsx new file mode 100644 index 0000000..d9dc033 --- /dev/null +++ b/src/components/elections/WardMap.tsx @@ -0,0 +1,92 @@ +/* A compact locator map: the whole city in outline, with one ward filled in. + + Regions that have ward geometry (see scripts/gen-ward-geo.mjs) supply their + own WARD_SHAPES and viewBox; regions that don't simply pass no map and the + ward tiles lay out without one. + + The city outline is defined once per page as a reusable and referenced + by every card via , so 25 ward tiles ship one copy of the geometry + rather than 25. `id` namespaces that definition per region — two cities' + maps on one page would otherwise collide on the same element id. */ + +export type WardShape = { + /** zero-padded ward number, e.g. "01" */ + n: string; + /** the city's own ward name */ + name: string; + /** SVG path in the region's WARD_MAP_VIEWBOX coordinate space */ + d: string; + /** projected centroid */ + cx: number; + cy: number; +}; + +export type WardGeo = { + id: string; + viewBox: string; + shapes: WardShape[]; + /** e.g. "City of Ottawa" — used in the map's accessible label */ + regionLabel: string; +}; + +/** + * Defines a region's full city outline once as a reusable . Render this a + * single time on any page that uses . + */ +export function WardMapDefs({ geo }: { geo: WardGeo }) { + return ( + + ); +} + +/** + * The city with `activeWard` filled in the accent colour. Requires + * to be present once on the page with the same geo. + */ +export function WardMap({ + geo, + activeWard, + className, +}: { + geo: WardGeo; + activeWard: string; + className?: string; +}) { + const active = geo.shapes.find((w) => w.n === activeWard); + + return ( + + + {active && ( + + )} + + ); +} diff --git a/src/lib/elections/dates.ts b/src/lib/elections/dates.ts new file mode 100644 index 0000000..31072ad --- /dev/null +++ b/src/lib/elections/dates.ts @@ -0,0 +1,25 @@ +// Election date helpers, kept free of any API or server imports so the +// client-side countdown can share them with the server-rendered pages. + +import { differenceInCalendarDays } from "date-fns"; + +/** Parse "YYYY-MM-DD" as local midnight, so day math and labels don't shift a + * day the way `new Date(iso)` does in negative-offset timezones. */ +export function parseDateOnly(iso: string): Date { + const [y, m, d] = iso.split("-").map(Number); + return new Date(y, m - 1, d); +} + +/** + * Whole calendar days from `now` until `targetIso`, floored at zero. Counts + * calendar days (not remaining 24h periods) so a counter reads the same all + * day, e.g. "103 days" throughout Jul 15 rather than ticking to 102 by lunch. + */ +export function daysUntil(targetIso: string, now: Date = new Date()): number { + return Math.max(0, differenceInCalendarDays(parseDateOnly(targetIso), now)); +} + +/** The calendar year of a "YYYY-MM-DD" date, e.g. "2026". */ +export function yearOf(iso: string): string { + return iso.slice(0, 4); +} diff --git a/src/lib/elections/election-data.ts b/src/lib/elections/election-data.ts new file mode 100644 index 0000000..e0aeb27 --- /dev/null +++ b/src/lib/elections/election-data.ts @@ -0,0 +1,425 @@ +// Election pages — the shared data layer. +// +// Reshapes one York Factory election (GET /elections/:slug) into what the +// shared landing and ward pages render, for any city we cover. Every region's +// data flows through here; only the copy differs (see each region's +// content.ts). +// +// The three jurisdictions this serves are shaped differently upstream, and the +// differences are load-bearing: +// +// Toronto 25 wards, one councillor each. Races carry the ward in +// `district_number` (not `ward_numbers`) and put the neighbourhood +// name in `district_name`, e.g. "Etobicoke North". +// Hamilton 15 wards, one councillor each, `district_name` is just "Ward 3". +// Brampton 10 wards paired into 5 districts, each electing BOTH a city and +// a regional councillor — so a ward votes in two council races and +// a race is identified by (office_type, office_body, district). +// +// Photos and bios exist only for Toronto, as hand-maintained enrichment keyed +// by name; everywhere else candidates render as initials. + +import { fetchElection, type ApiCandidate, type ApiRace } from "@/lib/api/elections"; +import { daysUntil, parseDateOnly } from "./dates"; + +export { daysUntil, parseDateOnly }; + +// ── Names ────────────────────────────────────────────────────────────────── + +/** Generational suffixes ignored when deriving a last-name sort key, so e.g. + * "Kannan S'ree Jr" sorts under "s", not "j". */ +const NAME_SUFFIXES = new Set(["jr", "jr.", "sr", "sr.", "ii", "iii", "iv", "v"]); + +/** Last-name sort key from a full name, e.g. "Eleanor Voss" → "voss". + * Trailing generational suffixes (Jr, Sr, III, …) are skipped. */ +export function lastNameKey(name: string): string { + const parts = name.trim().split(/\s+/).filter(Boolean); + while (parts.length > 1 && NAME_SUFFIXES.has(parts[parts.length - 1].toLowerCase())) { + parts.pop(); + } + return (parts[parts.length - 1] ?? "").toLowerCase(); +} + +/** Sort candidates alphabetically by last name (stable, non-mutating). */ +function byLastName(candidates: T[]): T[] { + return [...candidates].sort((a, b) => + lastNameKey(a.name).localeCompare(lastNameKey(b.name)), + ); +} + +/** Initials from a name, e.g. "Eleanor Voss" → "EV" (shown when a candidate + * has no photo, which upstream is everyone outside Toronto). */ +export function initialsFor(name: string): string { + const parts = name.trim().split(/\s+/).filter(Boolean); + const first = parts[0]?.[0] ?? ""; + const last = parts.length > 1 ? parts[parts.length - 1][0] : ""; + return (first + last).toUpperCase(); +} + +/** Matching key for enrichment lookups: lowercase, diacritics and punctuation + * stripped, so the Clerk's "Ala'a Adib" matches a local entry written "Alaa + * Adib". Also the stable candidate key in analytics events, since the clerks' + * feeds carry no candidate IDs. */ +export function nameKey(name: string): string { + return name + .normalize("NFD") + .replace(new RegExp("[\\u0300-\\u036f]", "g"), "") + .toLowerCase() + .replace(/[^a-z0-9 ]+/g, "") + .replace(/\s+/g, " ") + .trim(); +} + +/** + * "First Last" display name. `first_name` is null for mononymous candidates, + * so render the parts we have rather than the published "Last, First". + */ +function displayName(candidate: ApiCandidate): string { + const parts = [candidate.first_name, candidate.last_name].filter(Boolean); + if (parts.length > 0) return parts.join(" "); + const [last, first] = candidate.full_name.split(",").map((s) => s.trim()); + return first ? `${first} ${last}` : candidate.full_name; +} + +// ── Dates ────────────────────────────────────────────────────────────────── + +const LONG_DATE = new Intl.DateTimeFormat("en-CA", { + weekday: "short", + month: "short", + day: "numeric", + year: "numeric", +}); + +const SHORT_DATE = new Intl.DateTimeFormat("en-CA", { + month: "short", + day: "numeric", + year: "numeric", +}); + +// ── Views ────────────────────────────────────────────────────────────────── + +/** Hand-maintained extras for a candidate, matched by `nameKey`. Toronto is + * the only region with any; see its candidates.ts. */ +export type CandidateEnrichment = { + tag?: string; + bio?: string; + image?: string; + website?: string; + initials?: string; +}; + +/** Enrichment for one race, keyed by `nameKey(name)`. */ +export type EnrichmentMap = Map; + +export type CandidateView = { + /** `nameKey(name)` — stable within a race, and the analytics candidate key */ + key: string; + name: string; + initials: string; + /** e.g. "Incumbent"; the default depends on the race (see `defaultTag`) */ + tag: string; + /** hand-written; empty for every region but Toronto */ + bio: string; + image?: string; + website?: string; + /** kept listed but struck through — some clerks never drop withdrawals */ + withdrawn: boolean; + socialLinks: { name: string; url: string }[]; +}; + +export type RaceView = { + /** (office_type, office_body, district) — the real race identity */ + id: string; + /** the seat alone, e.g. "City Councillor" */ + seat: string; + /** the seat plus its district, e.g. "City Councillor — Wards 1, 5" */ + label: string; + /** e.g. "Brampton City Council"; null for mayor and for Toronto councillors */ + officeBody: string | null; + /** e.g. "Etobicoke North" or "Wards 1, 5"; null when at-large */ + districtName: string | null; + /** city wards this race covers; empty when at-large or unmapped */ + wardNumbers: number[]; + atLarge: boolean; + candidates: CandidateView[]; + /** registered (non-withdrawn) candidates */ + registeredCount: number; +}; + +export type WardView = { + /** the route token — zero-padded for Toronto ("01"), plain elsewhere ("7") */ + n: string; + /** the ward's integer, for matching against the API */ + number: number; + /** e.g. "Etobicoke North", or "Ward 7" where the city names no districts */ + name: string; + /** registered candidates across every council race this ward votes in */ + count: number; +}; + +export type ElectionView = { + slug: string; + /** e.g. "Toronto 2026 General Municipal Election" */ + name: string; + electionDateIso: string; + /** e.g. "Mon, Oct 26, 2026" */ + electionDateLabel: string; + /** e.g. "Aug 21, 2026"; null when the city hasn't published it */ + nominationCloseLabel: string | null; + daysUntil: number; + /** the mayoral field, sorted by last name */ + mayoral: CandidateView[]; + wards: WardView[]; + /** city-wide races other than mayor (e.g. the French-board trustees) */ + atLargeRaces: RaceView[]; + raceCount: number; + /** registered candidates across every race */ + candidateCount: number; +}; + +/** A region's ward roster, when it supplies its own. Toronto does, so its + * wards keep the official neighbourhood names and zero-padded route tokens + * from its map geometry; other regions are derived from the API. */ +export type WardRosterEntry = { n: string; number: number; name: string }; + +export type ElectionDataOptions = { + /** override the API-derived ward roster (Toronto passes its map geometry) */ + wardRoster?: WardRosterEntry[]; + /** hand-maintained extras for the mayoral field */ + mayoralEnrichment?: EnrichmentMap; + /** hand-maintained extras for a ward's council candidates */ + councillorEnrichment?: (wardNumber: number) => EnrichmentMap; +}; + +// ── Reshaping ────────────────────────────────────────────────────────────── + +/** + * The seat being contested, e.g. "City Councillor". The API hands over the + * parts, not the sentence: a councillor race is a city or a regional seat + * depending only on `office_body`. Where a city runs one council (Toronto, + * Hamilton) that distinction doesn't exist and the seat is just "Councillor". + */ +function seatName(race: ApiRace): string { + if (race.office_type === "mayor") return "Mayor"; + if (race.office_type === "trustee") return "Trustee"; + if (race.office_type === "councillor") { + if (/region/i.test(race.office_body ?? "")) return "Regional Councillor"; + return race.office_body ? "City Councillor" : "Councillor"; + } + // mp/mpp — future federal/provincial elections. + return race.office_type.toUpperCase(); +} + +/** + * The city wards a race covers. + * + * `ward_numbers` is authoritative wherever it's set. Councillor races that + * lack it (Toronto's) carry the ward in `district_number` instead. Trustee + * races get no such fallback on purpose: a school-board ward is its own + * numbering, so Toronto's "TDSB ward 5" would otherwise be read as city ward + * 5 and put the wrong trustees on a ward page. + */ +function wardsFor(race: ApiRace): number[] { + if (race.ward_numbers && race.ward_numbers.length > 0) return race.ward_numbers; + if ( + race.office_type === "councillor" && + race.district_type !== "at_large" && + race.district_number !== null + ) { + return [race.district_number]; + } + return []; +} + +/** The default tag for a candidate with no hand-written one. Mirrors what the + * Toronto pages have always shown, so its copy is unchanged. */ +function defaultTag(race: ApiRace): string { + return race.office_type === "mayor" ? "Declared" : "Registered"; +} + +function toCandidateView( + candidate: ApiCandidate, + race: ApiRace, + enrichment?: EnrichmentMap, +): CandidateView { + const name = displayName(candidate); + const curated = enrichment?.get(nameKey(name)); + return { + key: nameKey(name), + name, + initials: curated?.initials ?? initialsFor(name), + tag: curated?.tag ?? defaultTag(race), + bio: curated?.bio ?? "", + image: curated?.image ?? candidate.photo_url ?? undefined, + website: curated?.website ?? candidate.website ?? undefined, + withdrawn: candidate.status === "withdrawn", + socialLinks: candidate.social_links ?? [], + }; +} + +function toRaceView(race: ApiRace, enrichment?: EnrichmentMap): RaceView { + // Withdrawn candidates stay listed where a clerk keeps them, but sort last. + const candidates = byLastName( + race.candidates.map((c) => toCandidateView(c, race, enrichment)), + ).sort((a, b) => Number(a.withdrawn) - Number(b.withdrawn)); + + const seat = seatName(race); + return { + id: [race.office_type, race.office_body ?? "", race.district_number ?? "at-large"].join( + "|", + ), + seat, + label: race.district_name ? `${seat} — ${race.district_name}` : seat, + officeBody: race.office_body, + districtName: race.district_name, + wardNumbers: wardsFor(race), + atLarge: race.district_type === "at_large", + candidates, + registeredCount: candidates.filter((c) => !c.withdrawn).length, + }; +} + +/** The council races a ward votes in — one for Toronto and Hamilton, two for + * Brampton (its city and its regional seat). */ +function councilRaces(races: ApiRace[], wardNumber: number): ApiRace[] { + return races.filter( + (race) => + race.office_type === "councillor" && wardsFor(race).includes(wardNumber), + ); +} + +/** + * The ward roster, derived from the council races. Used for every region that + * doesn't supply its own: the ward's name is its district name only when the + * district *is* the ward (Hamilton's "Ward 3"), never when the district spans + * several (Brampton's "Wards 1, 5" is a district, not a ward name). + */ +function deriveWardRoster(races: ApiRace[]): WardRosterEntry[] { + const numbers = new Set(); + for (const race of races) { + if (race.office_type !== "councillor") continue; + for (const n of wardsFor(race)) numbers.add(n); + } + + return [...numbers] + .sort((a, b) => a - b) + .map((number) => { + const own = races.find( + (race) => + race.office_type === "councillor" && + race.district_name !== null && + wardsFor(race).length === 1 && + wardsFor(race)[0] === number, + ); + return { + n: String(number), + number, + name: own?.district_name ?? `Ward ${number}`, + }; + }); +} + +// ── Public API ───────────────────────────────────────────────────────────── + +/** + * One election reshaped for its landing page, or null when the API is + * unreachable — every caller renders a fallback rather than failing the route. + */ +export async function getElectionView( + slug: string, + options: ElectionDataOptions = {}, +): Promise { + const election = await fetchElection(slug); + if (!election) return null; + + const mayorRace = election.races.find((race) => race.office_type === "mayor"); + const mayoral = mayorRace + ? toRaceView(mayorRace, options.mayoralEnrichment).candidates.filter( + (c) => !c.withdrawn, + ) + : []; + + const roster = options.wardRoster ?? deriveWardRoster(election.races); + const wards: WardView[] = roster.map((ward) => ({ + ...ward, + count: councilRaces(election.races, ward.number).reduce( + (total, race) => + total + race.candidates.filter((c) => c.status === "active").length, + 0, + ), + })); + + // City-wide races beside the mayor's — the French-language school boards in + // Brampton and Hamilton. Toronto has none, so its page is unaffected. + const atLargeRaces = election.races + .filter((race) => race.district_type === "at_large" && race.office_type !== "mayor") + .map((race) => toRaceView(race)); + + return { + slug, + name: election.name, + electionDateIso: election.election_date, + electionDateLabel: LONG_DATE.format(parseDateOnly(election.election_date)), + nominationCloseLabel: election.nomination_close_date + ? SHORT_DATE.format(parseDateOnly(election.nomination_close_date)) + : null, + daysUntil: daysUntil(election.election_date), + mayoral, + wards, + atLargeRaces, + raceCount: election.races.length, + candidateCount: election.races.reduce( + (total, race) => + total + race.candidates.filter((c) => c.status === "active").length, + 0, + ), + }; +} + +export type WardDetail = { + ward: WardView; + /** the council seat(s) this ward elects — two in Brampton, one elsewhere */ + councilRaces: RaceView[]; + /** school-board races this ward votes in; empty for Toronto, whose trustee + * races carry no city-ward mapping */ + trusteeRaces: RaceView[]; + /** every ward, for the prev/next footer and the ward count */ + wards: WardView[]; +}; + +/** + * One ward's races, or null when the ward isn't in this election's roster (a + * bad URL) or the API is unreachable. `wardToken` is the route segment, so + * "01" and "1" both resolve. + */ +export async function getWardDetail( + slug: string, + wardToken: string, + options: ElectionDataOptions = {}, +): Promise { + const election = await fetchElection(slug); + if (!election) return null; + + const number = parseInt(wardToken, 10); + if (Number.isNaN(number)) return null; + + const view = await getElectionView(slug, options); + const ward = view?.wards.find((w) => w.number === number); + if (!view || !ward) return null; + + const enrichment = options.councillorEnrichment?.(number); + + return { + ward, + wards: view.wards, + councilRaces: councilRaces(election.races, number).map((race) => + toRaceView(race, enrichment), + ), + trusteeRaces: election.races + .filter( + (race) => + race.office_type === "trustee" && wardsFor(race).includes(number), + ) + .map((race) => toRaceView(race)), + }; +} diff --git a/src/lib/elections/registry.ts b/src/lib/elections/registry.ts index d17badd..e63a94a 100644 --- a/src/lib/elections/registry.ts +++ b/src/lib/elections/registry.ts @@ -1,13 +1,27 @@ -// The elections this site has built pages for, and the region-specific copy -// and paths the shared pledge flow needs. +// The elections this site has built pages for, and the region-specific paths +// and key dates the shared election pages need. // // York Factory knows about elections; only this file knows we have pages for // them. Adding a region means adding an entry here plus its route folder — -// the pledge flow, the /elections index and the API proxy all read from this. +// the pledge flow, the shared landing/ward pages, the /vote index and the +// API proxy all read from this. +// +// Every coverage region uses the same route shape, `//vote/`, with +// `/wards/:n` and `/pledge` beneath it. Page *copy* is not here: it lives +// beside each region's route and is passed into the shared components, so this +// stays small enough to import from client code. // // Residency for pledging is decided upstream, per jurisdiction // (york_factory Election::PledgeEligibility), so nothing here gates it. +/** A dated milestone in the election calendar, shown as a countdown. */ +export type ElectionKeyDate = { + /** "YYYY-MM-DD", parsed as local midnight by the countdown helpers */ + iso: string; + /** e.g. "Oct 6 – 11" — the human range or day beneath the number */ + label: string; +}; + export type SupportedElection = { /** York Factory election slug, e.g. "toronto-2026" */ slug: string; @@ -23,10 +37,32 @@ export type SupportedElection = { basePath: string; /** the pledge page; shared pledges live under `${pledgePath}/:slug` */ pledgePath: string; - /** e.g. "Monday, October 26" */ + /** voting day, "YYYY-MM-DD" — the main countdown's target */ + electionDateIso: string; + /** e.g. "Monday, October 26" — prose form, used mid-sentence */ voteDayLabel: string; + /** e.g. "Mon, Oct 26" — compact form, used in stat cells */ + electionDayLabel: string; /** e.g. "10:00 a.m. – 8:00 p.m." */ pollHoursLabel: string; + /** first day of advance voting; omitted until the city publishes it */ + advanceVote?: ElectionKeyDate; + /** deadline to apply to vote by mail; omitted until published */ + mailIn?: ElectionKeyDate; + /** + * Palette class wrapped around this election's pages. Only Toronto sets one + * — `.theme-election` is its blue palette, matching the rest of /toronto. + * Every other region uses the site's own auburn-and-linen theme, so it + * leaves this unset rather than borrowing Toronto's colours. + */ + themeClass?: string; + /** + * Whether to offer the postal-code → ward lookup on this election's page. + * Off unless we've confirmed the upstream lookup returns *this* city's + * municipal wards — a lookup that silently resolves to another city's ward + * number would match our roster and show a confidently wrong ward. + */ + wardLookup: boolean; }; const TORONTO_2026: SupportedElection = { @@ -35,10 +71,18 @@ const TORONTO_2026: SupportedElection = { cityLabel: "Toronto", regionLabel: "City of Toronto", eyebrow: "Municipal Election · City of Toronto", - basePath: "/toronto/elections/2026", - pledgePath: "/toronto/elections/2026/pledge", + basePath: "/toronto/vote/2026", + pledgePath: "/toronto/vote/2026/pledge", + electionDateIso: "2026-10-26", voteDayLabel: "Monday, October 26", + electionDayLabel: "Mon, Oct 26", pollHoursLabel: "10:00 a.m. – 8:00 p.m.", + // Per the City Clerk's 2026 election calendar: + // https://www.toronto.ca/city-government/elections/key-dates/ + advanceVote: { iso: "2026-10-06", label: "Oct 6 – 11" }, + mailIn: { iso: "2026-09-24", label: "Thu, Sept 24" }, + themeClass: "theme-election", + wardLookup: true, }; const BRAMPTON_2026: SupportedElection = { @@ -47,15 +91,54 @@ const BRAMPTON_2026: SupportedElection = { cityLabel: "Brampton", regionLabel: "City of Brampton", eyebrow: "Municipal Election · City of Brampton", - basePath: "/elections/brampton/2026", - pledgePath: "/elections/brampton/2026/pledge", + basePath: "/brampton/vote/2026", + pledgePath: "/brampton/vote/2026/pledge", + electionDateIso: "2026-10-26", + voteDayLabel: "Monday, October 26", + electionDayLabel: "Mon, Oct 26", + pollHoursLabel: "10:00 a.m. – 8:00 p.m.", + // Brampton hasn't published its advance-vote or vote-by-mail dates yet; + // those countdowns stay off the page rather than guess at them. + wardLookup: false, +}; + +const HAMILTON_2026: SupportedElection = { + slug: "hamilton-2026", + jurisdictionSlug: "hamilton", + cityLabel: "Hamilton", + regionLabel: "City of Hamilton", + eyebrow: "Municipal Election · City of Hamilton", + basePath: "/hamilton/vote/2026", + pledgePath: "/hamilton/vote/2026/pledge", + electionDateIso: "2026-10-26", + voteDayLabel: "Monday, October 26", + electionDayLabel: "Mon, Oct 26", + pollHoursLabel: "10:00 a.m. – 8:00 p.m.", + // As with Brampton — not yet published by the city. + wardLookup: false, +}; + +const OTTAWA_2026: SupportedElection = { + slug: "ottawa-2026", + jurisdictionSlug: "ottawa", + cityLabel: "Ottawa", + regionLabel: "City of Ottawa", + eyebrow: "Municipal Election · City of Ottawa", + basePath: "/ottawa/vote/2026", + pledgePath: "/ottawa/vote/2026/pledge", + electionDateIso: "2026-10-26", voteDayLabel: "Monday, October 26", + electionDayLabel: "Mon, Oct 26", pollHoursLabel: "10:00 a.m. – 8:00 p.m.", + // As with Brampton and Hamilton — not yet published by the city. + wardLookup: false, }; export const SUPPORTED_ELECTIONS: Record = { [TORONTO_2026.slug]: TORONTO_2026, [BRAMPTON_2026.slug]: BRAMPTON_2026, + [HAMILTON_2026.slug]: HAMILTON_2026, + [OTTAWA_2026.slug]: OTTAWA_2026, }; /** The election the pledge flow assumes when a caller names none — Toronto, diff --git a/src/lib/elections/ward-lookup.ts b/src/lib/elections/ward-lookup.ts new file mode 100644 index 0000000..f644d9e --- /dev/null +++ b/src/lib/elections/ward-lookup.ts @@ -0,0 +1,38 @@ +// Postal code → ward lookup — shared types. +// +// Mirrors york_factory's GET /api/v1/geo/ward_lookup. See +// docs/WARD_LOOKUP_API_SPEC.md. The lookup is a best guess, not a fact: a +// postal code's stored point is the centroid of its delivery points, so a code +// straddling a ward line can resolve to the neighbouring ward. Present results +// as provisional and never auto-navigate on them. + +export type WardLookupReason = + | "resolved" + | "malformed_postal_code" + | "unknown_postal_code" + | "outside_boundary" + | "boundary_data_unavailable"; + +export type Ward = { + /** internal id, "-" — do not parse or route on it */ + geo_uid: string; + /** the integer to route on; null only for named (school board) wards */ + ward_number: number | null; + name_en: string; + name_fr: string | null; + boundary_type: "ward" | "school_board_ward"; + /** warehouse load tag, not the ward model's vintage — never display */ + census_year: number; +}; + +export type WardLookupResponse = { + /** normalized to "M4C 1S9"; null when the input was malformed */ + postal_code: string | null; + /** Canada Post's city label, uppercase — display only, never a jurisdiction */ + city: string | null; + found: boolean; + reason: WardLookupReason; + /** true where we could not judge, as opposed to judging them outside */ + unverified: boolean; + ward: Ward | null; +};