diff --git a/src/app/api/elections/candidate-responses/route.ts b/src/app/api/elections/candidate-responses/route.ts index 72cb1e4b..9b98cb71 100644 --- a/src/app/api/elections/candidate-responses/route.ts +++ b/src/app/api/elections/candidate-responses/route.ts @@ -6,6 +6,7 @@ import { } from "@/lib/elections/candidate-responses"; import { DEFAULT_ELECTION_SLUG, + getElection, isSupportedElection, } from "@/lib/elections/registry"; import { @@ -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"); diff --git a/src/app/api/elections/survey/route.ts b/src/app/api/elections/survey/route.ts index 3bc9653e..fb5336f3 100644 --- a/src/app/api/elections/survey/route.ts +++ b/src/app/api/elections/survey/route.ts @@ -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" }, diff --git a/src/app/toronto/vote/2026/candidates/[candidate]/page.tsx b/src/app/toronto/vote/2026/candidates/[candidate]/page.tsx index 0bdb4fb0..b7996a9a 100644 --- a/src/app/toronto/vote/2026/candidates/[candidate]/page.tsx +++ b/src/app/toronto/vote/2026/candidates/[candidate]/page.tsx @@ -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, @@ -456,7 +462,16 @@ export default async function CandidatePage({

{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"}

{surveyAnswers ? ( @@ -481,7 +496,7 @@ export default async function CandidatePage({ /> - ) : ( + ) : withheld ? null : (

{candidate.name} has not returned our questionnaire. We publish answers as they arrive, so check back — and{" "} diff --git a/src/app/toronto/vote/2026/issues/page.tsx b/src/app/toronto/vote/2026/issues/page.tsx index 73b57964..033b40d8 100644 --- a/src/app/toronto/vote/2026/issues/page.tsx +++ b/src/app/toronto/vote/2026/issues/page.tsx @@ -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 { @@ -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; @@ -131,25 +144,39 @@ export default async function IssuesPage() { Where the candidates stand

- 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’s next - council already agrees on, and what it will spend four years - fighting over. + {withheld ? ( + <> + This page reads the whole field’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’s next + council already agrees on, and what it will spend four years + fighting over. + + )}

{/* ── Key stats ──────────────────────────────────────── */} -
- - - - -
+ {/* 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 && ( +
+ + + + +
+ )} {/* ── The field, question by question ────────────────── */} {groups.length > 0 && respondents.length > 0 ? ( @@ -183,61 +210,77 @@ export default async function IssuesPage() { ) : (

- 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."}

)} {/* ── Your turn ──────────────────────────────────────── */} -
-
-

- Now answer them yourself -

-

- 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. -

-
- - - Days until polls open - + {/* 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 && ( +
+
+

+ Now answer them yourself +

+

+ 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. +

+
+ + + Days until polls open + +
-
- -
+ + + )} {/* ── Method ─────────────────────────────────────────── */} -
-

- 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. -

-

- 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. -

-
+ {/* 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 && ( +
+

+ 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. +

+

+ 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. +

+
+ )} {/* ── Elsewhere ──────────────────────────────────────── */}
diff --git a/src/app/toronto/vote/2026/mayor/candidates/page.tsx b/src/app/toronto/vote/2026/mayor/candidates/page.tsx index d21a78e3..f7635d2a 100644 --- a/src/app/toronto/vote/2026/mayor/candidates/page.tsx +++ b/src/app/toronto/vote/2026/mayor/candidates/page.tsx @@ -9,6 +9,7 @@ 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"; @@ -66,8 +67,15 @@ export default async function MayoralCandidatesPage() { ); const roster = surveyRoster(view.mayoral, answers); - const answered = roster.filter((candidate) => candidate.answers); - const quiet = roster.filter((candidate) => !candidate.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); const withdrawn = view.mayoral.filter((candidate) => candidate.withdrawn); const sites = roster.filter((candidate) => candidate.website).length; @@ -98,13 +106,16 @@ export default async function MayoralCandidatesPage() { {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.`} +

{/* ── Key stats ──────────────────────────────────────── */}
- + {/* A count of the answers, which at nil reads as the claim that + nobody gave any. */} + {!withheld && }
)} - {/* ── Yet to respond ─────────────────────────────────── */} + {/* ── Yet to respond, or simply the ballot ───────────── */} {quiet.length > 0 && (
diff --git a/src/app/toronto/vote/2026/mayor/page.tsx b/src/app/toronto/vote/2026/mayor/page.tsx index 29e6f850..a9175914 100644 --- a/src/app/toronto/vote/2026/mayor/page.tsx +++ b/src/app/toronto/vote/2026/mayor/page.tsx @@ -9,6 +9,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 { byCandidateKey, @@ -67,12 +68,19 @@ export const metadata: Metadata = { }; export default async function MayorPage() { - const [view, survey, responses] = await Promise.all([ + const [view, survey, published] = await Promise.all([ getToronto2026(), 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. Everything downstream is + derived from this array, so emptying it here is what guarantees no answer + reaches the markup by a route nobody remembered to check. */ + const withheld = ELECTION.questionnaireHidden ?? false; + const responses = withheld ? [] : published; + /* The ballot line, and the part of it that wrote back. The whole election's responses come back from one fetch — the counts a @@ -80,6 +88,10 @@ export default async function MayorPage() { and the roster narrows who gets named, exactly as the ward pages do. */ const ballot = view.mayoral.filter((candidate) => !candidate.withdrawn); const registered = ballot.length; + /* Nothing while the voter survey is closed — see `surveyClosed` in the + registry. Named for the invitation rather than the survey, because + `survey` here is already the candidate questionnaire. */ + const surveyInvite = surveyHref(ELECTION); const ballotKeys = new Set(ballot.map((candidate) => candidate.key)); const answers = survey @@ -123,9 +135,11 @@ export default async function MayorPage() { How the mayoral field answered

- {mayoral.length > 0 - ? `${mayoral.length} of the ${registered} candidates for mayor returned our questionnaire. Their answers, question by question — the mayoral field on each one.` - : `No one running for mayor has answered our questionnaire yet. ${registered} candidates have registered for the race.`} + {withheld + ? `${registered} candidates have registered for the race. The ballot is below.` + : mayoral.length > 0 + ? `${mayoral.length} of the ${registered} candidates for mayor returned our questionnaire. Their answers, question by question — the mayoral field on each one.` + : `No one running for mayor has answered our questionnaire yet. ${registered} candidates have registered for the race.`}

{/* ── Key stats ──────────────────────────────────────── */} -
- + {/* Two of these four count the answers, and while those are withheld + both would read zero — which is not a smaller version of the truth, + it is a different claim: that nobody answered. So the row drops to + what it can still say honestly, the ballot and the clock. */} +
+ {!withheld && } - + {!withheld && }
{/* ── The field, question by question ────────────────── */} - {groups.length > 0 && mayoral.length > 0 ? ( + {/* The ballot outlives the answers. A page about the mayoral race that + names nobody in it is no use to a reader who came with a name in + mind, and who is running is a fact about the election rather than + anything a candidate told us. */} + {withheld ? ( +
+

+ {ANSWERS_WITHHELD} +

+ +
+ ) : groups.length > 0 && mayoral.length > 0 ? (
{/* THE WHOLE BALLOT, ONCE @@ -196,29 +235,38 @@ export default async function MayorPage() { )} {/* ── Your turn ──────────────────────────────────────── */} -
-
-

- Now answer them yourself -

-

- These are the same questions we put to the field. Answer them and - see which candidates line up with you. -

-
- -
+ {/* 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. */} + {surveyInvite && ( +
+
+

+ Now answer them yourself +

+

+ These are the same questions we put to the field. Answer them + and see which candidates line up with you. +

+
+ +
+ )} {/* ── Method ─────────────────────────────────────────── */}
-

- Every bar is the mayoral field that answered, one cell per - candidate: filled with the option that candidate picked, hollow - where they did not answer that question. Candidates who never - returned the questionnaire are not in these counts — they are on the - roster. Open a card for the names behind the bars and what each of - them wrote, published verbatim. -

+ {/* How to read cards that are not on the page is not method, it + is noise. */} + {!withheld && ( +

+ Every bar is the mayoral field that answered, one cell per + candidate: filled with the option that candidate picked, hollow + where they did not answer that question. Candidates who never + returned the questionnaire are not in these counts — they are on + the roster. Open a card for the names behind the bars and what + each of them wrote, published verbatim. +

+ )}

Registered candidates come from the City Clerk’s list, less anyone who has withdrawn. The field is not final until nominations diff --git a/src/app/toronto/vote/2026/page.tsx b/src/app/toronto/vote/2026/page.tsx index 3270dc1d..1ecde692 100644 --- a/src/app/toronto/vote/2026/page.tsx +++ b/src/app/toronto/vote/2026/page.tsx @@ -3,6 +3,7 @@ import { ElectionLanding } from "@/components/elections/ElectionLanding"; import { WardMap, WardMapDefs } from "@/components/elections/WardMap"; import { WARD_GEO } from "./wardGeo"; import { ELECTION, getToronto2026 } from "./data"; +import { surveyHref } from "@/lib/elections/registry"; import { ADVANCE_VOTING_PATH, ELECTION_DAY, @@ -36,7 +37,10 @@ export default async function Toronto2026ElectionPage() { // Toronto publishes its poll hours, so the band's headline counter is // the live timer from the /toronto hero rather than a whole-day count. electionDay={ELECTION_DAY} - surveyPath={`${ELECTION.basePath}/survey`} + /* Undefined while the survey is closed, which drops both the explore + grid's survey card and the closing call to action — ElectionLanding + already falls back to the pledge for regions that run no survey. */ + surveyPath={surveyHref(ELECTION)} wardMapDefs={} renderWardMap={(ward) => ( = Object.fromEntries( // than an honest miss, and keeping a hard-coded copy here to fall back on is // the drift this move was meant to end. async function loadSurvey(): Promise { + /* Closed means closed at the route, not only at the links into it. Dropping + the invitations alone would leave the form standing for anyone holding the + URL — a bookmark, a share, a search result — and still taking answers and + still showing a reader their alignment, which is the half of this we were + asked to stop. See `surveyClosed` in the registry. */ + if (getElection(DEFAULT_ELECTION_SLUG).surveyClosed) return null; + try { return await fetchSurvey(DEFAULT_ELECTION_SLUG, CITY_PRIORITIES_SLUG); } catch { diff --git a/src/components/elections/ElectionLanding.tsx b/src/components/elections/ElectionLanding.tsx index 46f9956a..de2d8f0c 100644 --- a/src/components/elections/ElectionLanding.tsx +++ b/src/components/elections/ElectionLanding.tsx @@ -666,6 +666,11 @@ function ExploreSection({ * lands here whenever the mayoral cards do */ anchorCandidates?: boolean; }) { + /* Read off the cards rather than taken as a prop: the survey card IS the + invite, so the blurb and the grid cannot disagree about whether there is + one. */ + const invitesSurvey = items.some((item) => item.tone === "invite"); + return (

{anchorCandidates && ( @@ -679,10 +684,16 @@ function ExploreSection({

Explore the election

+ {/* The second half of this is a promise about the survey, so it is + only made where there is a survey to make it about. Read under a + grid with no survey card in it, "then answer them yourself" sends + a reader hunting the page for something that is not on it. */}

We put the same questions to every candidate on the ballot. See how - they answered — then answer them yourself and find out who lines up - with you. + they answered + {invitesSurvey + ? " — then answer them yourself and find out who lines up with you." + : ", question by question and ward by ward."}

diff --git a/src/components/elections/WardDetail.tsx b/src/components/elections/WardDetail.tsx index e22d1cd9..628b5657 100644 --- a/src/components/elections/WardDetail.tsx +++ b/src/components/elections/WardDetail.tsx @@ -10,6 +10,7 @@ import { } from "./QuestionnaireCards"; import { QuestionnaireRail } from "./QuestionnaireRail"; import { SurveyCta } from "./SurveyCta"; +import { ANSWERS_WITHHELD, surveyHref } from "@/lib/elections/registry"; import { IncumbentBadge } from "./ElectionLanding"; import { CandidateNameLink } from "./CandidateNameLink"; import { WardProfileSection, type WardProfile } from "./WardProfile"; @@ -103,6 +104,11 @@ export function WardDetail({ (candidate) => surveyAnswers?.[candidate.key], ); + /* The answers arrive empty either way, so the page cannot tell a quiet field + from a withheld one by looking at them — see `questionnaireHidden` in the + registry. It has to ask. */ + const withheld = election.questionnaireHidden ?? false; + return (
@@ -186,15 +192,29 @@ export function WardDetail({

{councilCandidates.length === 0 ? "No one has registered in this ward yet." - : "Nobody in this ward has answered yet. These are the questions we asked."} + : withheld + ? ANSWERS_WITHHELD + : "Nobody in this ward has answered yet. These are the questions we asked."}

)} {councilCandidates.length > 0 && ( !surveyAnswers?.[candidate.key], - )} + /* One list while the answers are withheld. The split is by + who wrote back, so with nothing to read back everyone + falls into the second half and the ward's whole ballot + sits under "yet to answer our questionnaire" — which they + did answer. Flat, and labelled for what it is. */ + respondents={withheld ? councilCandidates : respondents} + silent={ + withheld + ? [] + : councilCandidates.filter( + (candidate) => !surveyAnswers?.[candidate.key], + ) + } + respondentsLabel={ + withheld ? "On the ballot" : undefined + } election={election.slug} race="councillor" ward={ward.n} @@ -203,7 +223,12 @@ export function WardDetail({ )}
- + {/* Absent entirely while the survey is closed. The column beside + it is the heading and the ballot, which stand on their own — + this was always the ask, not part of the ward's own facts. */} + {surveyHref(election) && ( + + )}
{councilCandidates.length === 0 ? ( @@ -219,6 +244,7 @@ export function WardDetail({ surveyAnswers={surveyAnswers} surveyShape={surveyShape} showHeading={showRaceHeadings} + withheld={withheld} issuesHref={`${election.basePath}/issues`} /> )) @@ -316,12 +342,16 @@ function RaceQuestionnaire({ surveyAnswers, surveyShape, showHeading, + withheld = false, issuesHref, }: { race: RaceView; surveyAnswers?: Record; surveyShape?: ComparedGroup[]; showHeading: boolean; + /** the answers are being held back, so an empty grid is our doing and not + * a field that stayed quiet — see `questionnaireHidden` in the registry */ + withheld?: boolean; issuesHref?: string; }) { /* Two lists, not one. The candidates who wrote back are the ones the @@ -360,15 +390,23 @@ function RaceQuestionnaire({ /> ) : ( - /* Only two ways to get here now: nobody has filed for the seat, or - the questionnaire itself could not be fetched. Either way there is - no grid to draw, and the candidates are still worth naming. */ + /* Nobody has filed for the seat, the questionnaire could not be + fetched, or the answers are being held back. Either way there is + no grid to draw, and the candidates are still worth naming — but + only the first two let us say the field has yet to respond, which + is why the third has to be told apart from them. */

{roster.length === 0 ? "No one has filed for this seat yet." - : `On the ballot, and yet to respond to us: ${roster - .map((candidate) => candidate.name) - .join(", ")}.`} + : withheld + ? /* Nothing. The notice is already up beside the ballot at the + top of this section, and a ward with two races would + otherwise print it once per race under the one that + covers them all. */ + null + : `On the ballot, and yet to respond to us: ${roster + .map((candidate) => candidate.name) + .join(", ")}.`}

)}
diff --git a/src/lib/elections/registry.ts b/src/lib/elections/registry.ts index 3e57827f..4c6116ef 100644 --- a/src/lib/elections/registry.ts +++ b/src/lib/elections/registry.ts @@ -83,8 +83,79 @@ export type SupportedElection = { * opt-in rather than assumed from `basePath`. */ candidateProfiles?: boolean; + /** + * The region's voter survey is off for now. + * + * Temporary and deliberately one line: the survey route stops answering and + * every invitation to it disappears, while the questions, the submissions + * already taken and the code that reads them all stay exactly where they + * are. Turning it back on is deleting this flag. + * + * Set it rather than unpicking the call sites. There are four separate + * invitations to the survey across the tracker — the landing page's card, + * its closing call to action, the mayoral page and the issues page — plus + * every ward page, and a survey withdrawn from three of them is a survey a + * reader still finds from the fourth. `surveyHref` below is what they all + * ask, so the rule lives in one place and no call site can forget it. + */ + surveyClosed?: boolean; + /** + * The candidates' questionnaire answers are off for now. + * + * The sibling of `surveyClosed` and the same bargain: temporary, one line, + * and nothing deleted. What comes down is every published answer — the cards + * on the ward, mayoral and candidate pages, the whole-field view on /issues, + * and the read proxy that serves them to the browser. What stays up is the + * ballot: who is running, in which ward, with their campaign site, which is + * a fact about the election rather than anything a candidate told us. + * + * A candidate's own bio stays too, though it arrives in the same response. + * It is a self-description rather than a position, and it is the only thing + * standing between most candidate pages and an empty one. + * + * The pages need no empty states written for this: they already have the + * ones built for the weeks before anybody had written back — "Nobody in this + * ward has answered yet", "No answers from the mayoral field have been + * published yet" — and hiding the answers at the source puts every page into + * exactly that state. + */ + questionnaireHidden?: boolean; }; +/** + * What a page says where the candidates' answers would be. + * + * One phrase, in one place, because it appears on the ward pages, the mayoral + * page, /issues and every candidate page, and four hand-written versions of it + * would be four different accounts of the same fact. + * + * Once per page. It names what is missing rather than only promising a return, + * so on a page that says it twice — in the masthead and again where the cards + * would be — it reads as a stutter rather than as a fuller explanation. Each + * page keeps it in the one place a reader looks for the thing that is gone. + * + * What it must not do is reuse the empty states these pages already had for + * the weeks before anybody had written back. "Nobody in this ward has answered + * yet", printed over a ward whose candidates answered months ago, is a claim + * about those candidates and it is ours, not theirs — and on a candidate page + * it sits under a named person's photograph. + */ +export const ANSWERS_WITHHELD = "Candidate survey coming soon."; + +/** + * Where this region's voter survey lives, or nothing while it is closed. + * + * Returning `undefined` rather than a path plus a flag to check is what makes + * the closed case hard to get wrong: a caller has nothing to link to, so the + * invitation has to disappear rather than being left pointing at a page that + * will not answer. + */ +export function surveyHref( + election: SupportedElection, +): string | undefined { + return election.surveyClosed ? undefined : `${election.basePath}/survey`; +} + const TORONTO_2026: SupportedElection = { slug: "toronto-2026", jurisdictionSlug: "toronto", @@ -105,6 +176,8 @@ const TORONTO_2026: SupportedElection = { wardLookup: true, candidateProfiles: true, themeClass: "theme-election", + surveyClosed: true, + questionnaireHidden: true, }; const BRAMPTON_2026: SupportedElection = { diff --git a/src/lib/elections/survey-answers.ts b/src/lib/elections/survey-answers.ts index 4b1057b6..5fe59578 100644 --- a/src/lib/elections/survey-answers.ts +++ b/src/lib/elections/survey-answers.ts @@ -13,6 +13,7 @@ // is nothing to show at all. import { + BIO_QUESTION_ID, byCandidateKey, candidateAnswers, candidateWriting, @@ -25,6 +26,7 @@ import { CANDIDATE_QUESTIONNAIRE_SLUG, fetchCandidateResponses, } from "./candidate-responses"; +import { getElection } from "./registry"; import { fetchSurvey } from "./survey"; export type RosterSurvey = { @@ -65,6 +67,32 @@ export async function rosterSurvey( ); const written = candidateWriting(survey, responses); + /* Answers withheld, prose kept — see `questionnaireHidden` in the + registry. The responses are still fetched because the bio rides in on + them, and a candidate's account of themselves is not one of the answers + being held back. Every page reading this is left with the empty state it + already had for a field that has not written back yet. + + `written` is narrowed to the bio alone rather than passed through. The + rest of it is prose answering a policy question — the questionnaire's + ward-commitment target is one — and letting that through under the + heading "About" would publish an answer by another door. */ + if (getElection(electionSlug).questionnaireHidden) { + return { + answers: {}, + shape: [], + written: Object.fromEntries( + Object.entries(written) + .filter(([key]) => candidateKeys.has(key)) + .map(([key, entries]) => [ + key, + entries.filter((entry) => entry.questionId === BIO_QUESTION_ID), + ]) + .filter(([, entries]) => (entries as WrittenAnswer[]).length > 0), + ), + }; + } + return { answers: byCandidateKey(entries), shape: questionnaireShape(survey, responses),