From b4d2c53c2e7a2b96e7690c13ff4545203561a55b Mon Sep 17 00:00:00 2001 From: Mikaal Naik Date: Wed, 19 Aug 2026 13:21:10 -0400 Subject: [PATCH 1/2] Show only Toronto election coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brampton, Hamilton and Ottawa have built-out election pages, but Toronto is the only race we're standing behind right now. Rather than delete the work, mark those regions `hidden` in the elections registry: the configs stay so their routes keep type-checking and each one can be turned back on by dropping a single line. Hidden regions are left off the /vote index entirely — not listed without a link — and every URL beneath them redirects to /vote, along with their legacy /elections shapes. Toronto's /vote/get-involved page is switched off the same way, redirecting to the election landing. All of these redirects are temporary (307), so nothing sticks in a browser cache when a region comes back. The generic /:city(toronto|brampton|hamilton|ottawa)/elections rules are narrowed to Toronto and the hidden cities get their own rules ahead of them, so no redirect chains through another. Co-Authored-By: Claude Opus 5 --- next.config.ts | 61 ++++++++++++++++++++++++++++------- src/app/vote/data.ts | 7 ++++ src/lib/elections/registry.ts | 10 ++++++ 3 files changed, 67 insertions(+), 11 deletions(-) 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/vote/data.ts b/src/app/vote/data.ts index c98f030..87aa0cf 100644 --- a/src/app/vote/data.ts +++ b/src/app/vote/data.ts @@ -96,6 +96,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 +110,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/registry.ts b/src/lib/elections/registry.ts index e63a94a..de6b514 100644 --- a/src/lib/elections/registry.ts +++ b/src/lib/elections/registry.ts @@ -63,6 +63,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 = { @@ -100,6 +107,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 +124,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 +141,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 = { From d5e03ceec78e4425018f945499f4537ac75b6b97 Mon Sep 17 00:00:00 2001 From: Mikaal Naik Date: Wed, 19 Aug 2026 13:28:01 -0400 Subject: [PATCH 2/2] Hardcode Toronto's nomination close date MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The City Clerk's calendar puts nomination day at August 21, 2026, but York Factory still carries September 18 on toronto-2026, so /vote and every ward page were showing the wrong date. Add an optional `nominationCloseIso` to the elections registry, set for Toronto, and reconcile it against the API in one helper — a registry date wins wherever we render one. The helper looks the election up by exact slug rather than through getElection, so an unrecognised slug gets no date instead of quietly inheriting Toronto's. Toronto's API-unreachable fallback now carries the date too; it had nothing to fall back on before and dropped the date from the copy. Drop the override once upstream agrees. Co-Authored-By: Claude Opus 5 --- src/app/toronto/vote/2026/data.ts | 3 ++- src/app/vote/data.ts | 14 +++++--------- src/lib/elections/election-data.ts | 25 ++++++++++++++++++++++--- src/lib/elections/registry.ts | 8 ++++++++ 4 files changed, 37 insertions(+), 13 deletions(-) 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 87aa0cf..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. 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 de6b514..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 */ @@ -86,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",