diff --git a/next.config.ts b/next.config.ts index 4321dd4..f140d67 100644 --- a/next.config.ts +++ b/next.config.ts @@ -53,8 +53,8 @@ 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 + // Election coverage lives under /vote: the index at /vote, and Toronto's + // pages at /toronto/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 @@ -64,24 +64,63 @@ const nextConfig: NextConfig = { destination: "/vote", permanent: true, }, + // Only Toronto's coverage is live. Brampton, Hamilton and Ottawa are + // built but switched off (registry `hidden`), so every URL under them — + // including the old /elections shapes — lands on the /vote index rather + // than a page we aren't standing behind. These come first so they win + // over the Toronto-shaped rules below; none of them chain. { - source: "/elections/brampton/2026", - destination: "/brampton/vote/2026", - permanent: true, + source: "/:city(brampton|hamilton|ottawa)/vote/:path*", + destination: "/vote", + permanent: false, + }, + { + source: "/:city(brampton|hamilton|ottawa)/vote", + destination: "/vote", + permanent: false, + }, + { + source: "/:city(brampton|hamilton|ottawa)/elections/:path*", + destination: "/vote", + permanent: false, + }, + { + source: "/:city(brampton|hamilton|ottawa)/elections", + destination: "/vote", + permanent: false, }, { source: "/elections/brampton/2026/:path*", - destination: "/brampton/vote/2026/:path*", - permanent: true, + destination: "/vote", + permanent: false, + }, + { + source: "/elections/brampton/2026", + destination: "/vote", + permanent: false, + }, + // Toronto's get-involved page is switched off. It stays in the repo but + // sends people to the election landing instead. The legacy /elections + // shape gets its own rule so it lands there directly rather than + // chaining through the generic /toronto/elections/:path* rule below. + { + source: "/toronto/vote/get-involved", + destination: "/toronto/vote/2026", + permanent: false, + }, + { + source: "/toronto/elections/get-involved", + destination: "/toronto/vote/2026", + permanent: false, }, { - source: "/:city(toronto|brampton|hamilton|ottawa)/elections", - destination: "/:city/vote", + source: "/toronto/elections", + destination: "/toronto/vote", permanent: true, }, { - source: "/:city(toronto|brampton|hamilton|ottawa)/elections/:path*", - destination: "/:city/vote/:path*", + source: "/toronto/elections/:path*", + destination: "/toronto/vote/:path*", permanent: true, }, ]; diff --git a/src/app/toronto/vote/2026/data.ts b/src/app/toronto/vote/2026/data.ts index dce5fe9..e69ab1b 100644 --- a/src/app/toronto/vote/2026/data.ts +++ b/src/app/toronto/vote/2026/data.ts @@ -14,6 +14,7 @@ import { WARD_SHAPES } from "./wardGeo"; import { getElectionView, + getNominationCloseLabel, getWardDetail, initialsFor, nameKey, @@ -92,7 +93,7 @@ function fallbackView(): ElectionView { name: "Toronto 2026 General Municipal Election", electionDateIso: ELECTION.electionDateIso, electionDateLabel: "Mon, Oct 26, 2026", - nominationCloseLabel: null, + nominationCloseLabel: getNominationCloseLabel(ELECTION.slug, null), daysUntil: 0, mayoral: byLastName(MAYORAL_CANDIDATES).map(toView), wards: WARD_ROSTER.map((ward) => ({ diff --git a/src/app/vote/data.ts b/src/app/vote/data.ts index c98f030..0061233 100644 --- a/src/app/vote/data.ts +++ b/src/app/vote/data.ts @@ -10,6 +10,7 @@ import { fetchElections, type ApiElectionSummary, } from "@/lib/api/elections"; +import { getNominationCloseLabel } from "@/lib/elections/election-data"; import { SUPPORTED_ELECTIONS } from "@/lib/elections/registry"; export type ActiveElection = { @@ -54,12 +55,6 @@ const LONG_DATE = new Intl.DateTimeFormat("en-CA", { year: "numeric", }); -const SHORT_DATE = new Intl.DateTimeFormat("en-CA", { - month: "short", - day: "numeric", - year: "numeric", -}); - /** "municipal" → "Municipal Election"; falls back to a generic label. */ function kindLabel(kind: string): string { if (!kind) return "Election"; @@ -79,9 +74,10 @@ async function describe(summary: ApiElectionSummary): Promise { jurisdictionName: summary.jurisdiction.name, electionDateIso: summary.election_date, electionDateLabel: LONG_DATE.format(parseDateOnly(summary.election_date)), - nominationCloseLabel: summary.nomination_close_date - ? SHORT_DATE.format(parseDateOnly(summary.nomination_close_date)) - : null, + nominationCloseLabel: getNominationCloseLabel( + summary.slug, + summary.nomination_close_date, + ), daysUntil: daysUntil(summary.election_date), // The registry is the list of elections we have pages for; anything else // York Factory knows about is listed here without a link. @@ -96,6 +92,12 @@ async function describe(summary: ApiElectionSummary): Promise { }; } +/** Whether we've switched this election's coverage off — hidden regions are + * left off this index entirely, not just unlinked. */ +function isHidden(slug: string): boolean { + return SUPPORTED_ELECTIONS[slug]?.hidden === true; +} + /** * Every election whose vote hasn't happened yet, soonest first. Empty when * the API is unreachable — the page renders a fallback in that case. @@ -104,6 +106,7 @@ export async function getActiveElections( now: Date = new Date(), ): Promise { const summaries = (await fetchElections()) + .filter((e) => !isHidden(e.slug)) .filter((e) => differenceInCalendarDays(parseDateOnly(e.election_date), now) >= 0) .sort((a, b) => a.election_date.localeCompare(b.election_date)); diff --git a/src/lib/elections/election-data.ts b/src/lib/elections/election-data.ts index e0aeb27..4e98f40 100644 --- a/src/lib/elections/election-data.ts +++ b/src/lib/elections/election-data.ts @@ -21,6 +21,7 @@ import { fetchElection, type ApiCandidate, type ApiRace } from "@/lib/api/elections"; import { daysUntil, parseDateOnly } from "./dates"; +import { SUPPORTED_ELECTIONS } from "./registry"; export { daysUntil, parseDateOnly }; @@ -96,6 +97,23 @@ const SHORT_DATE = new Intl.DateTimeFormat("en-CA", { year: "numeric", }); +/** + * The nomination-close date to show, as "Aug 21, 2026". A registry + * `nominationCloseIso` wins over the API's date — we hardcode a date when the + * city has published one York Factory hasn't caught up to — so this is the one + * place the two sources are reconciled. Null when neither has a date. + * + * Looked up by exact slug, not `getElection`, so an unrecognised slug gets no + * date rather than quietly inheriting Toronto's. + */ +export function getNominationCloseLabel( + slug: string, + apiIso: string | null, +): string | null { + const iso = SUPPORTED_ELECTIONS[slug]?.nominationCloseIso ?? apiIso; + return iso ? SHORT_DATE.format(parseDateOnly(iso)) : null; +} + // ── Views ────────────────────────────────────────────────────────────────── /** Hand-maintained extras for a candidate, matched by `nameKey`. Toronto is @@ -360,9 +378,10 @@ export async function getElectionView( 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, + nominationCloseLabel: getNominationCloseLabel( + slug, + election.nomination_close_date, + ), daysUntil: daysUntil(election.election_date), mayoral, wards, diff --git a/src/lib/elections/registry.ts b/src/lib/elections/registry.ts index e63a94a..b037d09 100644 --- a/src/lib/elections/registry.ts +++ b/src/lib/elections/registry.ts @@ -45,6 +45,13 @@ export type SupportedElection = { electionDayLabel: string; /** e.g. "10:00 a.m. – 8:00 p.m." */ pollHoursLabel: string; + /** + * Nomination day, "YYYY-MM-DD". Overrides York Factory's + * `nomination_close_date` wherever we show it — set this when the city has + * published a date the upstream record hasn't caught up to yet, and drop it + * again once upstream agrees. + */ + nominationCloseIso?: string; /** first day of advance voting; omitted until the city publishes it */ advanceVote?: ElectionKeyDate; /** deadline to apply to vote by mail; omitted until published */ @@ -63,6 +70,13 @@ export type SupportedElection = { * number would match our roster and show a confidently wrong ward. */ wardLookup: boolean; + /** + * Whether this region's coverage is switched off. The config stays here so + * the routes keep type-checking and the pages can be turned back on in one + * line, but a hidden election is dropped from the /vote index and its URLs + * redirect there (see next.config.ts). Only Toronto is live right now. + */ + hidden?: boolean; }; const TORONTO_2026: SupportedElection = { @@ -79,6 +93,7 @@ const TORONTO_2026: SupportedElection = { 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/ + nominationCloseIso: "2026-08-21", advanceVote: { iso: "2026-10-06", label: "Oct 6 – 11" }, mailIn: { iso: "2026-09-24", label: "Thu, Sept 24" }, themeClass: "theme-election", @@ -100,6 +115,7 @@ const BRAMPTON_2026: SupportedElection = { // 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, + hidden: true, }; const HAMILTON_2026: SupportedElection = { @@ -116,6 +132,7 @@ const HAMILTON_2026: SupportedElection = { pollHoursLabel: "10:00 a.m. – 8:00 p.m.", // As with Brampton — not yet published by the city. wardLookup: false, + hidden: true, }; const OTTAWA_2026: SupportedElection = { @@ -132,6 +149,7 @@ const OTTAWA_2026: SupportedElection = { pollHoursLabel: "10:00 a.m. – 8:00 p.m.", // As with Brampton and Hamilton — not yet published by the city. wardLookup: false, + hidden: true, }; export const SUPPORTED_ELECTIONS: Record = {