Skip to content

Give every Toronto candidate a page of their own - #81

Merged
mikaalnaik merged 4 commits into
mainfrom
mikaal/candidate-pages
Sep 9, 2026
Merged

Give every Toronto candidate a page of their own#81
mikaalnaik merged 4 commits into
mainfrom
mikaal/candidate-pages

Conversation

@mikaalnaik

@mikaalnaik mikaalnaik commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

A candidate's name was a link to their campaign site, everywhere a name appeared — the mayoral cards, every ward roster, every questionnaire column head. So the one element that identified a person was also the exit, and a reader who clicked it landed on a campaign's own account of the candidate with the ward, the questionnaire answers and the ballot they're on all left behind.

Names now lead to a page of ours, and the campaign site is a link on it.

What's here

378 candidate pages at /toronto/vote/2026/candidates/:slug, slugged from the 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. A slug naming nobody 404s. Each page carries the portrait, incumbent/withdrawn badges, the race and ward, the campaign site, social links, the bio the candidate wrote us, and their questionnaire answers. Plus an OG image, rendered on demand rather than 378 satori renders in the build.

One shared CandidateNameLink replaced every outbound name link: the questionnaire column heads and rosters, the mayoral cards and city-wide race rows, the trustee cards, the mayoral roster page. The separate "Campaign site" line under each name is gone — the name is the link. SiteLink became dead and was removed.

Other regions degrade rather than break. The link falls back to the old outbound behaviour where a region has no candidate route, gated on a new candidateProfiles registry flag (Toronto only), so a name never points at a 404.

The questionnaire reads through the ward pages' own cards — the same QuestionnaireRail and QuestionnaireCards, given a roster of one — so the two can never disagree about how a questionnaire reads. Each question is a card, the candidate filed under the answer they gave, their own words printed in the open underneath. That retired CandidateSurveyAnswers (this page was its only consumer) and with it AnswerOptionList, which nothing imported any more.

Analytics keep the funnel whole. candidate_website_clicked still fires from the profile page's outbound link, with a new candidate_profile_clicked in front of it — same property names, so a breakdown by candidate_key reads across the pair.

The bios we were already holding

55 of the 58 candidates who returned the questionnaire wrote a bio in it, and the site showed none of them.

The bio lives in the questionnaire's about-you step, which comparableQuestions excludes as non-policy — correctly, for a pivot that exists to compare choices against a field. But nothing ever picked the written half back up, so the only bio a candidate page could print was the hand-maintained one in candidates.ts, which is empty for all but a handful.

writtenQuestions is the complement of comparableQuestions, and candidateWriting pivots it per candidate the way candidateAnswers pivots the choices — with no counts, charts or comparison, because a paragraph cannot be counted against a field. rosterSurvey returns it alongside the answers, narrowed to the roster on the same rule: prose we cannot place on a candidate is prose we must not attribute.

The filter is textarea only, and that is the privacy guard — worth a look in review. Not "everything that is not a choice". Every free-text field in the resident survey is identity: postal code, first name, last name, email, all text or email. Selecting the long-form type is what stops a contact field added in the CMS from ever arriving here as publishable prose. It is also the guard doing the work rather than the decorative one: postal_code sits in about-you, which the consent-step exclusion deliberately lets through so a candidate's bio can pass.

On the page it gets a section of its own between the ballot facts and the questionnaire — who is this, then what do they think. Not the hero: these run to a median of 800 characters, half are several paragraphs, and the longest is 1,800, which in the hero pushed the ward and the answers off the screen. It's attributed before it's read ("Written by X in answer to our questionnaire, and published as given. Not our description of them"), because a candidate's account of themselves set in our type would read as ours. Blank lines are kept as paragraph breaks, single newlines as typed.

ward_commitment_target — the numerical target and deadline, answered by 21 — comes through the same mechanism and prints under its question. Trim it if bios were all that was wanted; it's a couple of lines.

Two things the real roster forced

Both found by checking the live feed rather than by reading the types:

  • Ten candidates registered in one race, withdrew, and registered in another. The first cut keyed profiles by name and took the first match, so Dianne Saxe's page read "Withdrawn" while she is in fact standing in Toronto-Danforth. Profiles now take their identity from a race the candidate is still standing in, and name the abandoned race separately ("Previously registered for Councillor — University-Rosedale, and withdrawn").
  • Toronto's trustee races carry their district as a school-board ward number that RaceView dropped, so those pages read "City-wide · Every ward votes" — wrong, since only part of the city elects a given trustee. RaceView now surfaces districtNumber and they read "Trustee — Toronto Catholic District School Board, Ward 9".

Known issue, not fixed

The survey cards overflow on the x axis. Reported and not yet diagnosed. The container chain is identical to the ward pages' (mx-[10px] … overflow-x-clip), so the cause is likely inside the card grid rather than the page wrapper, and it may well affect the ward pages too. Worth fixing before this goes in front of voters.

Calls made along the way

  • The bio prose runs the full width of its column, uncapped. A reading measure left a third of the band empty and filling the width was the call; leading went up with the line length to compensate as far as that can. If the lines read long, lg:columns-2 fills the band at a short measure — it costs the straight top-to-bottom reading order, which is why it isn't the default.
  • The stat cells read caption-first — "Running for" over "Councillor" — since these are answers to a question rather than figures with a unit. The mayoral roster page's figure-first cells ("53" over "On the ballot") are untouched.
  • The ~100 trustee pages are correct but unlinked. The rest of the site covers the mayor and the 25 council wards, so nothing points at them; they're reachable by search and by URL. Happy to scope generation to mayor and council instead.

Verification

Against the live York Factory API: 378 pages build (660 static pages total, up from ~272), bad slugs 404, OG images render, the multi-race and trustee cases read correctly, bios render as paragraphs with a non-English bio and a bare "12000" target both publishing as given, non-respondents get no bio section, and the ward/mayor/issues/survey routes are unaffected. Typecheck and eslint clean.

The build does still fail on /tracker — its endpoints (/api/dashboard/1/at_a_glance, /api/v1/commitments.json) 404 on both localhost:3000 and production york_factory. Pre-existing and unrelated; every election page generates before it.

🤖 Generated with Claude Code

A candidate's name was a link to their campaign site, everywhere a name
appeared. So the one element that identified a person was also the exit,
and a 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.

Names now lead to a page of ours at /toronto/vote/2026/candidates/:slug,
and the campaign site is a link on it — one fact among the race they are
standing in, the answers they gave us, and where the rest of their
ballot line is. 378 pages, slugged from the name, since the roster is
rebuilt from the City Clerk's feed daily and carries no candidate ids.

One shared CandidateNameLink replaced every outbound name link: the
questionnaire column heads and rosters, the mayoral cards and city-wide
race rows, the trustee cards, and the mayoral roster page. It falls back
to the old outbound link for regions with no candidate route, gated on a
new `candidateProfiles` registry flag, so a name never points at a 404.

The questionnaire reads through the ward pages' own cards — the same
QuestionnaireRail and QuestionnaireCards, given a roster of one — so the
two can never disagree about how a questionnaire reads. That retired
CandidateSurveyAnswers, which the profile page was the only consumer of,
and with it AnswerOptionList, which nothing imported any more.

Analytics keep the funnel whole: `candidate_website_clicked` still fires
from the profile page's outbound link, with `candidate_profile_clicked`
in front of it, same property names so a breakdown by `candidate_key`
reads across the pair.

Two things the roster forced, both verified against the live feed:

  · Ten candidates registered in one race, withdrew, and registered in
    another. Profiles take their identity from a race they are still
    standing in — otherwise Dianne Saxe's page read "Withdrawn" while
    she stands in Toronto-Danforth — and name the abandoned race
    separately.

  · Toronto's trustee races carry their district as a school-board ward
    number that RaceView dropped, so those pages read "City-wide · Every
    ward votes". RaceView now surfaces `districtNumber` and they read
    "Trustee — Toronto Catholic District School Board, Ward 9".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Sep 9, 2026

Copy link
Copy Markdown

Greptile Summary

The PR adds first-party profile pages for Toronto election candidates and changes candidate-name links to lead to those pages while retaining campaign links and analytics.

  • Adds candidate profile, metadata, and on-demand Open Graph image routes.
  • Extends election data models for candidate profiles, multi-race histories, and trustee district numbers.
  • Reuses questionnaire cards on profile pages and consolidates candidate-name linking across election views.

Confidence Score: 4/5

The PR is not yet safe to merge because the previously reported outage fallback still renders council-profile links that lead to 404 pages.

When York Factory is unavailable, ward fallback data still includes locally sourced councillors and links their names internally, while the profile fallback searches only mayoral candidates and consequently rejects those council slugs.

Files Needing Attention: src/app/toronto/vote/2026/data.ts

Important Files Changed

Filename Overview
src/app/toronto/vote/2026/data.ts Adds candidate-profile loading and fallback construction alongside trustee district metadata.
src/app/toronto/vote/2026/candidates/[candidate]/page.tsx Adds the candidate profile route with race details, biography, campaign links, questionnaire answers, and ballot navigation.
src/components/elections/CandidateNameLink.tsx Centralizes candidate-name links, selecting an internal profile or external campaign site according to election capabilities.
src/lib/elections/election-data.ts Extends normalized election data with candidate profiles and race district information.

Reviews (3): Last reviewed commit: "Let the bio prose fill the width" | Re-trigger Greptile

Comment on lines +180 to +181
const view = fallbackView();
return view.mayoral.map((candidate) => ({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Fallback profiles omit councillors

When York Factory is unavailable, ward pages still render locally enriched council candidates and link their names to internal profiles, but this fallback returns only mayoral profiles. getToronto2026Candidate therefore cannot resolve those council links and the candidate pages return 404.

Knowledge Base Used:

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/toronto/vote/2026/data.ts
Line: 180-181

Comment:
**Fallback profiles omit councillors**

When York Factory is unavailable, ward pages still render locally enriched council candidates and link their names to internal profiles, but this fallback returns only mayoral profiles. `getToronto2026Candidate` therefore cannot resolve those council links and the candidate pages return 404.

**Knowledge Base Used:**
- [Municipal election engagement](https://app.greptile.com/buildcanada/-/custom-context/knowledge-base/buildcanada/tradingpost/-/docs/municipal-elections.md)
- [Ward lookup and candidate information](https://app.greptile.com/buildcanada/-/custom-context/knowledge-base/buildcanada/tradingpost/-/docs/ward-lookup.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Codex Fix in Claude Code

mikaalnaik and others added 3 commits September 9, 2026 10:55
55 of the 58 candidates who returned the questionnaire wrote a bio in
it, and the site showed none of them.

The bio lives in the questionnaire's `about-you` step, which
`comparableQuestions` excludes as non-policy — correctly, for a pivot
that exists to compare choices against a field. But nothing ever picked
the written half back up, so the only bio a candidate page could print
was the hand-maintained one in candidates.ts, which is empty for all but
a handful. We held 55 self-authored biographies and published zero.

`writtenQuestions` is the complement of `comparableQuestions`, and
`candidateWriting` pivots it per candidate the way `candidateAnswers`
pivots the choices — with no counts, charts or comparison, because a
paragraph cannot be counted against a field. `rosterSurvey` returns it
alongside the answers, narrowed to the roster on the same rule: prose we
cannot place on a candidate is prose we must not attribute.

TEXTAREA ONLY, WHICH IS THE PRIVACY GUARD

Not "everything that is not a choice". Every free-text field in the
resident survey is identity — postal code, first name, last name, email
— and all four are `text` or `email`. Selecting the long-form type is
what keeps a contact field added in the CMS from ever arriving here as
publishable prose. It is also the guard that does the work: postal_code
sits in `about-you`, which the consent-step exclusion deliberately lets
through so a candidate's bio can pass.

ON THE PAGE

A section of its own between the ballot facts and the questionnaire, in
the order the page actually reads — who is this, then what do they
think. Not the hero: these run to a median of 800 characters, half are
several paragraphs, and the longest is 1,800, which in the hero pushed
the ward and the answers off the screen.

Attributed before it is read. A candidate's account of themselves set in
the same type as the rest of the page would read as ours, so the section
says who wrote it and that it is not our description of them. Blank
lines are kept as paragraph breaks and single newlines as typed.

`ward_commitment_target` — the numerical target and deadline, answered
by 21 — comes through the same mechanism and prints under its question.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Set as one column it was 640px of serif — the width a paragraph can be
read at — sitting in a 1300px band, so two-thirds of the section was air
with a heading floating at the top of it.

The heading and the attribution now 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. The rail is sticky, so the line
saying these are the candidate's words and not ours stays beside the
prose it qualifies instead of being something the reader passes once and
scrolls away from. Below lg it stacks as before.

Also drops the section's "In their own words" eyebrow, which sat
directly on top of a line already saying the candidate wrote this and we
did not — the label and its own caption, stacked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The measure cap was leaving a third of the band empty; filling the width
is the call. So the prose runs the full width of its column, and the
question headings on the follow-up answers lose their cap too.

Leading goes up with the line length — 1.6 to 1.75 on the bio, 1.55 to
1.7 on the rest. A long line needs more space beneath it for the eye to
find the start of the next one, which is the one thing available for
readability that does not narrow the column again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mikaalnaik
mikaalnaik merged commit 7090aa8 into main Sep 9, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant