Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 50 additions & 11 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 /<city>/vote/<year>. Two earlier shapes are still out in the
// Election coverage lives under /vote: the index at /vote, and Toronto's
// pages at /toronto/vote/<year>. 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
Expand All @@ -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,
},
];
Expand Down
3 changes: 2 additions & 1 deletion src/app/toronto/vote/2026/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import { WARD_SHAPES } from "./wardGeo";
import {
getElectionView,
getNominationCloseLabel,
getWardDetail,
initialsFor,
nameKey,
Expand Down Expand Up @@ -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) => ({
Expand Down
21 changes: 12 additions & 9 deletions src/app/vote/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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";
Expand All @@ -79,9 +74,10 @@ async function describe(summary: ApiElectionSummary): Promise<ActiveElection> {
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.
Expand All @@ -96,6 +92,12 @@ async function describe(summary: ApiElectionSummary): Promise<ActiveElection> {
};
}

/** 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.
Expand All @@ -104,6 +106,7 @@ export async function getActiveElections(
now: Date = new Date(),
): Promise<ActiveElection[]> {
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));

Expand Down
25 changes: 22 additions & 3 deletions src/lib/elections/election-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
18 changes: 18 additions & 0 deletions src/lib/elections/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand All @@ -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 = {
Expand All @@ -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",
Expand All @@ -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 = {
Expand All @@ -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 = {
Expand All @@ -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<string, SupportedElection> = {
Expand Down
Loading