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
10 changes: 10 additions & 0 deletions src/app/api/elections/candidate-responses/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
} from "@/lib/elections/candidate-responses";
import {
DEFAULT_ELECTION_SLUG,
getElection,
isSupportedElection,
} from "@/lib/elections/registry";
import {
Expand Down Expand Up @@ -53,6 +54,15 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ error: "Invalid ward" }, { status: 400 });
}

/* The answers are held back everywhere, and "everywhere" has to include the
door the pages do not come through. This is a read proxy for exactly the
answers the ward and mayoral pages have stopped drawing, and left open it
would serve the whole comparison as JSON to anyone who asked. Its only
caller is the survey page, which is closed too. */
if (getElection(election).questionnaireHidden) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}

const toronto = election === TORONTO_2026_SLUG;
const wardToken = ward.padStart(2, "0");

Expand Down
11 changes: 11 additions & 0 deletions src/app/api/elections/survey/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,17 @@ export async function POST(req: NextRequest) {
: DEFAULT_ELECTION_SLUG;
const config = getElection(electionSlug);

/* A closed survey does not take answers, whoever is asking.
The page 404s and every link to it is gone, but neither of those closes
this: a tab opened before the survey came down still holds a filled-in
form and a working endpoint, and a submission accepted here is a
response recorded against a survey we have stopped running. Same status
as the page, because as far as the site is concerned there is no survey
at this election to post to. */
if (config.surveyClosed) {
return NextResponse.json({ error: "Survey not found" }, { status: 404 });
}

if (typeof survey_slug !== "string" || !SLUG_PATTERN.test(survey_slug)) {
return NextResponse.json(
{ error: "A survey_slug is required" },
Expand Down
19 changes: 17 additions & 2 deletions src/app/toronto/vote/2026/candidates/[candidate]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,12 @@ 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 Down Expand Up @@ -456,7 +462,16 @@ export default async function CandidatePage({
<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`
: "Yet to answer"}
: 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 ? (
Expand All @@ -481,7 +496,7 @@ export default async function CandidatePage({
/>
</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{" "}
Expand Down
169 changes: 106 additions & 63 deletions src/app/toronto/vote/2026/issues/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from "@/components/elections/QuestionnaireCards";
import { QuestionnaireRail } from "@/components/elections/QuestionnaireRail";
import { SurveyCta } from "@/components/elections/SurveyCta";
import { ANSWERS_WITHHELD, surveyHref } from "@/lib/elections/registry";
import CountdownDays from "@/components/elections/CountdownDays";
import { fieldSentiment } from "@/lib/elections/field-sentiment";
import {
Expand Down Expand Up @@ -63,16 +64,28 @@ export default async function IssuesPage() {
/* Unlike the ward and mayoral pages, the questionnaire is not a
nice-to-have here — it is the entire page. A failed fetch has nothing to
fall back to, so it renders as the empty state rather than as a roster. */
const [survey, responses] = await Promise.all([
const [survey, published] = await Promise.all([
fetchSurvey(ELECTION.slug, CANDIDATE_QUESTIONNAIRE_SLUG).catch(() => null),
fetchCandidateResponses(ELECTION.slug),
]);

/* Held back at the top of the page rather than at each place that draws
them — see `questionnaireHidden` in the registry. This page is nothing but
the answers, so emptying the array empties the page; what is left is the
masthead saying so. */
const withheld = ELECTION.questionnaireHidden ?? false;
const responses = withheld ? [] : published;

/* `fieldSentiment` is still what tells us who counts as a respondent and
what seat they are running for — it reads the responses against the
ballot and drops anyone who returned the form without answering a policy
question. The cards themselves come from the same pivot the ward and
mayoral pages use, over the whole city's entries rather than one race's. */
/* Nothing while the voter survey is closed — see `surveyClosed` in the
registry. Named for the invitation because `survey` here is already the
candidate questionnaire. */
const surveyInvite = surveyHref(ELECTION);

const field = survey ? fieldSentiment(survey, responses) : null;
const respondents = field?.respondents ?? [];
const mayoral = respondents.filter((r) => r.race === "mayor").length;
Expand Down Expand Up @@ -131,25 +144,39 @@ export default async function IssuesPage() {
Where the candidates stand
</h1>
<p className="font-serif text-[1.05rem] leading-[1.5] text-dark/85 max-w-[58ch] text-pretty">
The same {questionCount} questions, put to everyone
running for mayor and for council. Read across the whole field, the
answers show what no single ballot can: what Toronto&rsquo;s next
council already agrees on, and what it will spend four years
fighting over.
{withheld ? (
<>
This page reads the whole field&rsquo;s answers across every
issue we asked about.
</>
) : (
<>
The same {questionCount} questions, put to everyone running for
mayor and for council. Read across the whole field, the answers
show what no single ballot can: what Toronto&rsquo;s next
council already agrees on, and what it will spend four years
fighting over.
</>
)}
</p>
</section>

{/* ── Key stats ──────────────────────────────────────── */}
<section className="grid grid-cols-2 md:grid-cols-4 border-b-2 border-dark">
<Stat value={respondents.length} label="Candidates answered" />
<Stat value={questionCount} label="Policy questions" />
<Stat
value={`${mayoral} / ${council}`}
label="Mayoral / council"
small
/>
<Stat value={wards} label="Wards represented" last />
</section>
{/* Every one of these four counts the answers, so with them withheld
the row is four zeros — a page-wide claim that nobody answered
anything. It goes rather than lies. */}
{!withheld && (
<section className="grid grid-cols-2 md:grid-cols-4 border-b-2 border-dark">
<Stat value={respondents.length} label="Candidates answered" />
<Stat value={questionCount} label="Policy questions" />
<Stat
value={`${mayoral} / ${council}`}
label="Mayoral / council"
small
/>
<Stat value={wards} label="Wards represented" last />
</section>
)}

{/* ── The field, question by question ────────────────── */}
{groups.length > 0 && respondents.length > 0 ? (
Expand Down Expand Up @@ -183,61 +210,77 @@ export default async function IssuesPage() {
) : (
<section className="px-6 md:px-14 py-16 border-b-2 border-dark">
<p className="font-serif text-[1.1rem] leading-[1.5] text-dark/80 max-w-[58ch] text-pretty">
No candidate answers have been published yet. Responses appear
here as they are reviewed and released.
{withheld
? ANSWERS_WITHHELD
: "No candidate answers have been published yet. Responses appear here as they are reviewed and released."}
</p>
</section>
)}

{/* ── Your turn ──────────────────────────────────────── */}
<section className="px-6 md:px-14 py-9 md:py-10 border-t-2 border-dark grid gap-8 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
<div>
<h2 className="font-sans font-medium leading-[1.05] tracking-[-0.03em] text-[clamp(1.6rem,2.6vw,2.1rem)] max-w-[20ch] text-balance">
Now answer them yourself
</h2>
<p className="mt-3.5 font-serif text-[1.05rem] leading-[1.5] text-dark/85 max-w-[56ch] text-pretty">
These are the same questions we asked the candidates. Answer them
and see which of the {respondents.length} line up with you — and
where you sit against the field you just read.
</p>
<div className="mt-6 flex flex-wrap items-center gap-x-7 gap-y-3">
<CountdownDays
initialDays={daysUntil(ELECTION.electionDateIso)}
targetIso={ELECTION.electionDateIso}
className="font-sans font-semibold text-[2.25rem] leading-none tracking-[-0.03em] tabular-nums"
/>
<span className="type-label-sm !tracking-[0.1em] text-text-secondary">
Days until polls open
</span>
{/* The whole band goes while the survey is closed, not just its
button: "Now answer them yourself" over a paragraph promising the
reader they can, with nothing to answer, is worse than silence.

The countdown goes with it, which is the one thing here worth
regretting — it is this page's only clock. It is not worth keeping
a band alive for: the days to polls are on the landing page, the
mayoral page and every ward page. */}
{surveyInvite && (
<section className="px-6 md:px-14 py-9 md:py-10 border-t-2 border-dark grid gap-8 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
<div>
<h2 className="font-sans font-medium leading-[1.05] tracking-[-0.03em] text-[clamp(1.6rem,2.6vw,2.1rem)] max-w-[20ch] text-balance">
Now answer them yourself
</h2>
<p className="mt-3.5 font-serif text-[1.05rem] leading-[1.5] text-dark/85 max-w-[56ch] text-pretty">
These are the same questions we asked the candidates. Answer
them and see which of the {respondents.length} line up with you
— and where you sit against the field you just read.
</p>
<div className="mt-6 flex flex-wrap items-center gap-x-7 gap-y-3">
<CountdownDays
initialDays={daysUntil(ELECTION.electionDateIso)}
targetIso={ELECTION.electionDateIso}
className="font-sans font-semibold text-[2.25rem] leading-none tracking-[-0.03em] tabular-nums"
/>
<span className="type-label-sm !tracking-[0.1em] text-text-secondary">
Days until polls open
</span>
</div>
</div>
</div>
<SurveyCta href={`${ELECTION.basePath}/survey`} />
</section>
<SurveyCta href={surveyInvite} />
</section>
)}

{/* ── Method ─────────────────────────────────────────── */}
<section className="px-6 md:px-14 py-4 border-t border-border-light grid gap-2">
<p className="type-label-sm text-text-muted max-w-[80ch] text-pretty">
Each card is one question, drawn as the share of the field that
gave each answer. The options are listed in full under the band,
in the wording the candidates were shown, with the number who chose
each. Options nobody picked are not shown, and a candidate who
answered in their own words is counted in the unshaded segment
rather than on any option. Shares are of the candidates who
answered that particular question, not of the whole field — a
questionnaire can come back half filled in, so the number behind a
card is the counts in its own legend added up. Hover or select any
answer to see the candidates who gave it, with the seat each is
running for.
</p>
<p className="type-label-sm text-text-muted max-w-[80ch] text-pretty">
The note most candidates wrote to explain their answer lives on
the ward and mayoral pages — thirty notes under every question is
more reading than this page can carry, and it is on those pages
that a reader has a ballot to weigh them against. Answers appear as
candidates return the questionnaire and staff review them, so the
field shown here grows through the campaign.
</p>
</section>
{/* How to read cards that are not on the page is not method, it is
noise — and the second paragraph explains where the notes went,
which is a distinction with nothing to draw it between. */}
{!withheld && (
<section className="px-6 md:px-14 py-4 border-t border-border-light grid gap-2">
<p className="type-label-sm text-text-muted max-w-[80ch] text-pretty">
Each card is one question, drawn as the share of the field that
gave each answer. The options are listed in full under the band,
in the wording the candidates were shown, with the number who
chose each. Options nobody picked are not shown, and a candidate
who answered in their own words is counted in the unshaded segment
rather than on any option. Shares are of the candidates who
answered that particular question, not of the whole field — a
questionnaire can come back half filled in, so the number behind a
card is the counts in its own legend added up. Hover or select any
answer to see the candidates who gave it, with the seat each is
running for.
</p>
<p className="type-label-sm text-text-muted max-w-[80ch] text-pretty">
The note most candidates wrote to explain their answer lives on
the ward and mayoral pages — thirty notes under every question is
more reading than this page can carry, and it is on those pages
that a reader has a ballot to weigh them against. Answers appear
as candidates return the questionnaire and staff review them, so
the field shown here grows through the campaign.
</p>
</section>
)}

{/* ── Elsewhere ──────────────────────────────────────── */}
<section className="border-t border-dark grid md:grid-cols-2">
Expand Down
Loading
Loading