Add "Stats for Nerds" to Bloc STAR (multi-winner STAR) results¶
PR: Equal-Vote/bettervoting#1675 (draft, opened 2026-09-23 from
masiarek/star-serverbranchfeat/1674-bloc-star-stats-for-nerds; four commits, verified locally against production data).Filed 2026-09-23 as Equal-Vote/bettervoting#1674 with labels Role: Front End, Complexity: Small, Track Via Parent (#1652). The text below is what was posted; screenshots embed from
bloc-star-stats-for-nerds/img/in this repo.
Overview¶
Single-winner STAR results have a Stats for Nerds dropdown under Race Details (Tabulation Steps, Distribution of Equal Support, Head-to-Head Matchups, Average Supporter Profile). Bloc STAR results (a STAR race with more than one winner) show only the Tabulation Steps widget, with no dropdown at all. A client has asked for these stats on a Bloc STAR election (stretch item "Add stats for nerds" on #1652; Arend on Slack, 2026-09-22), so bring Bloc STAR to parity, with the seat-dependent section drawn once per seat.
Bloc STAR fills N seats by running the single-winner STAR count N times on the same ballots, removing each winner before the next count. In the code, results.roundResults[i] is the count for seat i+1, and roundResults[i].runner_up[0] is the finalist who lost that seat's automatic runoff.
As-is / Should-be¶
| Single-winner STAR | Bloc STAR | |
|---|---|---|
| As-is | Race Details → Stats for Nerds dropdown with 4 entries | Race Details → Tabulation Steps only, no dropdown (e.g. https://bettervoting.com/k7pfqt/results) |
| Should-be | unchanged | Race Details → the same 4-entry dropdown; Distribution of Equal Support drawn once per seat |
The library's headless screenshot script states the gap in one line each: on a Bloc election bv_result_screenshot.py 2cdvm6 --list-panels reports no combobox; on a single-winner election (67x93r) it reports ["Tabulation Steps","Distribution of Equal Support","Head-to-Head Matchups","Average Supporter Profile"]. After the fix the Bloc line must match the single-winner one.
What each section should show on a Bloc STAR race with N seats¶
- Tabulation Steps: as today, one "Winner k" block per seat.
- Distribution of Equal Support: N charts stacked, one per seat, each headed with the seat and its finalist pair (e.g. "Winner 2: Anika vs Bo", which also answers #903). Each bar is the share of that seat's equal-support ballots at that star level, exactly as the single-winner chart shows it today (on 67x93r: 5★ 50%, 3★ 50%). The ballots behind the bars must add up to that seat's Equal Support count in the Automatic Runoff chart and in the Tabulation Steps sentence. A seat with no runner-up (the last seat when one candidate is left) shows the existing "X is the only candidate, and wins by default" text instead of a chart.
- Head-to-Head Matchups: unchanged. The candidate dropdown lists winners in seat order, then the rest by score.
- Average Supporter Profile: unchanged; its "preferred frontrunner" panel is already hidden when
num_winners > 1. - Single-winner STAR results are unchanged. The three
ALL_STATS-flagged entries stay behind the flag.
Where it lives¶
packages/frontend/src/components/Election/Results/Results.tsx,STARResultsViewer(line 31).roundsisrace.num_winners(line 35). Lines 46–59 are the single-winner branch:STARDetailedResultsplus<DetailExpander level={1}>holding the four widgets and the three flagged ones. Lines 60–64 are the multi-winner branch: onlySTARResultDetailedStepsWidget. The change is to make lines 60–64 look like 46–59, minusSTARDetailedResults.packages/frontend/src/components/Election/Results/components/DetailExpander.tsx:level={1}is the Stats for Nerds dropdown. Constraint: it builds the menu fromReact.Children.toArray(children)filtered byselectorTitleKeys(lines 29–38) but renderschildren[selector]from the raw children (line 92). So each widget must be exactly one direct child, in menu order, with any conditional children last. Do not.map()per-seat widgets as siblings in Results.tsx and do not wrap them in a Fragment; the per-seat loop goes inside the widget. A brand-new component must also be added toselectorTitleKeys.packages/frontend/src/components/Election/Results/STAR/STAREqualPreferencesWidget.tsx: takes onefrontRunnerspair; line 18 readsfrontRunners[1].id, which throws when a seat has no runner-up.- The backend already returns everything needed:
runBlocTabulator(packages/backend/src/Tabulators/Util.ts) pushes oneroundResultsentry per seat with its ownwinners[0],runner_up[0]andtieBreakType, andvotesPreferredOveris filled for every candidate pair regardless of seats. No backend, API or database change.
Action items¶
Step A: drop-in, no logic change (about 10 lines in Results.tsx). Add <DetailExpander level={1}> to the rounds > 1 branch with STARResultDetailedStepsWidget, STAREqualPreferencesWidget, HeadToHeadWidget, VoterProfileWidget topScore={5} and the three flagged widgets, in that order. Step A alone gives three of the four sections and can ship on its own if the election date bites.
Step B: per-seat Distribution of Equal Support (about 30–40 lines in STAREqualPreferencesWidget.tsx).
1. Read results.roundResults inside the widget (via useRace()) instead of a single frontRunners prop, or pass a per-seat list built in Results.tsx. Keep it one child element.
2. Loop over seats inside the widget: one Widget/ResultsBarChart per seat, finalists roundResults[i].winners[0] and .runner_up[0].
3. Head each chart with the seat and the pair. STARResultDetailedStepsWidget.tsx:47 hardcodes Winner ${r + 1} in English with no i18n key; add one key (e.g. results.star.seat_heading, "Winner {{n}}") and use it in both widgets rather than adding a second hardcoded heading.
4. When runner_up is empty, render t('results.single_candidate_result', {name}) (en.yaml, already used by STARResultSummaryWidget.tsx:35) instead of a chart.
5. Per-seat tie text must come from roundResults[i].tieBreakType, not the top-level results.tieBreakType, which only reflects the final seat (#1582).
Step C: a backend unit test. Star.test.ts has no test with more than one winner. Add one Star(candidates, votes, 2) case using the BV2266 ballots below, asserting each seat's winner and runner-up and that the pairwise matrix includes non-finalists (Anika > Bo 4–2, > Cora 5–2, > Dev 4–3; Dev > Bo 2–1).
Out of scope, separate issues if wanted: the level-0 Race Details tables (STARDetailedResults: Scores Table + Runoff Table) are single-winner only and read summaryData.candidates[0..1] as the finalists; a per-seat version is its own ticket. The Head-to-Head chart's grey-segment tooltip ("Distribution of Equal Support") has been empty for every cardinal method since 860e1d63 dropped the per-score computation; pre-existing and unrelated to seats.
How to verify¶
The Sandbox cannot be used: it never mounts AnonymizedBallotsContextProvider, so Race Details stays on "Loading..." (#782). Use live elections with public results, on production or with the "Local Frontend + Production Backend" setup from docs/contributions/developers/1_local_setup.md. All of these are frozen in masiarek/star-voting-library (02_STAR_Bloc) with a _bv_export.json and an independent engine count, so the expected numbers below can be read off the library page.
| Election | Shape | Per-seat finalists and Equal Support count | Exercises |
|---|---|---|---|
| k7pfqt (BV2266) | 4 cand / 2 seats / 7 ballots | S1 Dev vs Bo, 4 (2★1, 3★1, 5★2); S2 Anika vs Bo, 1 (1★1) | different pair per seat, multi-level histogram, same runner-up twice. Head-to-Head dropdown order: Dev, Anika, Bo, Cora |
| 8h3yrx (BV1835) | 5 / 4 / 100 | S1–S4 Bianca, Cedric, Deegan, Eli each vs Ava, 0 each (all-zero histograms) | 4 stacked charts, all-zero data, no NaN; dropdown order Bianca, Cedric, Deegan, Eli, Ava |
| yhxy7q (BV130) | 6 / 3 / 9 | S1 Someone I Like vs Santa Claus, 6 (3★4, 5★2); S2 Santa Claus vs The Lesser Evil, 0; S3 The Lesser Evil vs Elvis Presley, 0 | happy path, 3 seats |
| fk38pk (BV1815, closed) | 3 / 2 / 3 | S1 A vs C, 0; S2 B vs C runoff 1–1 decided by score, 1 (0★1) | score tiebreak on seat 2 |
| 484mbm | 3 / 2 | S1 Blythe vs Arden, random after a 3-way score tie | random tiebreak on seat 1; roundResults[0].tieBreakType is random while the top-level field reads none (#1582) |
| 2cdvm6 (BV2290) | 12 / 5 / 108 | Equal Support 96, 64, 96, 96, 14 | many candidates (11 Head-to-Head charts), large Equal Support |
| rq2c3g (BV2292) | 14 / 6 / 175 | Equal Support 0, 161, 0, 123, 58, 58 | six stacked charts |
| xpr4wk (BV2289) | 5 / 2 / 12 | S1 Uma vs Ugo, 7; S2 Ugo vs Maya, 0 | histogram present on one seat, empty on the next |
Checks:
- The dropdown has exactly four entries in this order: Tabulation Steps, Distribution of Equal Support, Head-to-Head Matchups, Average Supporter Profile; no duplicate entry; selecting each renders that widget and not its neighbour. With flag_overrides ALL_STATS on, three more entries appear.
- Distribution of Equal Support: one chart per seat with the pairs and totals above. If the seat-1 chart on 8h3yrx shows Bianca vs Cedric, the widget is reading summaryData.candidates[0..1] instead of roundResults[0].
- A seat with zero equal-support ballots renders six 0% bars and no NaN (8h3yrx every seat).
- Tabulation Steps for Winner 1 on 484mbm still shows the random tiebreak.
- Single-winner STAR results (e.g. 67x93r) are unchanged.
- Print preview on 8h3yrx with Distribution of Equal Support selected: no chart split across a page. At 375px width no horizontal scroll.
- Known, not this issue: on w3vvff and r4dqvd the frontend keeps partial/flat ballots the tabulator files as abstentions (#1478), so histogram totals there will not match the runoff chart. Do not use those two for count matching.
- bv_result_screenshot.py <id> --list-panels on any Bloc election above must print the four-entry list; --panel "Distribution of Equal Support" captures the stacked charts for the PR.
- No Playwright spec opens Race Details and all four specs create num_winners: 1 races; an e2e for this is a separate ticket.
Test elections still needed (no live coverage today)¶
- 2 candidates / 2 seats (and 3 / 3): the last seat has
runner_up: []. BetterVoting accepts this shape (the form only rejects more winners than candidates), but no public election or library case exists, and the library engine refuses seats == candidates, so it must be a BV-only election. Expected: Winner 2 page shows the single-candidate text, and the seat-2 Equal Support section shows that text instead of a chart. - A seat decided at the five-star rung: none exists anywhere. Engine-verified proposal in the library PR: 3 candidates / 2 seats, both seats resolved at five-star, no lot.
- 3 seats with a non-trivial histogram at every seat, same runner-up losing twice, then a score tiebreak: engine-verified proposal (4 candidates / 3 seats / 9 ballots).
- Same Equal Support total, different shape per seat, with two flat ballots: engine-verified proposal (5 / 2 / 10).
Effort¶
Small. Step A about 10 lines; Step B about 30–40 lines plus one i18n key; Step C one backend test. Half a day including manual QA on the elections above. No backend, API or database change.
Open questions¶
- How many seats and candidates does the client's Bloc STAR race have? Stacked per-seat charts are fine for 2–6 seats; beyond that a seat selector may be worth it.
- Is Average Supporter Profile wanted, or only the three sections in the screenshot? (Default here: include it, it is a drop-in.)
- Is Step A on its own acceptable for the election date if Step B slips?
Related¶
-
1652 — parent; this is the "Add stats for nerds" stretch item¶
-
1582 — multi-winner export only carries the final seat's
tieBreakType; per-seat text must readroundResults[i]¶ -
903 — "Distribution of Equal Support" title is unclear; the per-seat heading names the pair¶
-
1478 — partial/flat ballots dropped as abstentions; why histogram totals differ on w3vvff / r4dqvd¶
-
782 — Sandbox reporting is incomplete; why Stats for Nerds cannot be QA'd there¶
-
740 — abstentions and other high-level stats in Stats for Nerds¶
-
1086 / #904 / PR #1475 — Bloc STAR naming on the same results page¶
- PR #1541 — results help page; add a Bloc STAR paragraph once this lands
- masiarek/star-voting-library
02_STAR_Bloc/02_Examples/bloc_shapes/README.md— the six live Bloc elections with 5+ seats, and the note that BV's ownStar.test.tsnever runs more than one seat