diff --git a/package.json b/package.json index f7700727..0f50c328 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,10 @@ "chartjs-adapter-date-fns": "^3.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "d3-color": "^3.1.0", + "d3-format": "^3.1.2", + "d3-scale": "^4.0.2", + "d3-shape": "^3.2.0", "date-fns": "^4.1.0", "dayjs": "^1.11.18", "fast-xml-parser": "^5.3.8", @@ -70,6 +74,10 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/d3-color": "^3.1.3", + "@types/d3-format": "^3.0.4", + "@types/d3-scale": "^4.0.9", + "@types/d3-shape": "^3.2.0", "@types/hast": "^3.0.5", "@types/node": "^20", "@types/react": "^19", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 987fc889..7b2b8928 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,6 +68,18 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + d3-color: + specifier: ^3.1.0 + version: 3.1.0 + d3-format: + specifier: ^3.1.2 + version: 3.1.2 + d3-scale: + specifier: ^4.0.2 + version: 4.0.2 + d3-shape: + specifier: ^3.2.0 + version: 3.2.0 date-fns: specifier: ^4.1.0 version: 4.1.0 @@ -165,6 +177,18 @@ importers: '@tailwindcss/postcss': specifier: ^4 version: 4.2.2 + '@types/d3-color': + specifier: ^3.1.3 + version: 3.1.3 + '@types/d3-format': + specifier: ^3.0.4 + version: 3.0.4 + '@types/d3-scale': + specifier: ^4.0.9 + version: 4.0.9 + '@types/d3-shape': + specifier: ^3.2.0 + version: 3.2.0 '@types/hast': specifier: ^3.0.5 version: 3.0.5 @@ -1801,6 +1825,24 @@ packages: '@types/cookie@0.6.0': resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-format@3.0.4': + resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-shape@3.2.0': + resolution: {integrity: sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} @@ -6372,6 +6414,22 @@ snapshots: '@types/cookie@0.6.0': optional: true + '@types/d3-color@3.1.3': {} + + '@types/d3-format@3.0.4': {} + + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-shape@3.2.0': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time@3.0.4': {} + '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 diff --git a/src/app/api/elections/candidate-responses/route.ts b/src/app/api/elections/candidate-responses/route.ts new file mode 100644 index 00000000..72cb1e4b --- /dev/null +++ b/src/app/api/elections/candidate-responses/route.ts @@ -0,0 +1,105 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { + CANDIDATE_QUESTIONNAIRE_SLUG, + fetchCandidateResponses, +} from "@/lib/elections/candidate-responses"; +import { + DEFAULT_ELECTION_SLUG, + isSupportedElection, +} from "@/lib/elections/registry"; +import { + TORONTO_2026_SLUG, + getToronto2026, + getToronto2026Ward, + nameKey, +} from "@/app/toronto/vote/2026/data"; + +// Published candidate questionnaire answers for one ward. +// +// A read proxy, not a data source: everything it returns is already public +// through York Factory's own endpoint, and it exists because the survey page +// only learns which ward to ask about after the API has placed the respondent +// from their postal code. Fetching it server-side at page load would mean +// shipping every ward's answers to every visitor to use one ward's worth. +// +// The mayoral field rides along too. A voter marks two ballots — one for their +// councillor, one for mayor — so a comparison that answers only half of that is +// answering the smaller half: the mayoral race is the one every voter in the +// city votes in. +// +// The ward's roster rides along with the answers. The comparison names every +// candidate on the ballot, not only the ones who wrote back — a reader wants to +// know that the candidate they are considering said nothing as much as they +// want to know what the others said — and the roster is the only place that +// fact lives. Toronto is the only region with a survey page, so it is the only +// region this looks one up for; everyone else gets an empty roster and a +// comparison of respondents alone. +// +// `election` is checked against the registry so a client cannot aim this at an +// arbitrary slug, matching the submit route. + +const WARD_PATTERN = /^\d{1,2}$/; + +export async function GET(req: NextRequest) { + const { searchParams } = new URL(req.url); + const election = searchParams.get("election") ?? DEFAULT_ELECTION_SLUG; + const ward = searchParams.get("ward") ?? ""; + + if (!isSupportedElection(election)) { + return NextResponse.json({ error: "Unknown election" }, { status: 400 }); + } + if (!WARD_PATTERN.test(ward)) { + return NextResponse.json({ error: "Invalid ward" }, { status: 400 }); + } + + const toronto = election === TORONTO_2026_SLUG; + const wardToken = ward.padStart(2, "0"); + + const [data, detail, view, everyResponse] = await Promise.all([ + fetchCandidateResponses(election, { + ward, + surveySlug: CANDIDATE_QUESTIONNAIRE_SLUG, + }), + toronto ? getToronto2026Ward(wardToken).catch(() => null) : null, + toronto ? getToronto2026().catch(() => null) : null, + // Unfiltered, because the mayoral field is on no ward: the responses are + // narrowed to the mayoral roster by name below, the same join every other + // surface uses. + toronto + ? fetchCandidateResponses(election, { + surveySlug: CANDIDATE_QUESTIONNAIRE_SLUG, + }) + : [], + ]); + + const roster = (detail?.councilRaces ?? []) + .flatMap((race) => race.candidates) + .map(rosterEntry); + + const mayoralRoster = (view?.mayoral ?? []).map(rosterEntry); + const mayoralKeys = new Set(mayoralRoster.map((candidate) => candidate.key)); + const mayoralData = everyResponse.filter((response) => + mayoralKeys.has(nameKey(response.candidateName)), + ); + + return NextResponse.json({ + data, + roster, + mayoral: { data: mayoralData, roster: mayoralRoster }, + }); +} + +function rosterEntry(candidate: { + key: string; + name: string; + website?: string; + withdrawn: boolean; +}) { + return { + key: candidate.key, + name: candidate.name, + website: candidate.website, + withdrawn: candidate.withdrawn, + }; +} diff --git a/src/app/api/elections/pledge/route.ts b/src/app/api/elections/pledge/route.ts index 024d06ea..c6f71574 100644 --- a/src/app/api/elections/pledge/route.ts +++ b/src/app/api/elections/pledge/route.ts @@ -7,6 +7,7 @@ import { isSupportedElection, } from "@/lib/elections/registry"; import { forwardedHubspotContext } from "@/lib/hubspot-context"; +import { normalizePostalCode } from "@/lib/elections/postal-code"; // "Pledge to vote" submissions — same low-friction pattern as /api/subscribe. // Forwards {email, name, region, postal_code} to York Factory, which signs the @@ -19,17 +20,6 @@ import { forwardedHubspotContext } from "@/lib/hubspot-context"; // defaults to the election's jurisdiction ("toronto", "brampton", …). const REGION_PATTERN = /^[a-z0-9-]{1,50}$/; -const POSTAL_PATTERN = /^[A-Za-z]\d[A-Za-z] ?\d[A-Za-z]\d$/; - -// "M5V1A1" / "m5v 1a1" → "M5V 1A1"; anything malformed is dropped rather -// than stored dirty -function normalizePostalCode(raw: unknown): string | undefined { - if (typeof raw !== "string" || !POSTAL_PATTERN.test(raw.trim())) { - return undefined; - } - const compact = raw.trim().toUpperCase().replace(" ", ""); - return `${compact.slice(0, 3)} ${compact.slice(3)}`; -} export async function POST(req: NextRequest) { try { diff --git a/src/app/api/elections/survey/route.ts b/src/app/api/elections/survey/route.ts new file mode 100644 index 00000000..3bc9653e --- /dev/null +++ b/src/app/api/elections/survey/route.ts @@ -0,0 +1,125 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { API_URL } from "@/lib/api/client"; +import { + DEFAULT_ELECTION_SLUG, + getElection, + isSupportedElection, +} from "@/lib/elections/registry"; +import { normalizePostalCode } from "@/lib/elections/postal-code"; +import { forwardedHubspotContext } from "@/lib/hubspot-context"; + +// Resident-survey submissions — same shape as /api/elections/pledge. Forwards +// to York Factory, which signs the email up as a subscriber and records one +// response per subscriber per survey per election (re-submitting replaces the +// answers). +// +// `election` is checked against the registry before it reaches the API, so a +// client can't aim this at an arbitrary slug. The answers themselves are +// passed through untouched: the question set is York Factory's, served from +// there and rendered by the survey page, so validating question ids in this +// proxy would only add a third copy of them to keep in step. York Factory +// applies structural limits (count, key and value length) and owns the +// question ids on both sides of the round trip. + +const SLUG_PATTERN = /^[a-z0-9-]{1,100}$/; +const REGION_PATTERN = /^[a-z0-9-]{1,50}$/; + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const { + email, + name, + answers, + survey_slug, + survey_version, + region, + postal_code, + election, + } = body; + + if (!email || typeof email !== "string") { + return NextResponse.json({ error: "Email is required" }, { status: 400 }); + } + + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + return NextResponse.json( + { error: "Invalid email format" }, + { status: 400 }, + ); + } + + if (!answers || typeof answers !== "object" || Array.isArray(answers)) { + return NextResponse.json( + { error: "Answers are required" }, + { status: 400 }, + ); + } + + if (election !== undefined && !isSupportedElection(election)) { + return NextResponse.json({ error: "Unknown election" }, { status: 400 }); + } + const electionSlug = isSupportedElection(election) + ? election + : DEFAULT_ELECTION_SLUG; + const config = getElection(electionSlug); + + if (typeof survey_slug !== "string" || !SLUG_PATTERN.test(survey_slug)) { + return NextResponse.json( + { error: "A survey_slug is required" }, + { status: 400 }, + ); + } + + // A malformed region is dropped rather than rejected — the ward is a + // nice-to-have for cutting results, not worth failing a completed survey + // over. The postal code is kept on the response so it can be re-derived. + const safeRegion = + typeof region === "string" && REGION_PATTERN.test(region) + ? region + : undefined; + + const res = await fetch( + `${API_URL}/elections/${electionSlug}/survey_responses`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email, + name: typeof name === "string" ? name.slice(0, 100) : undefined, + answers, + survey_slug, + survey_version: + typeof survey_version === "string" ? survey_version : undefined, + region: safeRegion, + postal_code: normalizePostalCode(postal_code), + ...forwardedHubspotContext(body, req), + }), + cache: "no-store", + }, + ); + + if (!res.ok) { + const errorData = await res.json().catch(() => ({})); + return NextResponse.json( + { error: errorData.errors?.[0] || "Survey submission failed" }, + { status: res.status }, + ); + } + + const data = await res.json(); + + return NextResponse.json({ + success: true, + election: config.slug, + surveySlug: data.survey_slug ?? survey_slug, + region: data.region ?? null, + derivedRegion: data.derived_region ?? null, + submittedAt: data.submitted_at ?? null, + }); + } catch (err) { + return NextResponse.json({ error: String(err) }, { status: 500 }); + } +} diff --git a/src/app/globals.css b/src/app/globals.css index cdd3e968..fa23adbc 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -117,6 +117,28 @@ html { 100% { transform: rotate(0deg); } } +/* ─── Multi-step form transitions ─── */ + +/* Each field in a step rises into place as it fades in. Staggered by an + inline animation-delay from the field's index. `forwards` plus the + reduced-motion override above means these still end up visible when + animation is disabled. */ +@keyframes stepFieldIn { + from { + opacity: 0; + transform: translateY(12px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.step-field { + opacity: 0; + animation: stepFieldIn 340ms cubic-bezier(0.22, 1, 0.36, 1) forwards; +} + .accordion-expand { display: grid; grid-template-rows: 1fr; diff --git a/src/app/ottawa/vote/2026/page.tsx b/src/app/ottawa/vote/2026/page.tsx index adb06573..4ad827c7 100644 --- a/src/app/ottawa/vote/2026/page.tsx +++ b/src/app/ottawa/vote/2026/page.tsx @@ -109,7 +109,9 @@ function PreRoster() {

Polls open{" "} - {ELECTION.voteDayLabel}, 2026 + + {ELECTION.voteDayLabel}, 2026 + , {ELECTION.pollHoursLabel}.

@@ -134,23 +136,29 @@ function PreRoster() { -
- {WARDS.map((ward) => ( - - } - className="border-b border-r border-border-light" - /> - ))} + {/* Cells rule after themselves and the overhang is clipped, so the + grid closes once on every side — see CardGrid in ElectionLanding, + which this mirrors by hand rather than importing a component out + of a shared page. */} +
+
+ {WARDS.map((ward) => ( + + } + className="border-b border-r border-border-light" + /> + ))} +
@@ -158,8 +166,8 @@ function PreRoster() {

Ward boundaries are the City of Ottawa’s published 2022–2026 - wards. The candidate list will follow the City Clerk’s official - registered-candidate listing once nominations open. + wards. The candidate list will follow the City Clerk’s + official registered-candidate listing once nominations open.

diff --git a/src/app/toronto/page.tsx b/src/app/toronto/page.tsx index e5484b49..413b13c9 100644 --- a/src/app/toronto/page.tsx +++ b/src/app/toronto/page.tsx @@ -293,7 +293,7 @@ function ElectionCardsSection() { w.n); diff --git a/src/app/toronto/vote/2026/issues/page.tsx b/src/app/toronto/vote/2026/issues/page.tsx new file mode 100644 index 00000000..9de52097 --- /dev/null +++ b/src/app/toronto/vote/2026/issues/page.tsx @@ -0,0 +1,276 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { ArrowLeft, ArrowRight } from "lucide-react"; + +import { + QuestionnaireCards, + questionnaireHeadings, +} from "@/components/elections/QuestionnaireCards"; +import { QuestionnaireRail } from "@/components/elections/QuestionnaireRail"; +import { SurveyCta } from "@/components/elections/SurveyCta"; +import CountdownDays from "@/components/elections/CountdownDays"; +import { fieldSentiment } from "@/lib/elections/field-sentiment"; +import { + candidateAnswers, + comparedQuestions, + questionnaireShape, +} from "@/lib/elections/candidate-answers"; +import { + CANDIDATE_QUESTIONNAIRE_SLUG, + fetchCandidateResponses, +} from "@/lib/elections/candidate-responses"; +import { fetchSurvey } from "@/lib/elections/survey"; +import { daysUntil } from "@/lib/elections/dates"; +import { ELECTION } from "../data"; + +/* Where the field stands, across every issue we asked about. + * + * The ward pages and the mayoral page each show one ballot's answers as a + * grid. This page shows all of them at once, and drops the grid to do it: + * thirty-two respondents will not fit as columns, and the city-wide question + * is not "what did this candidate say" anyway. It is where the people running + * to govern Toronto converge, and where they split. + * + * A card per question, with the candidates filed under the answer they gave — + * the same roll-call form the ward and mayoral pages use, so a reader who has + * learned to read one page of the tracker can read all of them. The only + * difference here is scale: the field is the whole city, so a name plate also + * carries the seat that candidate is running for. + * + * The shell around them is deliberately short. Two dozen questions is the + * page; every band of prose above them is a band the reader scrolls past to + * reach it, so the hero states the premise once and the stats row carries the + * rest of what a masthead would say. + */ + +export const metadata: Metadata = { + title: "Where the candidates stand", + description: + "Every candidate answer to our Toronto 2026 questionnaire, read across the whole field: where the candidates agree, and where they split.", + alternates: { canonical: `${ELECTION.basePath}/issues` }, + openGraph: { + title: "Where the candidates stand — Toronto 2026 Election", + description: + "How the field answered our questionnaire, issue by issue: the consensus, and the fights.", + type: "website", + }, +}; + +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([ + fetchSurvey(ELECTION.slug, CANDIDATE_QUESTIONNAIRE_SLUG).catch(() => null), + fetchCandidateResponses(ELECTION.slug), + ]); + + /* `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. */ + const field = survey ? fieldSentiment(survey, responses) : null; + const respondents = field?.respondents ?? []; + const mayoral = respondents.filter((r) => r.race === "mayor").length; + const council = respondents.length - mayoral; + const wards = new Set(respondents.filter((r) => r.ward).map((r) => r.ward)) + .size; + + /* The roster the cards name, in the order `fieldSentiment` sorted it + (surname), and the seat each of them is running for — which is both what + prints on a plate and what splits each answer into its two races. */ + const roster = respondents.map((r) => ({ key: r.key, name: r.name })); + const seats = Object.fromEntries( + respondents.map((r) => [ + r.key, + r.ward + ? { race: "councillor" as const, label: `Ward ${r.ward}` } + : { race: "mayor" as const }, + ]), + ); + + const entries = survey ? candidateAnswers(survey, responses) : []; + const groups = comparedQuestions( + entries, + roster, + survey ? questionnaireShape(survey, responses) : undefined, + ); + /* Counted off the cards rather than off the questionnaire: a question + nobody has answered yet draws no card, and a stats row that claims one + more question than the page shows is a stats row a reader can catch. */ + const questionCount = groups.reduce( + (n, group) => n + group.questions.length, + 0, + ); + + return ( +
+
+ {/* ── Breadcrumb ─────────────────────────────────────── */} +
+ + Toronto 2026 + + / + Where they stand +
+ + {/* ── Hero ───────────────────────────────────────────── */} +
+

The whole field

+

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

+
+ + {/* ── Key stats ──────────────────────────────────────── */} +
+ + + + +
+ + {/* ── The field, question by question ────────────────── */} + {groups.length > 0 && respondents.length > 0 ? ( +
+ {/* No roster above the cards, unlike the ward and mayoral pages. + Theirs names one ballot line and links each candidate to their + campaign; this page's field is thirty-odd people across a + mayoral race and two dozen wards, and a flat list of them is a + list with no ballot behind it. The seat on each plate is the + pointer instead. */} + + + +
+ ) : ( +
+

+ 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 + +
+
+ +
+ + {/* ── Method ─────────────────────────────────────────── */} +
+

+ Each card is one question, and each block inside it is one of the + answers offered, printed in the wording the candidates were shown. + Under it are the candidates who gave that answer, with the seat + they are running for. Options nobody picked are not shown, and a + candidate who answered in their own words sits on no option. +

+

+ Most candidates also wrote a note explaining their answer. Thirty + of them under every question is more reading than this page can + carry, so the notes live on the ward and mayoral pages, where the + field is small enough to read them in full. Answers appear as + candidates return the questionnaire and staff review them, so the + field shown here grows through the campaign. +

+
+ + {/* ── Elsewhere ──────────────────────────────────────── */} +
+ + + The mayoral field, side by side + + + + + + + Find your ward + + +
+
+
+ ); +} + +function Stat({ + value, + label, + small = false, + last = false, +}: { + value: number | string; + label: string; + small?: boolean; + last?: boolean; +}) { + return ( +
+
+ {value} +
+
+ {label} +
+
+ ); +} diff --git a/src/app/toronto/vote/2026/mayor/candidates/page.tsx b/src/app/toronto/vote/2026/mayor/candidates/page.tsx new file mode 100644 index 00000000..0f670705 --- /dev/null +++ b/src/app/toronto/vote/2026/mayor/candidates/page.tsx @@ -0,0 +1,307 @@ +import type { Metadata } from "next"; +import Image from "next/image"; +import Link from "next/link"; +import { ArrowLeft, ArrowRight } from "lucide-react"; + +import { + IncumbentBadge, + SiteLink, +} from "@/components/elections/ElectionLanding"; +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 type { CandidateView } from "@/lib/elections/election-data"; +import { ELECTION, getToronto2026 } from "../../data"; + +/* Everyone running for mayor, on a page of their own. + * + * WHY IT EXISTS + * The roster lived on the landing page as a grid of portrait cards, and the + * field is now fifty-odd people: half a screen of the city's front page + * spent on a list whose reader either wants one name or wants all of them, + * and in both cases is better served somewhere they can look properly. The + * landing page's job is to point at races. This is the race. + * + * It is also the page /mayor never was. That route is titled "Candidates for + * Mayor" and holds the questionnaire read across the field — thirty-odd + * 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. + * + * 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 + * comes here holding a name needs to find out it is no longer a candidate. + */ + +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.", + 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.", + 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); + + const answered = roster.filter((candidate) => candidate.answers); + const quiet = roster.filter((candidate) => !candidate.answers); + const withdrawn = view.mayoral.filter((candidate) => candidate.withdrawn); + const sites = roster.filter((candidate) => candidate.website).length; + + return ( +
+
+ {/* ── Breadcrumb ─────────────────────────────────────── */} +
+ + Toronto 2026 + + / + Candidates for Mayor +
+ + {/* ── Hero ───────────────────────────────────────────── */} +
+

City of Toronto

+

+ Everyone running for mayor +

+

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

+
+ + {/* ── Key stats ──────────────────────────────────────── */} +
+ + + +
+ +
+ Days until polls open +
+
+
+ + {/* ── Answered ───────────────────────────────────────── */} + {answered.length > 0 && ( +
+ + +
+ )} + + {/* ── Yet to respond ─────────────────────────────────── */} + {quiet.length > 0 && ( +
+ + +
+ )} + + {/* ── Withdrawn ──────────────────────────────────────── */} + {withdrawn.length > 0 && ( +
+ + +
+ )} + + {/* ── Source note ────────────────────────────────────── */} +
+

+ Registered candidates from the City Clerk’s official list, + refreshed daily. The field is not final until nominations close + {view.nominationCloseLabel + ? ` on ${view.nominationCloseLabel}` + : ""} + . +

+
+ + {/* ── Elsewhere ──────────────────────────────────────── */} +
+ + + Where the whole field stands + + + + + + + Find your ward + + +
+
+
+ ); +} + +function Stat({ value, label }: { value: number; label: string }) { + return ( +
+
+ {value} +
+
+ {label} +
+
+ ); +} + +function GroupHead({ + eyebrow, + title, + blurb, + action, +}: { + eyebrow: string; + title: string; + blurb: string; + action?: { label: string; href: string }; +}) { + return ( +
+
+

{eyebrow}

+

+ {title} +

+

+ {blurb} +

+
+ {action && ( + + {action.label} + + + )} +
+ ); +} + +/** The field as a grid of rows. + * + * A row, not the landing page's card: fifty-three of anything is a list, and + * the portrait was the tallest thing on a tile whose text is a name and a + * link. Kept small it still does the one job a portrait does here — making a + * name a person — without setting the height of the page. */ +function Roster({ candidates }: { candidates: CandidateView[] }) { + return ( + + ); +} diff --git a/src/app/toronto/vote/2026/mayor/page.tsx b/src/app/toronto/vote/2026/mayor/page.tsx new file mode 100644 index 00000000..29e6f850 --- /dev/null +++ b/src/app/toronto/vote/2026/mayor/page.tsx @@ -0,0 +1,270 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { ArrowLeft, ArrowRight } from "lucide-react"; + +import { CandidateRoster } from "@/components/elections/CandidateRoster"; +import { + QuestionnaireCards, + questionnaireHeadings, +} from "@/components/elections/QuestionnaireCards"; +import { QuestionnaireRail } from "@/components/elections/QuestionnaireRail"; +import { SurveyCta } from "@/components/elections/SurveyCta"; +import CountdownDays from "@/components/elections/CountdownDays"; +import { + byCandidateKey, + candidateAnswers, + comparedQuestions, + questionnaireShape, + surveyRoster, +} from "@/lib/elections/candidate-answers"; +import { + CANDIDATE_QUESTIONNAIRE_SLUG, + fetchCandidateResponses, +} from "@/lib/elections/candidate-responses"; +import { fetchSurvey } from "@/lib/elections/survey"; +import { daysUntil } from "@/lib/elections/dates"; +import { ELECTION, getToronto2026 } from "../data"; + +/* How the mayoral field answered — question first. + * + * WHAT THIS PAGE USED TO BE, AND WHY IT CHANGED + * A grid: every question a row, every candidate a column, every candidate on + * the ballot given a column whether they wrote back or not. That shape is + * right for a ward, where a field of four fits across a laptop and an empty + * column is a visible fact about a named person. + * + * Toronto's mayoral ballot is fifty-three people. The grid came out 12,558px + * wide — thirteen screens of sideways drag — and forty-four of those columns + * were empty, because nine candidates answered. The reader had to haul past + * forty-four blanks to compare the nine. An empty column stops being a + * finding somewhere around the tenth one; after that it is furniture. + * + * So the page turns ninety degrees. The question becomes the object and the + * candidates are filed inside it, under the answer each one gave — the same + * cards the ward pages use, so the two can never disagree about how a + * questionnaire reads. Nine respondents is a comfortable fit: it is a + * handful of names under each answer, which is what the form was built for, + * where the fifty-three-column grid was thirteen screens of drag. + * + * The forty-four who have not answered are not named on the cards. On a + * ward, where the field is a dozen, every card names its silent candidates; + * here that would be fifteen hundred names saying one thing. They are on + * the roster page, which groups the field by exactly that line, and this + * page links to it from the hero and names the count in its stats. + */ + +export const metadata: Metadata = { + title: "How the mayoral candidates answered", + description: + "How the candidates for Mayor of Toronto answered our questionnaire in the October 26, 2026 election — question by question, with the whole mayoral field on each one.", + alternates: { canonical: `${ELECTION.basePath}/mayor` }, + openGraph: { + title: "How the mayoral candidates answered — Toronto 2026 Election", + description: + "Every answer the mayoral field gave us, read question by question.", + type: "website", + }, +}; + +export default async function MayorPage() { + const [view, survey, responses] = await Promise.all([ + getToronto2026(), + fetchSurvey(ELECTION.slug, CANDIDATE_QUESTIONNAIRE_SLUG).catch(() => null), + fetchCandidateResponses(ELECTION.slug), + ]); + + /* The ballot line, and the part of it that wrote back. + + The whole election's responses come back from one fetch — the counts a + candidate's answer is measured against are the field's, not this race's — + 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; + + const ballotKeys = new Set(ballot.map((candidate) => candidate.key)); + const answers = survey + ? byCandidateKey( + candidateAnswers(survey, responses).filter((entry) => + ballotKeys.has(entry.key), + ), + ) + : {}; + const roster = surveyRoster(ballot, answers); + const mayoral = roster.filter((candidate) => candidate.answers); + const groups = comparedQuestions( + mayoral.map((candidate) => candidate.answers!), + mayoral, + survey ? questionnaireShape(survey, responses) : undefined, + ); + const questionCount = groups.reduce( + (n, group) => n + group.questions.length, + 0, + ); + + return ( +
+
+ {/* ── Breadcrumb ─────────────────────────────────────── */} +
+ + Toronto 2026 + + / + Mayor +
+ + {/* ── Hero ───────────────────────────────────────────── */} +
+

City of Toronto

+

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

+ + Every candidate for mayor + + +
+ + {/* ── Key stats ──────────────────────────────────────── */} +
+ + + +
+ +
+ Days until polls open +
+
+
+ + {/* ── The field, question by question ────────────────── */} + {groups.length > 0 && mayoral.length > 0 ? ( +
+ {/* THE WHOLE BALLOT, ONCE + + This listed the nine who answered and nobody else, under the + heading "Answered our questionnaire" — which is a page about + the mayoral race that never names most of the people in it. A + reader arriving with a name in mind, and most do, could not + find out from this page whether that person is running. + + So it is the ballot: every registered candidate, in surname + order, each linked to their campaign. Who answered is what the + thirty-four cards below are made of, and does not need saying + twice. Still one list rather than two — a second heading of + forty-four names reading "yet to answer" is a scoreboard, and + the cards keep that score already. */} + + + + +
+ ) : ( +
+

+ No answers from the mayoral field 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 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. +

+

+ Registered candidates come from the City Clerk’s list, less + anyone who has withdrawn. The field is not final until nominations + close + {view.nominationCloseLabel + ? ` on ${view.nominationCloseLabel}` + : ""} + . +

+
+ + {/* ── Elsewhere ──────────────────────────────────────── */} +
+ + + The whole field, mayoral and council + + + + + + + Find your ward + + +
+
+
+ ); +} + +function Stat({ value, label }: { value: number; label: string }) { + return ( +
+
+ {value} +
+
+ {label} +
+
+ ); +} diff --git a/src/app/toronto/vote/2026/page.tsx b/src/app/toronto/vote/2026/page.tsx index b39fc3f2..3270dc1d 100644 --- a/src/app/toronto/vote/2026/page.tsx +++ b/src/app/toronto/vote/2026/page.tsx @@ -5,6 +5,7 @@ import { WARD_GEO } from "./wardGeo"; import { ELECTION, getToronto2026 } from "./data"; import { ADVANCE_VOTING_PATH, + ELECTION_DAY, HOW_TO_VOTE_PATH, KEY_DATES_PATH, VOTE_BY_MAIL_PATH, @@ -30,6 +31,12 @@ export default async function Toronto2026ElectionPage() { } renderWardMap={(ward) => ( - Toronto votes Monday, October 26. Add your name — then bring someone - with you. + Toronto votes Monday, October 26. Answer the questions we put to the + candidates and see which of them line up with you. ), + /* WHAT IS ONLY HERE + A card earns its place by going somewhere a reader would not + otherwise get to, and by being the thing they came for. What is left + is the two questionnaire reads — one race, then every race — with + the survey between them; ElectionLanding supplies the mayoral card + and the survey card itself. + + Everything else is reachable from the section that owns it, which is + where a reader looks for it anyway: the question set is linked from + the survey and from every questionnaire page, the pledge from the + closing band, and the wards from the ward grid two hundred pixels + below. */ + explore: [ + { + eyebrow: "Every race", + title: "Where the candidates stand", + blurb: + "Mayor and council together, question by question: where the field agrees, and where it splits.", + href: `${ELECTION.basePath}/issues`, + }, + ], guideLinks: [ { label: "See all key dates", href: KEY_DATES_PATH }, { label: "How to vote in Toronto", href: HOW_TO_VOTE_PATH }, diff --git a/src/app/toronto/vote/2026/survey/AgreementChart.tsx b/src/app/toronto/vote/2026/survey/AgreementChart.tsx new file mode 100644 index 00000000..64b7ce09 --- /dev/null +++ b/src/app/toronto/vote/2026/survey/AgreementChart.tsx @@ -0,0 +1,188 @@ +import type { Alignment, CandidateScore } from "@/lib/elections/alignment"; + +/* How much of the questionnaire each candidate answered the way you did. + * + * WHAT THIS REPLACED + * A card per candidate, each carrying a grid of thirty-three little squares — + * one per question, coloured by the option they picked and faded where it was + * not yours. It was a handsome object and the wrong one. Three faults: + * + * · It answered the wrong question. A reader arriving here has just spent ten + * minutes on a questionnaire and wants a ranking: who is closest to me. The + * card grid gave a ranking you had to assemble by reading a two-digit + * figure out of every card and holding all of them in your head, because + * the cards were laid across four columns and the eye cannot compare down a + * grid. + * · The squares were unreadable at their size. Thirty-three cells across a + * card three hundred pixels wide are seven pixels each; which question a + * cell stood for was recoverable only by hovering it one at a time. + * · It matched nothing else on the site. Every other page in the tracker is + * rules, plates and house type. This was a heatmap. + * + * WHAT IT IS NOW + * A ranked list, one candidate per ruled row, each with a single bar divided + * into how you and they compare. The ranking is the layout: the longest bar + * is at the top and a reader gets the order by looking down the left edge, + * which is the thing they came for. + * + * The bar is the whole questionnaire every time, so the rows are comparable — + * a candidate who answered nine questions and agreed on all nine does not + * draw the same bar as one who answered thirty-three and agreed on all of + * them. Silence takes up its own share of the bar, which is the honest + * picture of a candidate who barely filled the thing in. + * + * WHERE THE DETAIL WENT + * Nowhere it was not already. The question cards below this chart file every + * candidate under the answer they gave — and the reader's own plate sits in + * those blocks too, so "where do we differ" is answered by scrolling rather + * than by hovering thirty-three squares in turn. A hover panel here was the + * same information in a worse format, and it reserved five empty lines under + * every card to say "Hover a square for the question". + */ + +/* The bands, in the order they stack along the bar: agreement first, from the + left edge, because that is the quantity being ranked and a reader compares + the length of the run that starts in the same place on every row. + + Agreement takes the accent. Everything else is the same ink at descending + strengths rather than four hues — the bar is one quantity split up, not four + competing ones, and a palette here would say that disagreeing and not + answering are different in kind rather than different in degree. */ +const BANDS = [ + { + key: "agreed", + label: "Same as you", + className: "bg-accent", + }, + { + key: "differed", + label: "Different", + className: "bg-dark/30", + }, + { + key: "unclear", + label: "Their own words", + className: "bg-dark/12", + }, + { + key: "unanswered", + label: "No answer", + className: "bg-dark/6", + }, +] as const; + +type BandKey = (typeof BANDS)[number]["key"]; + +export function AgreementChart({ + alignment, + scores, +}: { + alignment: Alignment; + /** the candidates in the order they should print; defaults to the + * alignment's own ranking, which is by agreement */ + scores?: CandidateScore[]; +}) { + const { rows } = alignment; + const ordered = scores ?? alignment.scores; + if (rows.length === 0 || ordered.length === 0) return null; + + const total = rows.length; + + /* A band that never occurs is left out of the key. Most fields produce three + of the four, and a key naming a colour nobody can find on the page is a + colour the reader goes looking for. */ + const present = BANDS.filter((band) => + ordered.some((score) => score[band.key] > 0), + ); + + return ( +
+

+ Every bar is the same {total} questions. The filled run is how many of + them that candidate answered the way you did. +

+ +
+ {ordered.map((score) => ( + + ))} +
+ + {/* The key, under the rows rather than over them: a reader looks at the + bars first and comes here only when a band needs naming. */} +
    + {present.map((band) => ( +
  • +
  • + ))} +
+
+ ); +} + +/* One candidate. + * + * Three tracks on a wide screen — name, bar, figures — so the bars all start + * and end on the same two lines and their lengths can be read against each + * other without a gridline to help. Below `sm` the name takes its own line + * above the bar, because a name column narrow enough to leave a usable bar is + * a name column that wraps every candidate onto three lines. */ +function Row({ score, total }: { score: CandidateScore; total: number }) { + const share = score.share === null ? null : Math.round(score.share * 100); + + return ( +
+

+ {score.candidateName} +

+ + + + {/* The count is the honest figure and leads; the share follows it in + muted ink. "24 of 33" is a fact about a questionnaire, where a bare + 73% invites being read as a poll result. */} +

+ + {score.agreed} + + + of {total} + {share !== null && ` · ${share}%`} + +

+
+ ); +} + +function Bar({ score, total }: { score: CandidateScore; total: number }) { + /* Everything the candidate did with the questionnaire, summing to its whole + length — including the questions they never reached, which is why the + denominator here is the question count and not `compared`. */ + const segments = BANDS.map((band) => ({ + ...band, + n: score[band.key as BandKey], + })).filter((segment) => segment.n > 0); + + return ( + `${segment.n} ${segment.label.toLowerCase()}`) + .join(", ")} + > + {segments.map((segment) => ( + + ))} + + ); +} diff --git a/src/app/toronto/vote/2026/survey/AlignmentResults.tsx b/src/app/toronto/vote/2026/survey/AlignmentResults.tsx new file mode 100644 index 00000000..8185e838 --- /dev/null +++ b/src/app/toronto/vote/2026/survey/AlignmentResults.tsx @@ -0,0 +1,377 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { ChevronDown } from "lucide-react"; + +import { Select } from "@/components/ui/select"; + +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; + +import { + QuestionnaireCards, + questionnaireHeadings, +} from "@/components/elections/QuestionnaireCards"; +import { QuestionnaireRail } from "@/components/elections/QuestionnaireRail"; +import type { Heading } from "@/components/custom/signpost/config"; +import type { Seat } from "@/components/elections/QuestionRollCall"; +import { AgreementChart } from "./AgreementChart"; +import { + byCandidateKey, + candidateAnswers, + comparedQuestions, + questionnaireShape, + surveyRoster, +} from "@/lib/elections/candidate-answers"; +import { lastName } from "@/lib/elections/names"; +import type { CandidateSurveyResponse } from "@/lib/elections/alignment"; +import type { Survey } from "@/lib/elections/survey"; +import type { Alignment, CandidateScore } from "@/lib/elections/alignment"; + +/* Alignment between one resident's answers and their ward's candidates. + * + * FORM + * The same grid the ward and mayoral pages draw, with one column added: the + * reader's own. Questions run down, candidates run across, and "You" is the + * first column, next to the question and pinned beside it — so the + * comparison the page exists to make is two cells on one line rather than + * two blocks a scroll apart. + * + * It was a block per question, with the ward's candidates filed under the + * option each picked and the reader's own pick badged among them. That read + * well for one question and did not compose: the reader's answer was + * restated thirty times, and the candidates who never returned the + * questionnaire — most of a ward's ballot, most of the campaign — were + * nowhere on the page, so a reader could not tell a candidate who disagreed + * with them from one who had said nothing at all. The grid has a column for + * both, and says which is which in every row. + * + * The ranked list stays. "Who is closest to me" is the question that brought + * the reader here, and it is an answer no grid gives at a glance. + */ + +/** A candidate on the ward's ballot, as the responses route hands them over. */ +export type SurveyRosterCandidate = { + key: string; + name: string; + website?: string; + withdrawn?: boolean; +}; + +/* The reader, as the cards name them. Named as the questionnaire would name a + candidate, because that is exactly what they are to the pivot — a respondent + with answers, run through the same code as everyone else's. */ +const YOU = "You"; + +/* How the agreement chart is ordered. + * + * The cards below it are not sorted by this and should not be: a card files + * its candidates under the answer they gave, and inside a group they run by + * surname so a reader finds the same person in the same place on all thirty + * questions. A ranking belongs in the thing that is a ranking. + * + * Agreement first by default: the reader has just answered thirty questions, + * and "who is closest to me" is the question that brought them here. The rest + * are the orders a reader actually asks for next — the opposite end of the + * same list, someone they already have a name for, and who put the most on + * the record. */ +const SORTS = [ + { value: "agreement", label: "Most in common with you" }, + { value: "disagreement", label: "Least in common with you" }, + { value: "answered", label: "Most questions answered" }, + { value: "name", label: "Name (A–Z)" }, +] as const; + +type Sort = (typeof SORTS)[number]["value"]; + +function sortScores(scores: CandidateScore[], sort: Sort): CandidateScore[] { + const byName = (a: CandidateScore, b: CandidateScore) => + lastName(a.candidateName).localeCompare(lastName(b.candidateName)) || + a.candidateName.localeCompare(b.candidateName); + + /* A candidate with nothing comparable has no share to rank on — a null is + not a zero, and sorting it as one would put "we cannot say" among the + people who disagree with you. They go last either way. */ + const share = (score: CandidateScore) => score.share ?? -1; + + return [...scores].sort((a, b) => { + switch (sort) { + case "agreement": + return share(b) - share(a) || byName(a, b); + case "disagreement": + return ( + (a.share === null ? 1 : 0) - (b.share === null ? 1 : 0) || + share(a) - share(b) || + byName(a, b) + ); + case "answered": + return b.compared - a.compared || share(b) - share(a) || byName(a, b); + case "name": + return byName(a, b); + } + }); +} + +/** One ballot a voter marks: the race, its candidates, and how the reader + * lines up with them. */ +export type RaceComparison = { + key: string; + /** e.g. "For mayor" or "Ward 9 — Davenport" */ + label: string; + alignment: Alignment; + /** that race's published responses, as fetched */ + responses: CandidateSurveyResponse[]; + /** everyone on that ballot, respondents or not */ + roster: SurveyRosterCandidate[]; +}; + +export default function AlignmentResults({ + races, + survey, + answers, +}: { + /** in ballot order — mayor first, then the ward */ + races: RaceComparison[]; + survey: Survey; + /** the reader's own answers, by question id */ + answers: Record; +}) { + /* Memoised, not just filtered inline: `grids` keys off this, and a fresh + array every render would rebuild both pivots on every keystroke the page + ever sees. */ + const shown = useMemo( + () => + races.filter( + (race) => + race.alignment.scores.length > 0 && race.alignment.rows.length > 0, + ), + [races], + ); + + const grids = useMemo( + () => shown.map((race) => raceGrid(survey, race, answers)), + [shown, survey, answers], + ); + + /* The rail's list: each race's sections, in ballot order, namespaced by + race. Both races ask the same questionnaire, so without the prefix the + document would hold two elements called "housing" and the rail would + scroll to whichever came first, whatever you clicked. + + A race's own heading takes the level-2 slot and its sections hang under + it as level 3, which is the one place in the tracker where the rail + nests — there are two questionnaires on this page and the sections would + otherwise read as one run of eighteen. + + A closed race unmounts its sections and the rail simply cannot find them: + the spy skips a heading with no element and the jump no-ops. Both races + open by default, so that is the state after a reader has folded one away, + which is a reader saying they do not want those sections. */ + const headings: Heading[] = useMemo( + () => + shown.flatMap((race, i) => [ + { id: race.key, text: race.label, level: 2 as const }, + ...questionnaireHeadings(grids[i].groups, { prefix: race.key }).map( + (heading) => ({ ...heading, level: 3 as const }), + ), + ]), + [shown, grids], + ); + + if (shown.length === 0) return null; + + /* No heading of its own. Once the answers are in, the page is this + comparison and nothing else, so the card's masthead says so — a second + title under the first was two announcements of one thing, with an accent + rule and a rule-off between them. What the masthead carries now used to + live here: see `SurveyClient`. */ + return ( +
+ +
+ {shown.map((race, i) => ( + + ))} +
+
+ + {/* One note for the whole page rather than one under every grid: it is + the same reading in both races. */} +

+ Reading these. Each card + is one question, with the candidates filed under the answer they gave — + and your own answer among them, marked. Options nobody picked are not + shown. The bars above count only the candidates who returned the + questionnaire, not the whole ballot. +

+
+ ); +} + +/* The cards, and the reader inside them. + * + * `candidateAnswers` does the pivoting for both — the reader is passed through + * it as a response of their own, so their answers are built by the same code + * that builds everyone else's and cannot disagree with them. + * + * Computed by the parent rather than inside the block that renders it, because + * the scroll rail has to list both races' sections before either block has + * rendered, and the sections are a property of this result. One computation, + * two readers. */ +function raceGrid( + survey: Survey, + race: RaceComparison, + answers: Record, +) { + + const entries = candidateAnswers(survey, race.responses); + const byKey = byCandidateKey(entries); + const field = surveyRoster( + race.roster.length > 0 + ? race.roster + : entries.map((entry) => ({ + key: entry.key, + name: entry.candidateName, + })), + byKey, + ); + + const [you] = candidateAnswers(survey, [ + { + candidateName: YOU, + ward: "", + surveySlug: survey.slug, + surveyVersion: "", + answers, + explanations: {}, + source: "form" as const, + }, + ]); + + /* Every name the cards can print, the reader first. The order here is + only the order the pivot hands the rows over in — inside a card the + candidates are filed under the answer they gave and sorted by surname, + so a reader looking for one person finds them in the same place on all + thirty questions. The agreement sort above drives the chart, which is + where a ranking belongs: it is a ranking. */ + const candidates = [ + ...(you ? [{ key: you.key, name: YOU }] : []), + ...field.map(({ key, name, website }) => ({ key, name, website })), + ]; + + /* The ballot line each plate belongs to. + + A race block is one race, so its heading already says which — but the + two blocks sit one under the other on a page a reader scrolls through + with a candidate's name in mind, and a plate that says only a name is + a plate whose race depends on remembering which heading you passed. + Every plate carries its own. The reader's own plate carries none: they + are not running for anything. */ + const seat: Seat = + race.key === "mayor" + ? { race: "mayor", label: "Mayor" } + : { race: "councillor", label: "Councillor" }; + const seats = Object.fromEntries( + field.map((candidate) => [candidate.key, seat]), + ); + + return { + yourKey: you?.key, + seats, + /* The reader counts as a respondent: they answered the questionnaire, + which is the whole reason there is a page. */ + respondents: [ + ...(you ? [{ key: you.key, name: YOU }] : []), + ...field.filter((candidate) => candidate.answers), + ], + silent: field + .filter((candidate) => !candidate.answers) + .map(({ key, name, website }) => ({ key, name, website })), + groups: comparedQuestions( + you ? [you, ...entries] : entries, + candidates, + questionnaireShape(survey, race.responses), + ), + }; +} + +type RaceGrid = ReturnType; + +/* ── One race: who is closest, then everyone question by question ─── */ + +function RaceBlock({ + race, + grid, +}: { + race: RaceComparison; + grid: RaceGrid; +}) { + const [sort, setSort] = useState("agreement"); + const scores = useMemo( + () => sortScores(race.alignment.scores, sort), + [race.alignment.scores, sort], + ); + + + + /* Both races open to begin with. A reader who has just answered thirty + questions is owed the answer to them, not two closed doors — the fold is + here so they can put one ballot away while they read the other, which is + a different thing from making them ask for either. */ + return ( + + +

+ {race.label} +

+ {/* What is behind the fold, so a closed race still says whether it + is worth opening. */} + + {race.alignment.scores.length} answered + +
+ + +
+ + onChange(e.target.value)} + className={`${FIELD_CLASS} ${question.id === "postal_code" ? "max-w-[240px]" : "max-w-[420px]"}`} + /> + ) : question.type === "textarea" ? ( +