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
15 changes: 15 additions & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,21 @@ const nextConfig: NextConfig = {
destination: "/vote",
permanent: false,
},
// /issues and /mayor are nothing but the candidates' answers, which are
// hidden until the questionnaire launches (`questionnaireHidden` in the
// election registry). Both pages stay in the repo as built; each lands
// on the nearest ballot instead. Temporary, so a 307: a 308 would sit in
// a reader's browser and skip the server after launch.
{
source: "/toronto/vote/2026/issues",
destination: "/toronto/vote/2026",
permanent: false,
},
{
source: "/toronto/vote/2026/mayor",
destination: "/toronto/vote/2026/mayor/candidates",
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
Expand Down
127 changes: 16 additions & 111 deletions src/app/toronto/vote/2026/candidates/[candidate]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,15 @@ import type { Metadata } from "next";
import Image from "next/image";
import Link from "next/link";
import { notFound } from "next/navigation";
import { ArrowLeft, ArrowRight, ArrowUpRight } from "lucide-react";
import { ArrowLeft, ArrowUpRight } from "lucide-react";

import { CandidateSiteLink } from "@/components/elections/CandidateSiteLink";
import {
QuestionnaireCards,
questionnaireHeadings,
} from "@/components/elections/QuestionnaireCards";
import { QuestionnaireRail } from "@/components/elections/QuestionnaireRail";
import CountdownDays from "@/components/elections/CountdownDays";
import { IncumbentBadge } from "@/components/elections/ElectionLanding";
import {
BIO_QUESTION_ID,
comparedQuestions,
} from "@/lib/elections/candidate-answers";
import { BIO_QUESTION_ID } from "@/lib/elections/candidate-answers";
import { rosterSurvey } from "@/lib/elections/survey-answers";
import { daysUntil } from "@/lib/elections/dates";
import { firstName, possessive } from "@/lib/elections/names";
import { firstName } from "@/lib/elections/names";
import type { CandidateProfile, RaceView } from "@/lib/elections/election-data";
import {
ELECTION,
Expand Down Expand Up @@ -103,7 +95,7 @@ export async function generateMetadata({

return {
title: `${name} — ${race}`,
description: `${name} is a registered candidate for ${race} in Toronto's October 26, 2026 municipal election. Their campaign site, and how they answered our questionnaire.`,
description: `${name} is a registered candidate for ${race} in Toronto's October 26, 2026 municipal election — the ward they are standing in, and how to reach their campaign.`,
alternates: { canonical: `${ELECTION.basePath}/candidates/${slug}` },
openGraph: {
title: `${name} — Toronto 2026 Election`,
Expand All @@ -127,14 +119,14 @@ export default async function CandidatePage({
const race = races[0];

/* The one-candidate case of what every roster page does: the questionnaire
is fetched for the whole election — the counts beside each answer are the
field's split — and narrowed to this candidate by key. A missing
questionnaire costs the answers, not the page. */
const { answers, written } = await rosterSurvey(
ELECTION.slug,
new Set([candidate.key]),
);
const surveyAnswers = answers[candidate.key];
is fetched for the whole election and narrowed to this candidate by key. A
missing questionnaire costs the prose, not the page.

Only the prose is read off it. The answers are not published yet, so the
page has no cards to draw — what it still wants is the bio a candidate
wrote in the questionnaire's own words, which `rosterSurvey` is left
holding while `questionnaireHidden` is set. */
const { written } = await rosterSurvey(ELECTION.slug, new Set([candidate.key]));

/* What the candidate wrote, as against what they picked.
55 of the 58 candidates who returned the questionnaire wrote a bio in it,
Expand All @@ -144,12 +136,6 @@ export default async function CandidatePage({
candidates.ts — which is empty for all but a handful. Theirs is a self
description and ours is not, so it is attributed rather than merged into
the same paragraph. */
/* The answers come back empty either way, so the page has to ask rather
than infer — see `questionnaireHidden` in the registry. The bio survives
it: a self-description is not one of the positions being held back, and
without it most of these pages have nothing on them. */
const withheld = ELECTION.questionnaireHidden ?? false;

const prose = written[candidate.key] ?? [];
const selfBio = prose.find(
(entry) => entry.questionId === BIO_QUESTION_ID,
Expand All @@ -158,26 +144,6 @@ export default async function CandidatePage({
(entry) => entry.questionId !== BIO_QUESTION_ID,
);

/* The ward pages' cards, given a roster of one.
`comparedQuestions` is the same pivot a ward runs — question first, the
candidates filed under the answer they gave — so this page's cards are
literally the ward's cards with a field of one person in them. A card
therefore shows the one option this candidate picked, in the option's own
colour, with their note printed in the open underneath their name plate.
What it cannot show is the split, since the other candidates are not in
the roster; "How the whole city answered" at the foot is the way to it. */
const roster = [
{
key: candidate.key,
name: candidate.name,
website: candidate.website,
bio: candidate.bio || undefined,
},
];
const groups = surveyAnswers
? comparedQuestions([surveyAnswers], roster)
: [];

const raceKind = profile.officeTypes[0] === "mayor" ? "mayor" : profile.officeTypes[0] === "trustee" ? "trustee" : "councillor";

/* Where the rest of this candidate's ballot line is — the ward page for a
Expand Down Expand Up @@ -456,61 +422,6 @@ export default async function CandidatePage({
</section>
)}

{/* ── Questionnaire ──────────────────────────────────── */}
<section className="px-6 md:px-14 py-9">
<p className="type-label text-accent mb-3">Our questionnaire</p>
<h2 className="font-sans font-medium leading-[1.05] tracking-[-0.03em] text-[clamp(1.6rem,2.6vw,2.1rem)] mb-3">
{surveyAnswers
? `Where ${candidate.name} stands`
: withheld
? /* Held back, which is not the same as never sent — and this
page of all of them must not confuse the two. "Yet to
answer", over a named person's photograph, is a claim
about that person that we would be making for them. The
heading carries it alone: a line under it repeating the
same four words is the eyebrow, the heading and the body
all saying one thing. */
"Candidate survey coming soon"
: "Yet to answer"}
</h2>

{surveyAnswers ? (
<>
<p className="font-serif text-[1.05rem] leading-[1.5] text-dark/85 max-w-[62ch] text-pretty mb-8">
{possessive(candidate.name)} own answers to the questions we put
to every candidate, published as given — including, where they
wrote one, their reasoning in their own words.
</p>
<QuestionnaireRail headings={questionnaireHeadings(groups)}>
<QuestionnaireCards
groups={groups}
respondents={roster}
silent={[]}
issuesHref={`${ELECTION.basePath}/issues`}
answerNote={
`Each card is one question, with ${candidate.name} filed ` +
"under the answer they gave and their own words underneath " +
"it. The other options offered are not shown, nor how the " +
"rest of the field answered."
}
/>
</QuestionnaireRail>
</>
) : withheld ? null : (
<p className="font-serif text-[1.05rem] leading-[1.5] text-dark/85 max-w-[62ch] text-pretty">
{candidate.name} has not returned our questionnaire. We publish
answers as they arrive, so check back — and{" "}
<Link
href={`${ELECTION.basePath}/issues`}
className="text-accent hover:underline"
>
see where the rest of the field stands
</Link>{" "}
in the meantime.
</p>
)}
</section>

{/* ── Source note ────────────────────────────────────── */}
<section className="px-6 md:px-14 py-4 border-t-2 border-dark">
<p className="type-label-sm text-text-muted max-w-[80ch] text-pretty">
Expand All @@ -521,7 +432,10 @@ export default async function CandidatePage({
</section>

{/* ── Elsewhere ──────────────────────────────────────── */}
<section className="border-t border-dark grid md:grid-cols-2">
{/* The rest of this candidate's ballot line, and nothing else. The
second way out of here was the field read question by question,
which is switched off with the rest of the questionnaire. */}
<section className="border-t border-dark">
<Link
href={ballotHref}
className="group px-6 md:px-14 py-6 flex items-center gap-2.5 transition-colors hover:bg-linen-50"
Expand All @@ -531,15 +445,6 @@ export default async function CandidatePage({
{ballotLabel}
</span>
</Link>
<Link
href={`${ELECTION.basePath}/issues`}
className="group px-6 md:px-14 py-6 flex items-center justify-between gap-4 border-t md:border-t-0 md:border-l border-border-light transition-colors hover:bg-linen-50"
>
<span className="font-sans font-medium text-[1.15rem] tracking-[-0.015em]">
Where the whole field stands
</span>
<ArrowRight className="size-4 flex-none text-text-secondary transition-transform group-hover:translate-x-0.5" />
</Link>
</section>
</div>
</div>
Expand Down
102 changes: 26 additions & 76 deletions src/app/toronto/vote/2026/mayor/candidates/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ import { CandidateNameLink } from "@/components/elections/CandidateNameLink";
import CountdownDays from "@/components/elections/CountdownDays";
import { surveyRoster } from "@/lib/elections/candidate-answers";
import { daysUntil } from "@/lib/elections/dates";
import { rosterSurvey } from "@/lib/elections/survey-answers";
import { ANSWERS_WITHHELD } from "@/lib/elections/registry";
import type { CandidateView } from "@/lib/elections/election-data";
import { ELECTION, getToronto2026 } from "../../data";

Expand All @@ -27,14 +25,14 @@ import { ELECTION, getToronto2026 } from "../../data";
* questions by every column — which answers "what did they say" and never
* answers "who is running". A reader who wants the ballot got a grid.
*
* ANSWERED FIRST, AND SAID SO
* The field splits in two: the candidates who returned our questionnaire and
* the candidates who have not. That is the most useful sort available — it
* is the difference between a name and a position — and `surveyRoster`
* already orders it that way, so the page prints the boundary rather than
* leaving the reader to infer it from a missing link. Within each group,
* surname order, because the alternative is a ranking nobody asked us to
* make.
* ONE LIST, IN SURNAME ORDER
* The field used to split in twothe candidates who returned our
* questionnaire and the candidates who had not — which is the most useful
* sort available while those answers are published. They are not, until the
* questionnaire launches, so the split would be a scoreboard reading nil-all
* and every word of it our doing. Flat, the page is what it says it is:
* everyone running, in surname order, because the alternative is a ranking
* nobody asked us to make.
*
* Withdrawn candidates keep a group at the foot rather than vanishing. Some
* clerks never drop them, they appear on lists elsewhere, and a reader who
Expand All @@ -44,38 +42,23 @@ import { ELECTION, getToronto2026 } from "../../data";
export const metadata: Metadata = {
title: "Every candidate for Mayor of Toronto",
description:
"The full field for Mayor of Toronto in the October 26, 2026 election: every registered candidate, their campaign site, and whether they answered our questionnaire.",
"The full field for Mayor of Toronto in the October 26, 2026 election: every registered candidate, in surname order, with their campaign site.",
alternates: { canonical: `${ELECTION.basePath}/mayor/candidates` },
openGraph: {
title: "Every candidate for Mayor — Toronto 2026 Election",
description:
"The full field for Mayor of Toronto: who is running, and who has told us where they stand.",
"The full field for Mayor of Toronto: everyone registered to run for the city's top job.",
type: "website",
},
};

export default async function MayoralCandidatesPage() {
const view = await getToronto2026();

/* Same two-step the questionnaire grid uses: the roster names the field, and
the survey fetch is keyed to it, so a response from someone who is not on
the ballot cannot put a stranger on this page. */
const named = surveyRoster(view.mayoral);
const { answers } = await rosterSurvey(
ELECTION.slug,
new Set(named.map((candidate) => candidate.key)),
);
const roster = surveyRoster(view.mayoral, answers);

/* One list, not two, while the answers are withheld — see
`questionnaireHidden` in the registry. The split here is by whether a
candidate wrote back, so with nothing to read back it collapses on its
own: `answered` empties and the entire ballot lands under "Yet to
respond", which is a scoreboard reading nil-all and every word of it our
doing. Flat, the page is what it says it is — everyone running. */
const withheld = ELECTION.questionnaireHidden ?? false;
const answered = withheld ? [] : roster.filter((candidate) => candidate.answers);
const quiet = withheld ? roster : roster.filter((candidate) => !candidate.answers);
/* The ballot, in surname order. `surveyRoster` with no answers to sort by is
exactly that — it is the same call the ward pages make, and the ordering
is the part of it this page still needs. */
const roster = surveyRoster(view.mayoral);
const withdrawn = view.mayoral.filter((candidate) => candidate.withdrawn);
const sites = roster.filter((candidate) => candidate.website).length;

Expand All @@ -102,20 +85,13 @@ export default async function MayoralCandidatesPage() {
</h1>
<p className="font-serif text-[1.05rem] leading-[1.5] text-dark/85 max-w-[58ch] text-pretty">
The one race every Toronto voter votes in, and the longest ballot in
the city.{" "}
{roster.length > 0 && answered.length > 0
? `${roster.length} candidates have registered; ${answered.length} of them have told us where they stand.`
: `${roster.length} candidates have registered.`}

the city. {roster.length} candidates have registered.
</p>
</section>

{/* ── Key stats ──────────────────────────────────────── */}
<section className="grid grid-cols-2 md:grid-cols-4 border-b-2 border-dark">
<section className="grid grid-cols-2 md:grid-cols-3 border-b-2 border-dark">
<Stat value={roster.length} label="On the ballot" />
{/* A count of the answers, which at nil reads as the claim that
nobody gave any. */}
{!withheld && <Stat value={answered.length} label="Answered us" />}
<Stat value={sites} label="With a campaign site" />
<div className="px-6 py-4 md:px-14 border-b md:border-b-0 border-border-light">
<CountdownDays
Expand All @@ -129,35 +105,15 @@ export default async function MayoralCandidatesPage() {
</div>
</section>

{/* ── Answered ───────────────────────────────────────── */}
{answered.length > 0 && (
<section>
<GroupHead
eyebrow="On the record"
title="Answered our questionnaire"
blurb={`${answered.length} of the ${roster.length} candidates for mayor returned the questionnaire. Every answer is published, question by question.`}
action={{
label: "See how they answered",
href: `${ELECTION.basePath}/mayor`,
}}
/>
<Roster candidates={answered} />
</section>
)}

{/* ── Yet to respond, or simply the ballot ───────────── */}
{quiet.length > 0 && (
{/* ── The ballot ────────────────────────────────────── */}
{roster.length > 0 && (
<section>
<GroupHead
eyebrow="Registered"
title={withheld ? "On the ballot" : "Yet to respond"}
blurb={
withheld
? `Every candidate registered for mayor, in surname order. ${ANSWERS_WITHHELD}`
: "On the ballot, and yet to tell us where they stand. We publish answers as they arrive, so this list shrinks through the campaign."
}
title="On the ballot"
blurb="Every candidate registered for mayor, in surname order, with the way to their own campaign where they have published one."
/>
<Roster candidates={quiet} />
<Roster candidates={roster} />
</section>
)}

Expand Down Expand Up @@ -186,19 +142,13 @@ export default async function MayoralCandidatesPage() {
</section>

{/* ── Elsewhere ──────────────────────────────────────── */}
<section className="border-t border-dark grid md:grid-cols-2">
<Link
href={`${ELECTION.basePath}/issues`}
className="group px-6 md:px-14 py-6 flex items-center justify-between gap-4 transition-colors hover:bg-linen-50"
>
<span className="font-sans font-medium text-[1.15rem] tracking-[-0.015em]">
Where the whole field stands
</span>
<ArrowRight className="size-4 flex-none text-text-secondary transition-transform group-hover:translate-x-0.5" />
</Link>
{/* One way on, not two: the other was the field read question by
question, which is switched off with the rest of the
questionnaire. */}
<section className="border-t border-dark">
<Link
href={`${ELECTION.basePath}#wards`}
className="group px-6 md:px-14 py-6 flex items-center gap-2.5 border-t md:border-t-0 md:border-l border-border-light transition-colors hover:bg-linen-50"
className="group px-6 md:px-14 py-6 flex items-center gap-2.5 transition-colors hover:bg-linen-50"
>
<ArrowLeft className="size-3.5 text-text-secondary" />
<span className="font-sans font-medium text-[1.15rem] tracking-[-0.015em]">
Expand Down
Loading
Loading