Skip to content

Give every Toronto ward a brief, its statistics, and its incumbent - #82

Merged
mikaalnaik merged 3 commits into
mainfrom
mikaal/ward-profiles
Sep 9, 2026
Merged

Give every Toronto ward a brief, its statistics, and its incumbent#82
mikaalnaik merged 3 commits into
mainfrom
mikaal/ward-profiles

Conversation

@mikaalnaik

Copy link
Copy Markdown
Contributor

Ward pages opened with a name, a map and a list of candidates — who is running, and nothing about the place they would represent. This adds the missing half.

Every ward now gets a short brief, eight Census statistics, and the name of the sitting councillor when they are registered to run again. The section sits at the bottom of the page, after the ballot a reader came for.

Where the numbers come from

Nothing here was typed from memory. Two City of Toronto Open Data sets, over Statistics Canada's 2021 and 2016 Census:

Each is extracted into a small checked-in CSV under scripts/data/, and scripts/gen-ward-profiles.mjs turns them into the display strings the pages use. scripts/data/README.md gives the provenance of every column down to its row in the City's spreadsheet, so any figure can be checked without opening an XLSX. The extraction asserted each field against its source row label rather than trusting row offsets.

Neighbourhood names in the briefs are the City's own, assigned by which ward contains each neighbourhood's centroid — all 158 assigned, no orphans.

Structure

The generated and hand-written halves are kept apart, the same way wardGeo.ts and candidates.ts already are:

file edited by
wardStats.ts, wardCouncillors.ts the generator — do not edit
wardProfiles.ts hand; holds the 25 briefs and combines both halves

WardProfile.tsx owns the WardStat / WardProfile types, as WardMap already owns WardGeo. WardDetail takes an optional profile prop, so Hamilton and Brampton are untouched.

Two decisions worth a look

Every figure is shown against the citywide one. A ward statistic read alone means very little: 45% of households renting is unremarkable until you know the city sits at 48%, and Ward 10's 88% of homes in towers is only legible beside a city average of 47%. The comparison was free — the source has a Toronto column.

Incumbency and candidacy are read from different places and joined at render time. Who sits comes from the City's data; whether they are standing again is answered by looking for them among the ward's registered candidates who have not withdrawn. This deliberately does not use the hand-kept tag: "Incumbent" in candidates.ts — only 13 of 25 wards carry one, so twelve wards would have shown nothing whether or not their councillor was running, and silence there reads as an answer.

Twenty-one wards name someone. The four that do not are all correct: Ward 19's councillor is registered for mayor, Ward 11's is registered in Ward 14, and Wards 4 and 14 have no sitting councillor on their own ballot.

Reviewer notes

  • The layout is visually unverified. No browser was available in the session, so the eight-cell stat grid and the incumbent line were checked in the rendered HTML but never actually looked at. Worth opening /toronto/vote/2026/wards/13 before merging.
  • A latent trap, untouched: candidates.ts:470 lists Dianne Saxe under ward "11" with tag: "Incumbent", but she is registered in Ward 14. The entry is currently inert — enrichment matches within a ward, so Ward 14 finds nothing for her and she renders as "Registered" (zero Incumbent badges on that page). But moving that entry to "14" without changing the tag would badge her incumbent for a seat she does not hold. Either fix the entry, or derive the roster badge from WARD_COUNCILLORS so incumbency has one source instead of two. Out of scope here.
  • Refresh points: wardCouncillors.ts after any by-election and once 2026 results are declared; toronto-ward-census-2021.csv when the City publishes 2026 Census profiles.
  • While drafting the briefs I initially wrote ten false superlatives (wrong ward for lowest rent, lowest income, lowest low-income rate, largest households, lowest rent burden). All ten were caught by ranking each claim against the data and corrected. If you add or edit a brief, the CSV is right there to check against — that failure mode is real.

tsc and eslint are clean; the four remaining eslint warnings are pre-existing in bills/ and state-of-the-nation/. Wards 01, 05, 13 and 25 were verified rendering against the dev server.

🤖 Generated with Claude Code

mikaalnaik and others added 3 commits September 9, 2026 12:01
The 2021 Census numbers behind a ward brief are the sort of thing that gets
hand-typed once and then quietly rots. So they are extracted instead: a small
checked-in CSV per source, and a dependency-free generator that turns them
into the display strings the pages want.

scripts/data/README.md carries the provenance of every column down to its row
in the City's spreadsheet, so a reviewer can check any figure without opening
an XLSX. The extraction asserted each field against its source row label
rather than trusting row offsets.

Statistics come from Ward Profiles (25-Ward Model); the sitting councillor for
each ward comes from Ward and Elected Councillor. Both are City of Toronto
Open Data, over Statistics Canada's 2021 and 2016 Census.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A ward page opened with a name, a map and a list of candidates, which told a
reader who was running and nothing at all about the place they would
represent. Every ward now gets a short brief and eight statistics.

The two halves are kept apart deliberately, the same way wardGeo.ts and
candidates.ts already are: the figures are generated and must not be edited by
hand, the briefs are prose and cannot be generated. wardProfiles.ts holds the
briefs and combines the two.

Every figure is shown against the citywide one, because a ward statistic read
alone means very little — 45% of households renting is unremarkable until you
know the city sits at 48%, and Ward 10's 88% of homes in towers is only
legible beside a city average of 47%.

The briefs stay on geography and the census, and leave the campaign alone.
Anything that turns over mid-campaign would go stale, cannot be verified
twenty-five times over, and on a page built to compare candidates risks
reading as favouring one of them. Neighbourhood names are the City's own,
assigned by which ward contains each neighbourhood's centroid.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Incumbency is a fact about the seat; candidacy is a fact about the roster, and
the roster changes daily. So the two are read from different places and joined
at render time: who sits comes from the City's elected-councillor data, and
whether they are standing again is answered by looking for them among the
ward's registered candidates who have not withdrawn.

That is why this does not use the hand-kept `tag: "Incumbent"` in
candidates.ts. Only 13 of 25 wards carry one, so twelve wards would have shown
nothing whether or not their councillor was running — silence that reads as an
answer. Deriving it covers all 25 and cannot fall behind the roster.

Twenty-one wards name someone today. The four that do not are all correct:
Ward 19's councillor is registered for mayor, Ward 11's is registered in Ward
14, and Wards 4 and 14 have no sitting councillor on their own ballot. Names
are matched with nameKey, so the Clerk's spelling and the City's need only
agree up to case, accents and punctuation.

The section sits at the bottom of the page, after the ballot a reader came
for.

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 an “About this ward” section to Toronto’s 25 ward pages, combining generated Census statistics, hand-written briefs, and incumbent information derived from the current candidate roster.

  • Adds checked-in source extracts and a dependency-free generator for ward statistics and councillor records.
  • Adds Toronto ward briefs and joins sitting councillors to active candidates using normalized names.
  • Extends the shared ward-detail page with an optional profile section, leaving cities without profiles unchanged.

Confidence Score: 5/5

The PR appears safe to merge with no concrete blocking or independently actionable non-blocking defects identified.

The new profile data remains Toronto-specific, ward tokens are normalized through the existing roster, incumbent links use the same name normalization as candidate routes, and the shared component remains backward-compatible through its optional prop.

Important Files Changed

Filename Overview
scripts/gen-ward-profiles.mjs Generates validated, display-ready statistics and councillor maps from the checked-in CSV extracts.
src/app/toronto/vote/2026/wardProfiles.ts Combines ward briefs, generated statistics, and current candidate data into optional ward profiles without an identified correctness defect.
src/app/toronto/vote/2026/wards/[ward]/page.tsx Supplies the resolved, canonically padded ward token and council roster to the new profile accessor.
src/components/elections/WardDetail.tsx Adds the optional profile section after ballot content without changing behavior for callers that omit it.
src/components/elections/WardProfile.tsx Renders the brief, incumbent status, comparative statistics, and source attribution with semantic section and definition-list markup.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  Census[Ward Census CSV] --> Generator[gen-ward-profiles.mjs]
  Councillors[Councillor CSV] --> Generator
  Generator --> Stats[wardStats.ts]
  Generator --> Seats[wardCouncillors.ts]
  Briefs[wardProfiles.ts briefs] --> Profile[wardProfile]
  Stats --> Profile
  Seats --> Profile
  Roster[Current ward candidate roster] --> Profile
  Profile --> Page[Toronto ward page]
  Page --> Section[About this ward section]
Loading

Reviews (1): Last reviewed commit: "Name a ward's incumbent when they are ru..." | Re-trigger Greptile

@mikaalnaik
mikaalnaik merged commit f4729a8 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