diff --git a/src/app/toronto/vote/2026/candidates/[candidate]/opengraph-image.tsx b/src/app/toronto/vote/2026/candidates/[candidate]/opengraph-image.tsx new file mode 100644 index 0000000..252ba7b --- /dev/null +++ b/src/app/toronto/vote/2026/candidates/[candidate]/opengraph-image.tsx @@ -0,0 +1,62 @@ +import { ImageResponse } from "next/og"; +import { ElectionOGImage, OG_SIZE, logoDataUri } from "../../election-og"; +import { getToronto2026Candidate } from "../../data"; + +export const alt = "Toronto 2026 Election candidate — Build Canada"; +export const size = OG_SIZE; +export const contentType = "image/png"; + +/* No generateStaticParams, deliberately — unlike the 25 ward images beside it. + The ballot is 388 candidates, and prerendering a PNG for every one would put + 388 satori renders in the build for images that are only ever fetched when + somebody shares that particular candidate. They render on demand and cache + from then on. */ + +export default async function Image({ + params, +}: { + params: Promise<{ candidate: string }>; +}) { + const { candidate: slug } = await params; + const [logoSrc, profile] = await Promise.all([ + logoDataUri(), + getToronto2026Candidate(slug), + ]); + + if (!profile) { + return new ImageResponse( + , + { ...size }, + ); + } + + const { candidate, races, wards } = profile; + const ward = wards[0]; + const race = races[0]; + + /* The ward locator earns its place here: for a council candidate it answers + "is this my ward" before the reader has read the name. A mayoral candidate + gets the whole map unfilled, because every ward is theirs. */ + return new ImageResponse( + , + { ...size }, + ); +} diff --git a/src/app/toronto/vote/2026/candidates/[candidate]/page.tsx b/src/app/toronto/vote/2026/candidates/[candidate]/page.tsx new file mode 100644 index 0000000..0bdb4fb --- /dev/null +++ b/src/app/toronto/vote/2026/candidates/[candidate]/page.tsx @@ -0,0 +1,583 @@ +import type { Metadata } from "next"; +import Image from "next/image"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { ArrowLeft, ArrowRight, ArrowUpRight } from "lucide-react"; + +import { CandidateSiteLink } from "@/components/elections/CandidateSiteLink"; +import { + QuestionnaireCards, + questionnaireHeadings, +} from "@/components/elections/QuestionnaireCards"; +import { QuestionnaireRail } from "@/components/elections/QuestionnaireRail"; +import CountdownDays from "@/components/elections/CountdownDays"; +import { IncumbentBadge } from "@/components/elections/ElectionLanding"; +import { + BIO_QUESTION_ID, + comparedQuestions, +} from "@/lib/elections/candidate-answers"; +import { rosterSurvey } from "@/lib/elections/survey-answers"; +import { daysUntil } from "@/lib/elections/dates"; +import { firstName, possessive } from "@/lib/elections/names"; +import type { CandidateProfile, RaceView } from "@/lib/elections/election-data"; +import { + ELECTION, + candidateSlug, + getToronto2026Candidate, + getToronto2026Candidates, +} from "../../data"; + +/* One candidate, on a page of their own. + * + * WHY IT EXISTS + * A candidate's name was a link to their campaign site — on the landing + * page, on every ward page, in every questionnaire roster. So the one thing + * on the page that identifies a person was also the way off the site, and a + * reader who clicked it left holding a campaign's own account of the + * candidate: no ward, no questionnaire answers, no indication of whether + * they had answered at all, and no way back but the back button. + * + * Everything we know about a candidate now has somewhere to live. The name + * goes here; the campaign site is a link on this page, one fact among the + * ward they are running in, the answers they gave us, and where the rest of + * their ballot line is. The outbound link is still instrumented exactly as + * it was (CandidateSiteLink), so the click-through funnel survives with a + * step in front of it. + * + * THE SLUG + * `candidateSlug(name)` — the roster is rebuilt from the City Clerk's feed + * daily and carries no candidate ids, so the URL has to come from the name. + * That makes the set of valid URLs a function of today's roster: a name the + * Clerk corrects is a new URL, and a slug naming nobody 404s rather than + * rendering an empty profile. + * + * WHAT IT DOES NOT DO + * It does not editorialise. Bios are hand-written where we have them and + * absent for most of a fifty-three-name ballot; where there is no bio, no + * site and no questionnaire, the page says plainly that this is a registered + * candidate we know little about, rather than padding the gap. */ + +/** + * One race, named in full: "Mayor", "Councillor — Etobicoke North", "Trustee — + * Toronto Catholic District School Board, Ward 2". + * + * `race.label` is enough for the two races the rest of the site covers, and + * not enough for a school board: the boards publish no district names, so a + * trustee race's label is the bare word "Trustee" and its district lives in + * `districtNumber`, in the board's own ward numbering. + */ +function raceTitle(race: RaceView): string { + if (race.districtName) return race.label; + if (race.officeBody) { + return race.districtNumber !== null + ? `${race.seat} — ${race.officeBody}, Ward ${race.districtNumber}` + : `${race.seat} — ${race.officeBody}`; + } + return race.seat; +} + +/** The races they are standing in — `races` has already set aside the ones + * they withdrew from, which the page names separately. */ +function raceLabel(profile: CandidateProfile): string { + return profile.races.map(raceTitle).join(" and "); +} + +export async function generateStaticParams() { + const profiles = await getToronto2026Candidates(); + return profiles.map((profile) => ({ + candidate: candidateSlug(profile.candidate.name), + })); +} + +export async function generateMetadata({ + params, +}: { + params: Promise<{ candidate: string }>; +}): Promise { + const { candidate: slug } = await params; + const profile = await getToronto2026Candidate(slug); + if (!profile) return { title: "Candidate not found" }; + + const { name } = profile.candidate; + const race = raceLabel(profile); + + return { + title: `${name} — ${race}`, + description: `${name} is a registered candidate for ${race} in Toronto's October 26, 2026 municipal election. Their campaign site, and how they answered our questionnaire.`, + alternates: { canonical: `${ELECTION.basePath}/candidates/${slug}` }, + openGraph: { + title: `${name} — Toronto 2026 Election`, + description: `Where ${name} stands, and how to reach their campaign.`, + type: "profile", + }, + }; +} + +export default async function CandidatePage({ + params, +}: { + params: Promise<{ candidate: string }>; +}) { + const { candidate: slug } = await params; + const profile = await getToronto2026Candidate(slug); + if (!profile) notFound(); + + const { candidate, races, wards } = profile; + const ward = wards[0]; + const race = races[0]; + + /* The one-candidate case of what every roster page does: the questionnaire + is fetched for the whole election — the counts beside each answer are the + field's split — and narrowed to this candidate by key. A missing + questionnaire costs the answers, not the page. */ + const { answers, written } = await rosterSurvey( + ELECTION.slug, + new Set([candidate.key]), + ); + const surveyAnswers = answers[candidate.key]; + + /* What the candidate wrote, as against what they picked. + 55 of the 58 candidates who returned the questionnaire wrote a bio in it, + and until now the page showed none of them: the bio sits in the + questionnaire's `about-you` step, which the policy pivot excludes, so the + only bio this page could print was the hand-maintained one in + 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. */ + const prose = written[candidate.key] ?? []; + const selfBio = prose.find( + (entry) => entry.questionId === BIO_QUESTION_ID, + )?.text; + const otherProse = prose.filter( + (entry) => entry.questionId !== BIO_QUESTION_ID, + ); + + /* The ward pages' cards, given a roster of one. + `comparedQuestions` is the same pivot a ward runs — question first, the + candidates filed under the answer they gave — so this page's cards are + literally the ward's cards with a field of one person in them. A card + therefore shows the one option this candidate picked, in the option's own + colour, with their note printed in the open underneath their name plate. + What it cannot show is the split, since the other candidates are not in + the roster; "How the whole city answered" at the foot is the way to it. */ + const roster = [ + { + key: candidate.key, + name: candidate.name, + website: candidate.website, + bio: candidate.bio || undefined, + }, + ]; + const groups = surveyAnswers + ? comparedQuestions([surveyAnswers], roster) + : []; + + const raceKind = profile.officeTypes[0] === "mayor" ? "mayor" : profile.officeTypes[0] === "trustee" ? "trustee" : "councillor"; + + /* Where the rest of this candidate's ballot line is — the ward page for a + councillor, the mayoral field for a mayoral candidate. */ + const ballotHref = ward + ? `${ELECTION.basePath}/wards/${ward.n}` + : raceKind === "mayor" + ? `${ELECTION.basePath}/mayor/candidates` + : ELECTION.basePath; + const ballotLabel = ward + ? `Everyone running in ${ward.name}` + : raceKind === "mayor" + ? "Everyone running for mayor" + : "The whole ballot"; + + return ( +
+
+ {/* ── Breadcrumb ─────────────────────────────────────── */} +
+ + Toronto 2026 + + / + {/* A link only where the crumb has its own page. Toronto's + school-board races have none — the site covers the mayor and the + 25 council wards — so a board crumb that pointed back at the + election index would have been a link to the crumb beside it. */} + {ballotHref === ELECTION.basePath ? ( + + {race.officeBody ?? race.seat} + + ) : ( + + {ward ? `Ward ${ward.number}` : race.seat} + + )} + / + {candidate.name} +
+ + {/* ── Hero ───────────────────────────────────────────── */} + {/* No eyebrow over the name. It printed the race — "Councillor — + Toronto Centre" — which the row directly beneath now says twice + over, as "Running for / Councillor" and "Ward 13 / Toronto + Centre". The breadcrumb above has already placed the reader too. + `raceTitle` still writes the page title and its description, where + the race is the one thing distinguishing one candidate from + another. */} +
+
+
+ {candidate.image ? ( + {candidate.name} + ) : ( + candidate.initials + )} +
+ +
+
+

+ {candidate.name} +

+ {candidate.tag === "Incumbent" && } + {candidate.withdrawn && ( + + Withdrawn + + )} +
+ + {candidate.withdrawn && ( +

+ Registered and since withdrawn — {candidate.name} cannot be + voted for. The page is kept because the name still appears on + lists elsewhere. +

+ )} + + {/* A candidate who registered somewhere, withdrew, and + registered somewhere else. Ten people on Toronto's 2026 + ballot have done it, and the page above names the race they + are actually standing in — this is the rest of the story, for + a reader who arrived from the ward they left. */} + {profile.withdrawnFrom.length > 0 && ( +

+ {`Previously registered for ${profile.withdrawnFrom + .map(raceTitle) + .join(" and ")}, and withdrawn.`} +

+ )} + + {candidate.bio && ( +

+ {candidate.bio} +

+ )} + + {/* Where we have nothing hand-written, say so rather than leave + the reader to read absence as a judgement. Most of a + fifty-three-name ballot is in exactly this state, and it is + the ordinary condition of a municipal candidate. */} + {!candidate.bio && !candidate.website && !selfBio && ( +

+ A registered candidate on the City Clerk’s list. We have + no campaign site or profile for {candidate.name} yet — this + page fills in as we learn more. +

+ )} + + {/* ── Links out ──────────────────────────────── */} +
+ {candidate.website && ( + + Campaign site + + + )} + + {candidate.socialLinks.length > 0 && ( +
+ {candidate.socialLinks.map((link) => ( + + {socialLabel(link.name)} + + ))} +
+ )} +
+
+
+
+ + {/* ── Where they're running ──────────────────────────── */} +
+ + {/* Only a genuinely at-large race is city-wide. A school-board + district is neither a city ward nor the whole city, and its own + ward number is the only name it has. */} + {/* The caption names which district, the line under it says what + that district is: "Ward 13" over "Toronto Centre". A city-wide + race is the same pair the other way about — "City-wide" is the + district's name and "Every ward votes" is what it amounts to — + so it swaps rather than putting a whole statement in the caption + slot. */} + +
+
+ Days until polls open +
+ +
+
+ + {/* ── In their own words ─────────────────────────────── */} + {/* Their bio, and any other prose the questionnaire asked them to + write, in one place and plainly attributed. + + A section of its own rather than a paragraph in the hero: these run + to a median of 800 characters and half of them are several + paragraphs, so in the hero the longest of them pushed the ballot + facts and the questionnaire off the screen. Here they have the room + the length needs, in the reading order a candidate page actually + has — who is this, then what do they think. + + TWO COLUMNS, because prose has a measure and a band does not. Set + as one column this was 640px of serif — the width a paragraph can + be read at — sitting in a 1300px section, so two-thirds of the band + was air with a heading floating at the top of it. The heading and + the attribution take a rail of their own and the prose keeps its + measure beside them, which spends the width on structure rather + than on a line too long to read. */} + {(selfBio || otherProse.length > 0) && ( +
+
+ {/* No eyebrow. "In their own words" over a line that already + says the candidate wrote this and we did not was the label + and its own caption, stacked. */} +

+ About {firstName(candidate.name)} +

+ {/* Whose words these are, said before they are read. A + candidate's account of themselves set in the same type as the + rest of the page would read as ours. In the rail it stays + beside the prose it qualifies rather than becoming a line the + reader passes once and scrolls away from. */} +

+ Written by {candidate.name} in answer to our questionnaire, and + published as given. Not our description of them. +

+
+ + {/* No measure cap. The prose runs the full width of its column: + capped at 70ch it left a third of the band empty, and filling + the width was the call. Leading goes up with the line length — + a long line needs more space beneath it for the eye to find + the start of the next one, which is the one thing that can be + done for readability without narrowing the column again. */} +
+ {selfBio && ( + + )} + + {otherProse.map((entry) => ( +
+

+ {entry.question} +

+ +
+ ))} +
+
+ )} + + {/* ── Questionnaire ──────────────────────────────────── */} +
+

Our questionnaire

+

+ {surveyAnswers + ? `Where ${candidate.name} stands` + : "Yet to answer"} +

+ + {surveyAnswers ? ( + <> +

+ {possessive(candidate.name)} own answers to the questions we put + to every candidate, published as given — including, where they + wrote one, their reasoning in their own words. +

+ + + + + ) : ( +

+ {candidate.name} has not returned our questionnaire. We publish + answers as they arrive, so check back — and{" "} + + see where the rest of the field stands + {" "} + in the meantime. +

+ )} +
+ + {/* ── Source note ────────────────────────────────────── */} +
+

+ Registered candidates from the City Clerk’s official list, + refreshed daily. Campaign sites are linked as published by the + candidate; a link is not an endorsement. +

+
+ + {/* ── Elsewhere ──────────────────────────────────────── */} +
+ + + + {ballotLabel} + + + + + Where the whole field stands + + + +
+
+
+ ); +} + +/* A cell of the "where they're running" row: what the cell is, then what it + says — "Running for" over "Councillor", not "Councillor" over "Running for". + The stat rows elsewhere on the site put the figure first because the figure + is the point and the caption is a unit ("53" / "On the ballot"). These cells + are not figures: "Councillor" arriving above the words telling you what it + is means the reader meets an answer before the question, and reads the cell + twice. */ +/* A written answer, as the candidate typed it. + * + * Blank lines are paragraph breaks — half of these bios have them, and run + * together into one block the reader gets a wall of prose that reads as though + * we had transcribed it carelessly. Single newlines inside a paragraph are + * kept by `whitespace-pre-line` rather than collapsed, because in these + * answers they are usually a deliberate list. */ +function Prose({ text, className }: { text: string; className?: string }) { + const paragraphs = text + .split(/\n\s*\n/) + .map((paragraph) => paragraph.trim()) + .filter(Boolean); + + return ( +
+ {paragraphs.map((paragraph, i) => ( +

+ {paragraph} +

+ ))} +
+ ); +} + +function Stat({ value, label }: { value: string; label: string }) { + return ( +
+
+ {label} +
+
+ {value} +
+
+ ); +} + +/** `social_links[].name` is an open vocabulary ("web", "facebook", "tiktok", + * …), so unknown names are title-cased rather than dropped. */ +function socialLabel(name: string): string { + if (name.toLowerCase() === "web") return "Website"; + return name.charAt(0).toUpperCase() + name.slice(1); +} diff --git a/src/app/toronto/vote/2026/data.ts b/src/app/toronto/vote/2026/data.ts index c9d62db..67362d4 100644 --- a/src/app/toronto/vote/2026/data.ts +++ b/src/app/toronto/vote/2026/data.ts @@ -13,17 +13,20 @@ import { WARD_SHAPES } from "./wardGeo"; import { + getCandidateProfiles, getElectionView, getNominationCloseLabel, getWardDetail, initialsFor, nameKey, + type CandidateProfile, type CandidateView, type ElectionDataOptions, type ElectionView, type WardDetail, type WardRosterEntry, } from "@/lib/elections/election-data"; +import { candidateSlug } from "@/lib/elections/candidate-profile"; import { getElection } from "@/lib/elections/registry"; import { MAYORAL_CANDIDATES, @@ -33,7 +36,8 @@ import { } from "./candidates"; export type { MayoralCandidate, CouncillorCandidate }; -export { initialsFor, nameKey }; +export { initialsFor, nameKey, candidateSlug }; +export type { CandidateProfile }; export const ELECTION = getElection("toronto-2026"); @@ -145,6 +149,7 @@ export async function getToronto2026Ward( label: `Councillor — ${ward.name}`, officeBody: null, districtName: ward.name, + districtNumber: number, wardNumbers: [number], atLarge: false, candidates, @@ -154,3 +159,56 @@ export async function getToronto2026Ward( trusteeRaces: [], }; } + +// ── Candidate pages ──────────────────────────────────────────────────────── + +/* Every candidate has a page of their own at ./candidates/[candidate], keyed + by `candidateSlug(name)`. Two things follow from the roster being rebuilt + from the Clerk's feed rather than stored: the slug has to be derivable from + the name, and a candidate who withdraws — or a name the Clerk corrects — + changes the set of valid URLs. So the routes are generated from whatever the + roster says today and anything outside it 404s. */ + +/** Every candidate in the election, mayoral and council alike, each with the + * race they are in. Falls back to the local roster when the API is down — + * which means the mayoral field only, since the fallback's ward races are + * assembled per ward. */ +export async function getToronto2026Candidates(): Promise { + const live = await getCandidateProfiles(ELECTION.slug, OPTIONS); + if (live) return live; + + const view = fallbackView(); + return view.mayoral.map((candidate) => ({ + candidate, + races: [ + { + id: "mayor||at-large", + seat: "Mayor", + label: "Mayor", + officeBody: null, + districtName: null, + districtNumber: null, + wardNumbers: [], + atLarge: true, + candidates: view.mayoral, + registeredCount: view.mayoral.length, + }, + ], + officeTypes: ["mayor"], + wards: [], + withdrawnFrom: [], + })); +} + +/** One candidate by their URL slug, or null for a slug that names nobody on + * the current roster. */ +export async function getToronto2026Candidate( + slug: string, +): Promise { + const profiles = await getToronto2026Candidates(); + return ( + profiles.find( + (profile) => candidateSlug(profile.candidate.name) === slug, + ) ?? null + ); +} diff --git a/src/app/toronto/vote/2026/mayor/candidates/page.tsx b/src/app/toronto/vote/2026/mayor/candidates/page.tsx index 0f67070..d21a78e 100644 --- a/src/app/toronto/vote/2026/mayor/candidates/page.tsx +++ b/src/app/toronto/vote/2026/mayor/candidates/page.tsx @@ -3,10 +3,8 @@ import Image from "next/image"; import Link from "next/link"; import { ArrowLeft, ArrowRight } from "lucide-react"; -import { - IncumbentBadge, - SiteLink, -} from "@/components/elections/ElectionLanding"; +import { IncumbentBadge } from "@/components/elections/ElectionLanding"; +import { CandidateNameLink } from "@/components/elections/CandidateNameLink"; import CountdownDays from "@/components/elections/CountdownDays"; import { surveyRoster } from "@/lib/elections/candidate-answers"; import { daysUntil } from "@/lib/elections/dates"; @@ -286,19 +284,17 @@ function Roster({ candidates }: { candidates: CandidateView[] }) { candidate.withdrawn ? "line-through decoration-1" : "" }`} > - {candidate.name} - - {candidate.tag === "Incumbent" && } - - {candidate.website && ( - - - )} + {candidate.tag === "Incumbent" && } + ))} diff --git a/src/components/elections/AnswerOptionList.tsx b/src/components/elections/AnswerOptionList.tsx deleted file mode 100644 index 25f18c7..0000000 --- a/src/components/elections/AnswerOptionList.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import { WedgeGlyph } from "@/components/charts/trilemma"; - -/* The options a question offered, as a list beside its chart. - * - * Shared by the ward pages and the survey's alignment view so the two cannot - * drift: an answer only means something read against the alternatives, and - * both places have to put them in the same order, with the same counts, and - * with the same glyph tying each row to its slice of the chart. - * - * The glyph is the chart's own wedge for that option, which is what lets the - * list be read straight onto the dial or bar without a legend. - */ - -export type OptionMark = { - /** which option this marks */ - index: number; - /** e.g. "Their answer", "Your answer" */ - label: string; -}; - -export function AnswerOptionList({ - options, - details, - counts, - colors, - marks = [], - markColor, - names, - showCounts = true, - valueFormat = String, -}: { - options: string[]; - /** parallel to `options`; a null entry simply has no expansion */ - details?: (string | null)[]; - counts: number[]; - /** parallel to `options` — the chart's fill for each, muted where unchosen */ - colors: string[]; - /** badges pinned to particular options */ - marks?: OptionMark[]; - /** - * Who picked each option, parallel to `options` — the comparison view lists - * the ward's candidates under the option they chose, which is the whole - * point of that page and the one thing a chart of shares cannot say. - */ - names?: string[][]; - /** badge and count colour; defaults to each option's own chart colour */ - markColor?: string; - /** - * The count column. Off where the chart beside the list already direct-labels - * every option with its count, which would otherwise print each number twice - * a centimetre apart. - */ - showCounts?: boolean; - /** - * How a count reads. Defaults to the number itself; pass `percentOf(total)` - * to match a chart beside the list that prints shares — the same quantity - * printed two ways a centimetre apart is worse than either. - */ - valueFormat?: (n: number) => string; -}) { - const marked = new Set(marks.map((mark) => mark.index)); - - return ( -
    - {options.map((option, i) => { - const tone = markColor ?? colors[i]; - const own = marks.filter((mark) => mark.index === i); - - return ( - /* An option nobody's badge is on still has to be readable: knowing - what a candidate turned down is half of knowing what they picked. - The distinction is carried by the type colour alone — charcoal - against the chosen option's near-black — rather than by a colour - AND a blanket opacity on top of it, which compounded into text - around a third of the contrast of the line above it. */ -
  • - {/* The chart's own wedge for this option, muted where unchosen — - which is where the fade now lives, on the swatch rather than on - the words. */} - - - - - - {option} - {details?.[i] && ( - : {details[i]} - )} - {own.map((mark) => ( - - {mark.label} - - ))} - {names?.[i]?.length ? ( - - {names[i].join(", ")} - - ) : null} - - - {showCounts && ( - - {/* Charcoal rather than the faintest token: an unchosen - option's share is the comparison, not a footnote to it. */} - - {valueFormat(counts[i])} - - - )} -
  • - ); - })} -
- ); -} diff --git a/src/components/elections/CandidateNameLink.tsx b/src/components/elections/CandidateNameLink.tsx new file mode 100644 index 0000000..8dc5ce5 --- /dev/null +++ b/src/components/elections/CandidateNameLink.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { ArrowRight, ArrowUpRight } from "lucide-react"; + +import { CandidateProfileLink } from "./CandidateProfileLink"; +import { CandidateSiteLink } from "./CandidateSiteLink"; +import { candidateProfilePath } from "@/lib/elections/candidate-profile"; + +/* A candidate's name, as a link to the candidate. + * + * WHERE THE NAME USED TO GO + * Out. A name with a campaign site was a link to that site, everywhere a + * name appeared — the mayoral cards, the ward rosters, the questionnaire + * column heads. So the one element that identified a person was also the + * exit, and the reader who clicked it landed on a campaign's own account of + * the candidate with the ward, the questionnaire answers and the ballot they + * are on all left behind. + * + * Now the name goes to our page for that candidate, and the campaign site is + * a link on it. Nothing is lost from the funnel: the outbound click is still + * `candidate_website_clicked`, fired from the profile page by the same + * CandidateSiteLink, with `candidate_profile_clicked` in front of it. + * + * WHERE THERE IS NO PAGE + * Regions we cover but have not built candidate pages for (everywhere but + * Toronto — see `candidateProfiles` in the registry) keep the outbound name + * link exactly as it was. A name pointing at a 404 is worse than a name + * pointing outward, so this degrades rather than assumes. + * + * THE ARROW + * It is the whole tell that a name is a link, so it appears only where there + * is somewhere to go — and it points the way it goes: right for a page of + * ours, up-and-out for a campaign site. + */ + +export type LinkableCandidate = { + /** `nameKey(name)` — the analytics candidate key */ + key: string; + name: string; + website?: string; + tag?: string; +}; + +export function CandidateNameLink({ + candidate, + election, + race, + ward, + wardName, + className = "", + linkClassName = "", + arrow = true, +}: { + candidate: LinkableCandidate; + /** York Factory election slug — decides whether a profile page exists */ + election: string; + race: "mayor" | "councillor" | "trustee"; + ward?: string; + wardName?: string; + /** typography for the name, applied whether or not it links */ + className?: string; + /** extra classes for the link state only (hover colour, group name) */ + linkClassName?: string; + /** drop the trailing arrow where the surrounding card is already a link + * target, or where the row has no width for it */ + arrow?: boolean; +}) { + const profileHref = candidateProfilePath(election, candidate.name); + + if (profileHref) { + return ( + + {candidate.name} + {arrow && ( + + ); + } + + if (candidate.website) { + return ( + + {candidate.name} + {arrow && ( + + ); + } + + return {candidate.name}; +} diff --git a/src/components/elections/CandidateProfileLink.tsx b/src/components/elections/CandidateProfileLink.tsx new file mode 100644 index 0000000..fede25d --- /dev/null +++ b/src/components/elections/CandidateProfileLink.tsx @@ -0,0 +1,62 @@ +"use client"; + +import Link from "next/link"; +import posthog from "posthog-js"; +import type { ReactNode } from "react"; + +/* An internal link to a candidate's own page, instrumented the way + CandidateSiteLink instruments the outbound one. + + The two events are deliberately separate and both worth having. + `candidate_profile_clicked` is interest in a candidate, measured on our own + pages; `candidate_website_clicked` still fires from the campaign-site link + on the profile page, so the outbound funnel is intact — it just has a step + in front of it now. Same property names in both, so a breakdown by + `candidate_key` reads across the pair. */ + +interface CandidateProfileLinkProps { + href: string; + candidate: string; + candidateKey: string; + race: "mayor" | "councillor" | "trustee"; + /** York Factory election slug, e.g. "toronto-2026" */ + election: string; + tag?: string; + ward?: string; + wardName?: string; + className?: string; + children: ReactNode; +} + +export function CandidateProfileLink({ + href, + candidate, + candidateKey, + race, + election, + tag, + ward, + wardName, + className, + children, +}: CandidateProfileLinkProps) { + return ( + { + posthog.capture("candidate_profile_clicked", { + candidate, + candidate_key: candidateKey, + race, + election, + tag, + ward, + ward_name: wardName, + }); + }} + > + {children} + + ); +} diff --git a/src/components/elections/CandidateSurveyAnswers.tsx b/src/components/elections/CandidateSurveyAnswers.tsx deleted file mode 100644 index 0e3e0bb..0000000 --- a/src/components/elections/CandidateSurveyAnswers.tsx +++ /dev/null @@ -1,295 +0,0 @@ -"use client"; - -import { ChevronDown } from "lucide-react"; - -import { WedgeGlyph, percentOf } from "@/components/charts/trilemma"; -import { AnswerChart, optionColors, sharedRadius } from "./AnswerChart"; -import { AnswerOptionList } from "./AnswerOptionList"; -import { firstName, lastName, possessive } from "@/lib/elections/names"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/components/ui/collapsible"; -import type { - CandidateAnswer, - CandidateAnswers, -} from "@/lib/elections/candidate-answers"; - -/* What one candidate told the questionnaire, on their roster card. - * - * FORM - * Most of the questionnaire is a three-way choice between competing - * alternatives, and those get a dial: a circle in equal thirds, one wedge - * per option, reaching out as far as the number of candidates who picked it. - * The wedge this candidate chose carries its colour and the rest go neutral, - * so the answer reads as a silhouette — where they landed, and whether the - * field landed with them. - * - * The five direct questions are not that shape. "Yes / Yes, with conditions - * / No" is ordered, and a dial would put those three at 120° from each other - * as though they were rival options rather than points on a scale. They get - * a segmented bar, which keeps the order and shows the split. So do the two - * four-option questions, which cannot be a trilemma at all. - * - * Every chart on the card shares one value scale (`fieldSize`), so a wedge - * that reaches the rim always means the same thing. Per-question scales - * would make every answer look unanimous. - * - * The answer itself is written out under the question, before the chart: - * what they picked, in the words they were offered, with that option's wedge - * glyph beside it. The chart is what the field did — reading a single - * candidate's answer off it means hunting the coloured third and then its - * rim label, which is a step too many between a question and its answer. - * - * The chart stays the biggest thing in the row all the same, and it carries - * its own key: the question sits above it as the row's heading, and - * each wedge is direct-labelled with the option's name and the number of - * candidates who picked it. A shape with the meaning parked in a column - * beside it is a decoration; a shape that names its own thirds is the answer. - * - * Beside each chart the options are listed as they were offered, in full - * wording with their expansions, each with a glyph of its own wedge so the - * list reads straight onto the dial. Counts are left to the chart, which - * prints them at the rim — the list carries what the chart cannot fit. - * - * Expanded by default. The answers are the reason to look a candidate up; - * hiding them behind a click made the card a promise rather than an answer. - * Still collapsible, because a ward with a dozen candidates is a long page. - * - * COLOUR - * The chart palette's own corner hues, which is what makes the option - * position legible: the first option is the same colour on all 32 questions, - * so "they picked the first one again" is visible without reading. Safe here - * in a way it is not in the survey's alignment view, which spends pine and - * copper on agree/differ and so has to stay two-tone. - */ - -export function CandidateSurveyAnswers({ - answers, - candidateName, -}: { - answers: CandidateAnswers; - candidateName: string; -}) { - const radius = sharedRadius(answers.groups.flatMap((group) => group.answers)); - - return ( - - - - - -
- {answers.groups.map((group) => ( -
- {/* The questionnaire's own sections, and the only landmarks in a - card that runs to thirty-odd answers. They take the house - section rule — a heavy line and a real heading — rather than - the faint eyebrow they had, which read as a caption on the - answer above it rather than as the start of something. */} -

- {group.stepTitle} -

- - {/* Two answers to a row where there is width for it: stacked - chart-over-options, each answer is a tall narrow block, and - one per row left a column of white space beside every dial. */} -
- {group.answers.map((answer) => ( - - ))} -
-
- ))} -
-
-
- ); -} - -function Answer({ - answer, - candidateName, - fieldSize, - radius, -}: { - answer: CandidateAnswer; - candidateName: string; - fieldSize: number; - /** the card's shared outer radius, from `sharedRadius` */ - radius?: number; -}) { - const { full, colors } = optionColors(answer.options.length, answer.choice); - // Candidates whose answer landed in a bucket — not the whole field, since a - // transcribed answer is in none of them. - const counted = answer.counts.reduce((a, b) => a + b, 0); - // Shares of the field, not head counts: the reader has no idea whether nine - // is most of the ward or a corner of it, and every chart on the card is - // scaled against the same field, so the same denominator is already implied - // by the geometry. - const share = percentOf(fieldSize); - - return ( - /* Boxed, and a full-height column rather than a content-height block. - Two answers share a row, and a hairline above each was enough to - separate a single column but not a grid: with a neighbour alongside, - a rule at the top of both reads as one line under the pair, and where - one answer runs longer than the other there was nothing to say which - question the leftover text belonged to. A box closes each one. - - Stretched to the row, with the chart pushed to the bottom, every answer - frames the same way — which is what makes any one of them croppable on - its own. */ -
- {/* The question, above the chart rather than beside it: the chart is - wider now, and a heading in its own column would have made the row a - pair of narrow strips. */} -
-
- {answer.question} -
- - {/* What they said, in words, directly under the question and ahead of - the chart. The chart shows where the field went and which third is - theirs, but reading it means finding the coloured wedge and then - its rim label — a step between the question and its answer. This - says it outright; the chart then answers "and who else?". - - The glyph is the chart's own wedge for that option, so the eye can - carry the colour from this line down onto the dial. */} -

- {answer.choice !== null && ( - - - - )} - - {answer.verbatim && <>“} - {answer.answer} - {answer.verbatim && ( - <> - ” - - {" "} - — {possessive(firstName(candidateName))} wording, not - one of the options - - - )} - -

-
- - {/* Options then chart, stacked rather than side by side — at half a - card's width the two columns were a pair of strips too narrow for - either. The chart goes last because it is the slowest thing to read: - the question, the answer in words, and the alternatives it was chosen - from are the whole story for most readers, and the field's shape is - what you stay for. Ending the row on it also puts every dial on a - consistent line above the next question's heading. */} -
-
- {/* The options as they were offered, so the answer is read against - the alternatives rather than on its own. Shares stay on the - chart below, which direct-labels every one of them. */} - - - {answer.explanation && ( -
-
- {possessive(firstName(candidateName))} note -
-

- {answer.explanation} -

-
- )} -
- - `${option} ${share(answer.counts[i])}`) - .join(", ")}; ${candidateName} chose ${answer.answer}`} - /> - - {/* Whose chart this is, said under it. On a card of thirty-odd - boxed answers — and in a screenshot of any one of them, cropped - away from the card's header — the dial otherwise arrives with no - owner: three coloured thirds and no statement of what the coloured - one belongs to. */} - {counted > 0 && ( -

- {possessive(lastName(candidateName))} responses vs all other - candidates -

- )} - -
-
- ); -} - -/** - * What every chart on the page is counting, said once at the foot of it. - * - * This used to sit under each chart, where it was true but relentless: a ward - * page carries a dozen candidates at thirty-odd answers each, so the same four - * lines were set several hundred times, and a note repeated that often stops - * being read at all. It is a property of the whole questionnaire — the same - * field, the same denominator, on every chart — so it belongs where a source - * note belongs, at the bottom, once. - */ -export function SurveyChartNote({ candidateCount }: { candidateCount: number }) { - return ( -

- Reading the charts. Each - third of a dial is one of the options, reaching further the more of this - ward’s candidates picked it; on the segmented bars each band is one - option, as wide as the share that picked it. Counts are out of the{" "} - {candidateCount} candidates in this ward who returned the - questionnaire, not the whole ballot. A candidate who answered in their - own words rather than picking an option is counted on no option, and is - named under the question instead. -

- ); -} diff --git a/src/components/elections/ElectionLanding.tsx b/src/components/elections/ElectionLanding.tsx index 14c02f9..46f9956 100644 --- a/src/components/elections/ElectionLanding.tsx +++ b/src/components/elections/ElectionLanding.tsx @@ -1,10 +1,10 @@ import Image from "next/image"; import Link from "next/link"; import { Suspense, type ReactNode } from "react"; -import { ArrowRight, ArrowUpRight } from "lucide-react"; +import { ArrowRight } from "lucide-react"; import CountdownDays from "./CountdownDays"; import LiveCountdown from "./LiveCountdown"; -import { CandidateSiteLink } from "./CandidateSiteLink"; +import { CandidateNameLink } from "./CandidateNameLink"; import { PledgeButton } from "./PledgeButton"; import { SurveyCta } from "./SurveyCta"; import { ResidencyModal } from "./ResidencyModal"; @@ -830,14 +830,18 @@ function MayoralCard({ candidate.initials )} -
-
-

- {candidate.name} -

- {candidate.tag === "Incumbent" && } -
- + {/* The name is the link. It used to be plain text with "Campaign site" + on the line beneath it, which spent a second line saying that the + thing above it led somewhere — and led off the site. */} +
+

+ +

+ {candidate.tag === "Incumbent" && }
); @@ -920,7 +924,11 @@ export function CandidateRow({ className={`font-sans font-medium text-[1.25rem] tracking-[-0.02em] leading-[1.15] ${candidate.withdrawn ? "line-through decoration-1" : "" }`} > - {candidate.name} +

{candidate.withdrawn && ( @@ -944,9 +952,6 @@ export function CandidateRow({ )} -
- -
); } @@ -959,43 +964,6 @@ export function IncumbentBadge() { ); } -/** The campaign-site link, or the placeholder shown when we have no URL. */ -export function SiteLink({ - candidate, - election, - race, - ward, - wardName, -}: { - candidate: CandidateView; - election: string; - race: "mayor" | "councillor" | "trustee"; - ward?: string; - wardName?: string; -}) { - /* No site, no line. "Profile to come" was a promise we do not control — a - candidate with no web presence may never acquire one — and printed under - every third name it read as a column of missing things rather than as the - ordinary state of a municipal candidate. The absence says it already. */ - if (!candidate.website) return null; - return ( - - Campaign site - - - ); -} - /** `social_links[].name` is an open vocabulary ("web", "facebook", "tiktok", * …), so unknown names are title-cased rather than dropped. */ function socialLabel(name: string): string { diff --git a/src/components/elections/QuestionnaireCards.tsx b/src/components/elections/QuestionnaireCards.tsx index fdcf0ff..880d563 100644 --- a/src/components/elections/QuestionnaireCards.tsx +++ b/src/components/elections/QuestionnaireCards.tsx @@ -1,4 +1,5 @@ import Link from "next/link"; +import type { ReactNode } from "react"; import { ArrowRight } from "lucide-react"; import type { Heading } from "@/components/custom/signpost/config"; @@ -77,6 +78,7 @@ export function QuestionnaireCards({ notes = true, yourKey, idPrefix, + answerNote, }: { groups: ComparedGroup[]; /** the candidates who returned the questionnaire */ @@ -100,6 +102,11 @@ export function QuestionnaireCards({ * element called "housing" in the document and the scroll rail would only * ever find the first. */ idPrefix?: string; + /** replaces the sentence at the foot explaining how to read the cards. The + * default is written for a race — "options nobody in this ward picked are + * not shown" — and a page whose cards hold a single candidate has a + * different thing to say about what is missing from them. */ + answerNote?: ReactNode; }) { const silentNames = silent.map((candidate) => ({ key: candidate.key, @@ -140,7 +147,11 @@ export function QuestionnaireCards({ ))} - + ); } @@ -151,9 +162,12 @@ export function QuestionnaireCards({ function WardAnswerNote({ issuesHref, notes, + note, }: { issuesHref?: string; notes?: boolean; + /** an override for the sentence — see `answerNote` */ + note?: ReactNode; /** the reader's own row, where they have answered the same questionnaire — * see QuestionRollCall. */ yourKey?: string; @@ -167,10 +181,14 @@ function WardAnswerNote({ return (

- Candidates are grouped by the answer they gave. Options nobody in this - ward picked are not shown, and a candidate who answered in their own - words sits on no option. - {notes ? " Notes are the candidates’ own words." : ""} + {note ?? ( + <> + Candidates are grouped by the answer they gave. Options nobody in + this ward picked are not shown, and a candidate who answered in + their own words sits on no option. + {notes ? " Notes are the candidates’ own words." : ""} + + )}

{issuesHref && ( {candidate.name}; - return ( - - {candidate.name} - - + className="font-sans text-[1rem] font-medium leading-[1.15] tracking-[-0.015em] text-dark text-balance" + /> ); } diff --git a/src/components/elections/WardDetail.tsx b/src/components/elections/WardDetail.tsx index cb61108..e832f54 100644 --- a/src/components/elections/WardDetail.tsx +++ b/src/components/elections/WardDetail.tsx @@ -10,7 +10,8 @@ import { } from "./QuestionnaireCards"; import { QuestionnaireRail } from "./QuestionnaireRail"; import { SurveyCta } from "./SurveyCta"; -import { IncumbentBadge, SiteLink } from "./ElectionLanding"; +import { IncumbentBadge } from "./ElectionLanding"; +import { CandidateNameLink } from "./CandidateNameLink"; import { comparedQuestions, surveyRoster, @@ -442,7 +443,13 @@ function CouncilCandidate({ candidate.withdrawn ? "line-through decoration-1" : "" }`} > - {candidate.name} + {candidate.tag === "Incumbent" && } {candidate.withdrawn && ( @@ -457,15 +464,6 @@ function CouncilCandidate({

)}
-
- -
); diff --git a/src/lib/elections/alignment.ts b/src/lib/elections/alignment.ts index ccfc3e2..2514dc8 100644 --- a/src/lib/elections/alignment.ts +++ b/src/lib/elections/alignment.ts @@ -33,6 +33,14 @@ const NON_POLICY_STEPS = new Set(["about-you", "stay-in-touch"]); */ const NON_POLICY_QUESTIONS = new Set(["volunteer", "updates"]); +/** + * The steps that are consent and contact rather than substance. A subset of + * NON_POLICY_STEPS: `about-you` is not policy either, but a candidate's bio in + * it is publishable prose, where anything in `stay-in-touch` is a private + * arrangement between the respondent and us. See `writtenQuestions`. + */ +const CONSENT_STEPS = new Set(["stay-in-touch"]); + /** Question types with a fixed option list, so two answers can be compared. */ const CHOICE_TYPES = new Set(["yesno", "radio", "select"]); @@ -173,6 +181,50 @@ export function comparableQuestions( ); } +/** + * The prose a respondent wrote, in the order they were asked to write it. + * + * The complement of `comparableQuestions`, and the reason both exist: a + * questionnaire is a set of choices plus the things the respondent said in + * their own words, and the choices are the only half anything could pivot, + * chart or count. So the written half was filtered out at the first step and + * then never picked up again — which meant the candidate questionnaire's `bio`, + * answered by 55 of the 58 candidates who returned it, was data we held and + * published nowhere. + * + * TEXTAREA ONLY, AND THAT IS A PRIVACY RULE + * `textarea` is the type the CMS uses for a paragraph the respondent means to + * be read. The short free-text types are where identity lives — `text` and + * `email` hold a name, an email, a postal code — and the resident survey is + * full of them. Selecting the long-form type rather than "everything that is + * not a choice" means a contact field added in the CMS can never arrive here + * as publishable prose. + * + * The consent questions are excluded on top of that, by step and by id, the + * same way `comparableQuestions` excludes them. `about-you` is NOT excluded + * here, unlike there: a candidate's bio is the one thing in that step which + * is a public statement rather than a fact about a private person. + */ +export function writtenQuestions( + survey: Survey, +): { question: SurveyQuestion; stepId: string; stepTitle: string }[] { + return survey.steps.flatMap((step) => + CONSENT_STEPS.has(step.id) + ? [] + : step.questions + .filter( + (question) => + question.type === "textarea" && + !NON_POLICY_QUESTIONS.has(question.id), + ) + .map((question) => ({ + question, + stepId: step.id, + stepTitle: step.title, + })), + ); +} + /** * Compares one set of resident answers against several candidate responses. * diff --git a/src/lib/elections/candidate-answers.ts b/src/lib/elections/candidate-answers.ts index af099af..ecd2bf5 100644 --- a/src/lib/elections/candidate-answers.ts +++ b/src/lib/elections/candidate-answers.ts @@ -15,7 +15,7 @@ // carry it: most wards have one or two respondents, where a per-ward count // says only that the candidate agrees with themselves. -import { comparableQuestions, isYesNoScale } from "./alignment"; +import { comparableQuestions, isYesNoScale, writtenQuestions } from "./alignment"; import type { CandidateSurveyResponse } from "./alignment"; import { nameKey } from "./election-data"; import { lastName } from "./names"; @@ -463,3 +463,63 @@ export function rollCall( return { groups, verbatim, unanswered }; } + +/* ------------------------------------------------------------------ */ +/* What they wrote */ +/* ------------------------------------------------------------------ */ + +/** The question a candidate's bio is asked under. Its own constant because + * the profile page treats it differently from every other written answer — + * a biography belongs beside the portrait, not among the policy answers. */ +export const BIO_QUESTION_ID = "bio"; + +/** One free-text answer, as the candidate wrote it. */ +export type WrittenAnswer = { + questionId: string; + /** the question as it was put to them, for the answers that need it */ + question: string; + stepId: string; + stepTitle: string; + /** their words, trimmed; never empty — blanks are dropped */ + text: string; +}; + +/** + * The prose each candidate wrote, keyed by `nameKey` and in the order the + * questionnaire asked for it. + * + * Separate from `candidateAnswers` because the two halves of a questionnaire + * are different kinds of thing: a choice can be counted against the field and + * a paragraph cannot, so nothing here carries counts, charts or a comparison. + * It is what the candidate said, attributed, and that is all it can be. + * + * Candidates who wrote nothing are absent rather than present and empty. + */ +export function candidateWriting( + survey: Survey, + responses: CandidateSurveyResponse[], +): Record { + const questions = writtenQuestions(survey); + const out: Record = {}; + + for (const response of responses) { + const written = questions.flatMap(({ question, stepId, stepTitle }) => { + const text = (response.answers[question.id] ?? "").trim(); + return text + ? [ + { + questionId: question.id, + question: question.label, + stepId, + stepTitle, + text, + }, + ] + : []; + }); + + if (written.length > 0) out[nameKey(response.candidateName)] = written; + } + + return out; +} diff --git a/src/lib/elections/candidate-profile.ts b/src/lib/elections/candidate-profile.ts new file mode 100644 index 0000000..8ea693e --- /dev/null +++ b/src/lib/elections/candidate-profile.ts @@ -0,0 +1,38 @@ +// Where a candidate's own page lives, for the components that link to it. +// +// A candidate's name used to be a link to their campaign site, everywhere a +// name appeared. That made the name a way off the site: the reader clicked the +// one thing on the page that identified a person and landed on a campaign's +// own framing of them, with the questionnaire answers, the ward, and whether +// they had even registered all left behind. Now the name goes to a page of +// ours, and the campaign site is a link on it — one of the things we know +// about the candidate rather than the only thing we point at. +// +// Kept apart from ./registry so that file stays plain configuration, and free +// of ./election-data so client components can import it: the path is derived +// from the name alone, which is all a rendered roster has. + +import { candidateSlug } from "./names"; +import { SUPPORTED_ELECTIONS } from "./registry"; + +export { candidateSlug }; + +/** + * The internal page for one candidate, or null where this region has none — + * only Toronto has the route (see `candidateProfiles`), and every other region + * keeps the outbound name link it has always had rather than pointing at a + * 404. + * + * Looked up by exact slug rather than `getElection`, so an unknown election + * gets no path instead of quietly inheriting Toronto's. + */ +export function candidateProfilePath( + electionSlug: string, + candidateName: string, +): string | null { + const election = SUPPORTED_ELECTIONS[electionSlug]; + if (!election?.candidateProfiles) return null; + const slug = candidateSlug(candidateName); + if (!slug) return null; + return `${election.basePath}/candidates/${slug}`; +} diff --git a/src/lib/elections/election-data.ts b/src/lib/elections/election-data.ts index 4e98f40..e7edee5 100644 --- a/src/lib/elections/election-data.ts +++ b/src/lib/elections/election-data.ts @@ -21,6 +21,7 @@ import { fetchElection, type ApiCandidate, type ApiRace } from "@/lib/api/elections"; import { daysUntil, parseDateOnly } from "./dates"; +import { nameKey } from "./names"; import { SUPPORTED_ELECTIONS } from "./registry"; export { daysUntil, parseDateOnly }; @@ -57,19 +58,10 @@ export function initialsFor(name: string): string { return (first + last).toUpperCase(); } -/** Matching key for enrichment lookups: lowercase, diacritics and punctuation - * stripped, so the Clerk's "Ala'a Adib" matches a local entry written "Alaa - * Adib". Also the stable candidate key in analytics events, since the clerks' - * feeds carry no candidate IDs. */ -export function nameKey(name: string): string { - return name - .normalize("NFD") - .replace(new RegExp("[\\u0300-\\u036f]", "g"), "") - .toLowerCase() - .replace(/[^a-z0-9 ]+/g, "") - .replace(/\s+/g, " ") - .trim(); -} +/** Matching key for enrichment lookups — re-exported from ./names, which the + * client components that link to a candidate's own page also need and which + * cannot import this module (it reaches the API). */ +export { nameKey }; /** * "First Last" display name. `first_name` is null for mononymous candidates, @@ -156,6 +148,14 @@ export type RaceView = { officeBody: string | null; /** e.g. "Etobicoke North" or "Wards 1, 5"; null when at-large */ districtName: string | null; + /** + * The API's `district_number`, null when at-large. Safe to print only where + * `districtName` is null and the district is not a city ward — which in + * practice is the school boards, whose own ward numbering is the only name + * their districts have. For a council race it is the lowest ward of the + * district and `wardNumbers` is what to show instead. + */ + districtNumber: number | null; /** city wards this race covers; empty when at-large or unmapped */ wardNumbers: number[]; atLarge: boolean; @@ -290,6 +290,7 @@ function toRaceView(race: ApiRace, enrichment?: EnrichmentMap): RaceView { label: race.district_name ? `${seat} — ${race.district_name}` : seat, officeBody: race.office_body, districtName: race.district_name, + districtNumber: race.district_number, wardNumbers: wardsFor(race), atLarge: race.district_type === "at_large", candidates, @@ -442,3 +443,126 @@ export async function getWardDetail( .map((race) => toRaceView(race)), }; } + +// ── Candidate profiles ───────────────────────────────────────────────────── + +/** One race a candidate appears in, and whether they are still standing in it. */ +type Appearance = { + candidate: CandidateView; + race: RaceView; + officeType: ApiRace["office_type"]; + wards: WardView[]; +}; + +/** + * One candidate, with the race context their own page needs. + * + * A name is not a key: candidates are joined on `nameKey`, the clerks' feeds + * carry no candidate ids, and one person turns up in more than one race often + * enough that it has to be handled — on Toronto's 2026 ballot ten candidates + * registered somewhere, withdrew, and registered somewhere else. So a profile + * carries the races the candidate is actually standing in, and names the ones + * they left separately rather than letting a stale ward speak for them. + */ +export type CandidateProfile = { + /** their details, taken from a race they are still standing in where there + * is one — so a candidate who moved wards does not read as withdrawn */ + candidate: CandidateView; + /** the races they are standing in; the withdrawn ones only when every race + * they appear in is withdrawn, so this is never empty */ + races: RaceView[]; + /** the office kinds those races are for, aligned with `races` */ + officeTypes: ApiRace["office_type"][]; + /** the region's wards those races cover, resolved against its roster */ + wards: WardView[]; + /** races they registered in and withdrew from, where they are standing + * somewhere else — empty for all but a handful of candidates */ + withdrawnFrom: RaceView[]; +}; + +/** + * Every candidate in the election, each with their race — the roster a + * per-candidate route generates its static params from and looks a slug up + * in. Null when the API is unreachable, so callers can fall back or 404. + * + * Built in one pass over the races rather than by walking `getWardDetail` for + * each ward: enrichment is per-race anyway, and 25 ward fetches to answer + * "who is this person" is 25 recomputations of the same election. + */ +export async function getCandidateProfiles( + slug: string, + options: ElectionDataOptions = {}, +): Promise { + const election = await fetchElection(slug); + if (!election) return null; + + const view = await getElectionView(slug, options); + if (!view) return null; + + const wardByNumber = new Map(view.wards.map((ward) => [ward.number, ward])); + + /* Keyed by `nameKey`, which is `CandidateView.key`, so a name in two races + accumulates rather than overwriting. */ + const appearances = new Map(); + + for (const apiRace of election.races) { + const wardNumbers = wardsFor(apiRace); + + /* Toronto's hand-maintained extras are held per ward, and its council + races are one ward each. A race spanning several wards (Brampton's) has + no single ward's enrichment to draw on, and no region that shape has + any. */ + const enrichment = + apiRace.office_type === "mayor" + ? options.mayoralEnrichment + : apiRace.office_type === "councillor" && wardNumbers.length === 1 + ? options.councillorEnrichment?.(wardNumbers[0]) + : undefined; + + const race = toRaceView(apiRace, enrichment); + const wards = wardNumbers + .map((number) => wardByNumber.get(number)) + .filter((ward): ward is WardView => ward !== undefined); + + for (const candidate of race.candidates) { + const list = appearances.get(candidate.key); + const appearance: Appearance = { + candidate, + race, + officeType: apiRace.office_type, + wards, + }; + if (list) list.push(appearance); + else appearances.set(candidate.key, [appearance]); + } + } + + return [...appearances.values()].map(toProfile); +} + +/** One candidate's appearances folded into their profile: the races they are + * standing in lead, and the ones they left are set aside. */ +function toProfile(appearances: Appearance[]): CandidateProfile { + const standing = appearances.filter((a) => !a.candidate.withdrawn); + /* Withdrawn everywhere — the withdrawal is the whole story, so those races + are the profile's races rather than a footnote to an empty page. */ + const active = standing.length > 0 ? standing : appearances; + + const wards: WardView[] = []; + for (const appearance of active) { + for (const ward of appearance.wards) { + if (!wards.some((w) => w.number === ward.number)) wards.push(ward); + } + } + + return { + candidate: active[0].candidate, + races: active.map((a) => a.race), + officeTypes: active.map((a) => a.officeType), + wards, + withdrawnFrom: + standing.length > 0 + ? appearances.filter((a) => a.candidate.withdrawn).map((a) => a.race) + : [], + }; +} diff --git a/src/lib/elections/names.ts b/src/lib/elections/names.ts index d70ecbd..f5fef49 100644 --- a/src/lib/elections/names.ts +++ b/src/lib/elections/names.ts @@ -17,3 +17,28 @@ export const lastName = (name: string) => name.trim().split(/\s+/).at(-1) || nam * ending in s takes the bare apostrophe. */ export const possessive = (name: string) => name.endsWith("s") || name.endsWith("S") ? `${name}’` : `${name}’s`; + +/** + * Matching key for a candidate: lowercase, diacritics and punctuation + * stripped, so the Clerk's "Ala'a Adib" matches a hand-written "Alaa Adib". + * Also the stable candidate key in analytics events and the roster joins, + * since the clerks' feeds carry no candidate IDs. + * + * Lives here rather than in election-data because the client components that + * link to a candidate's page need it, and election-data reaches the API. + */ +export function nameKey(name: string): string { + return name + .normalize("NFD") + .replace(new RegExp("[\\u0300-\\u036f]", "g"), "") + .toLowerCase() + .replace(/[^a-z0-9 ]+/g, "") + .replace(/\s+/g, " ") + .trim(); +} + +/** `nameKey` as a URL segment — "Brad Bradford" → "brad-bradford". The whole + * identity of a candidate's own page, so it must stay derivable from the name + * alone: the roster is rebuilt from the Clerk's feed daily and carries no ids + * we could key a URL to. */ +export const candidateSlug = (name: string) => nameKey(name).replace(/ /g, "-"); diff --git a/src/lib/elections/registry.ts b/src/lib/elections/registry.ts index 6473fe8..3e57827 100644 --- a/src/lib/elections/registry.ts +++ b/src/lib/elections/registry.ts @@ -74,6 +74,15 @@ export type SupportedElection = { * section can offer the lookup. False is not "no map": it is "asking for a * postal code here would answer with boundary_data_unavailable". */ wardLookup?: boolean; + /** + * The region has a page per candidate under `${basePath}/candidates/:slug`. + * Where it does, a candidate's name is a link to that page and the campaign + * site is one of the things on it; where it doesn't, the name links straight + * out to the campaign site as it always did. Only Toronto has the route, and + * a name linking to a 404 is worse than a name linking outward, so this is + * opt-in rather than assumed from `basePath`. + */ + candidateProfiles?: boolean; }; const TORONTO_2026: SupportedElection = { @@ -94,6 +103,7 @@ const TORONTO_2026: SupportedElection = { advanceVote: { iso: "2026-10-06", label: "Oct 6 – 11" }, mailIn: { iso: "2026-09-24", label: "Thu, Sept 24" }, wardLookup: true, + candidateProfiles: true, themeClass: "theme-election", }; diff --git a/src/lib/elections/survey-answers.ts b/src/lib/elections/survey-answers.ts index 5e6e2c9..4b1057b 100644 --- a/src/lib/elections/survey-answers.ts +++ b/src/lib/elections/survey-answers.ts @@ -15,9 +15,11 @@ import { byCandidateKey, candidateAnswers, + candidateWriting, questionnaireShape, type CandidateAnswers, type ComparedGroup, + type WrittenAnswer, } from "./candidate-answers"; import { CANDIDATE_QUESTIONNAIRE_SLUG, @@ -31,6 +33,9 @@ export type RosterSurvey = { /** every question, with nobody attached — the grid's shape when the whole * roster stayed quiet */ shape: ComparedGroup[]; + /** the roster's free-text answers, keyed by `nameKey`. The choices are what + * a grid can compare; this is what the candidates wrote. */ + written: Record; }; /** @@ -58,11 +63,18 @@ export async function rosterSurvey( const entries = candidateAnswers(survey, responses).filter((entry) => candidateKeys.has(entry.key), ); + const written = candidateWriting(survey, responses); + return { answers: byCandidateKey(entries), shape: questionnaireShape(survey, responses), + /* Narrowed to the roster on the same rule the answers are: prose we + cannot place on a candidate is prose we must not attribute. */ + written: Object.fromEntries( + Object.entries(written).filter(([key]) => candidateKeys.has(key)), + ), }; } catch { - return { answers: {}, shape: [] }; + return { answers: {}, shape: [], written: {} }; } }