Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
ded384a
refactor(mp-m4bn): remove the unreachable encho cap
gitrgoliveira Jul 31, 2026
1a46604
feat(mp-m4bn): surface the encho period count in result displays
gitrgoliveira Jul 31, 2026
cd795a6
docs(mp-m4bn): document encho overtime and the (E)/(E×N) result marker
gitrgoliveira Jul 31, 2026
9eab377
chore(mp-m4bn): ignore root PNGs and repair the dead specs screenshot…
gitrgoliveira Jul 31, 2026
1ec1b82
test(mp-m4bn): drop stale comments naming the deleted encho cap (tri-…
gitrgoliveira Jul 31, 2026
7718f76
refactor(mp-m4bn): apply /simplify findings to the encho suffix path
gitrgoliveira Jul 31, 2026
a3c452f
refactor(mp-m4bn): apply /simplify round-2 findings
gitrgoliveira Jul 31, 2026
66cbb45
refactor(mp-m4bn): apply /simplify round-3 findings
gitrgoliveira Jul 31, 2026
7343495
refactor(mp-m4bn): apply /simplify round-4 findings
gitrgoliveira Jul 31, 2026
847f054
refactor(mp-m4bn): replace the source-grep mirror test with a shared …
gitrgoliveira Jul 31, 2026
e5bc736
refactor(mp-m4bn): apply /simplify round-6 findings to the golden-tab…
gitrgoliveira Jul 31, 2026
dff9baa
docs(mp-m4bn): update CLAUDE.md encho suffix gloss to (E)/(E×N)
gitrgoliveira Jul 31, 2026
83ae717
refactor(mp-m4bn): encho marker is always bare (E); the count is reco…
gitrgoliveira Jul 31, 2026
5dd3ecf
feat(mp-m4bn): centre the (E) marker between the two sides' scores
gitrgoliveira Jul 31, 2026
95b63d0
docs(mp-m4bn): illustrate the encho counter and the centred (E) marker
gitrgoliveira Jul 31, 2026
2d773cb
refactor(mp-m4bn): middle marks vs/X/(E)/(DH); results ride with the …
gitrgoliveira Aug 1, 2026
ede103e
refactor(mp-m4bn): ippon scores render as waza letters only, never nu…
gitrgoliveira Aug 1, 2026
fd0e8bc
refactor(mp-m4bn): plain middle reads "vs"; a side with no points sho…
gitrgoliveira Aug 1, 2026
30952d9
docs(mp-m4bn): refresh the viewer screenshot for the vs/dash score gr…
gitrgoliveira Aug 1, 2026
96b0e85
refactor(mp-m4bn): apply /simplify round-9 findings
gitrgoliveira Aug 1, 2026
bdcdc63
docs(mp-m4bn): refresh viewer and pools screenshots for the vs/dash s…
gitrgoliveira Aug 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ Thumbs.db
!web-mobile/.oxlintrc.json
!web/package.json
!web/package-lock.json
# Go testdata fixtures. These are test INPUTS, not build output: the blanket
# *.json above would silently drop them, and a golden table that never lands
# fails CI on a checkout that works locally. /**/ matches zero or more
# directories, so nested layouts (testdata/golden/...) are covered too.
!**/testdata/**/*.json
# Fixture data for scripts/setup_tournament.py (make mobile-app-example);
# without this the demo target fails with FileNotFoundError on any checkout
# other than the one that happens to have the file untracked on disk.
Expand All @@ -76,6 +81,9 @@ node_modules/
# rule above would not match.
web-mobile/node_modules
specs/*
# Re-include the spec directories themselves so git descends into them; a
# directory excluded outright can never have its contents re-included below.
!specs/*/
specs/*/*
!specs/openapi.yaml
# UAT screenshot evidence (review.md V5): per-task PNGs captured during browser
Expand All @@ -85,6 +93,12 @@ specs/*/*
.specify
.agent*
.playwright-mcp
# The Playwright MCP writes browser_take_screenshot files to the repo/worktree
# root, where `git add -A` would sweep them into a commit. PR screenshots belong
# on the pr-assets branch, never in the tree. Root-anchored on purpose: a blanket
# *.png would silently swallow new docs/screenshots/, docs/assets/images/ and
# specs/*/screenshots/ files, which are tracked.
/*.png
.claude/skills/speckit-*
.claude/worktrees/*

Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ tournament-data/
- **Excel Layout**: Uses an **8-column per court** layout. Columns A and G (and their court-shifted counterparts) are 30 units wide; others are 5 units wide. A blank row separates pools vertically.
- **Team Match Labels**: Summaries use **"IV"** (Individual Victories) and **"PW"** (Points Won).
- **Court limit**: courts are labelled A–Z, so `--courts` is hard-capped at 26 and any value over that returns an error rather than silently truncating.
- **Match Decision Types** (`internal/domain/decision.go`): 10 canonical wire values: `""` (none), `"fought"`, `"hikiwake"` (draw), `"kiken"` (legacy withdrawal, maps to kiken-voluntary on YAML load), `"kiken-voluntary"` (FIK Art. 31, permanent), `"kiken-injury"` (FIK Art. 30, reinstateable), `"fusenpai"` (no-show), `"fusensho"` (per-bout default win), `"daihyosen"` (rep bout), `"kachinuki-exhaustion"`. Use `domain.IsKikenDecision(d)` / `domain.IsKikenDecisionStr(s)` to check any kiken variant. Legacy YAML `decision: true` migrates to `"hikiwake"`, `false` to `"fought"`, `"kiken"` to `"kiken-voluntary"` (Decision.UnmarshalYAML). Visual suffixes in the UI: `Kiken`, `Fus.`, `DH`, `(E)` for encho.
- **Match Decision Types** (`internal/domain/decision.go`): 10 canonical wire values: `""` (none), `"fought"`, `"hikiwake"` (draw), `"kiken"` (legacy withdrawal, maps to kiken-voluntary on YAML load), `"kiken-voluntary"` (FIK Art. 31, permanent), `"kiken-injury"` (FIK Art. 30, reinstateable), `"fusenpai"` (no-show), `"fusensho"` (per-bout default win), `"daihyosen"` (rep bout), `"kachinuki-exhaustion"`. Use `domain.IsKikenDecision(d)` / `domain.IsKikenDecisionStr(s)` to check any kiken variant. Legacy YAML `decision: true` migrates to `"hikiwake"`, `false` to `"fought"`, `"kiken"` to `"kiken-voluntary"` (Decision.UnmarshalYAML). Visual marks follow the score sheet's layout, split into two kinds. MIDDLE marks (the centre "vs" cell / score-string centre) — exactly one of `X` (tie), `(E)` (overtime, always bare: `periodCount` is recorded but never displayed), `(DH)` (rep bout); mutually exclusive by rule (a match that went to encho cannot end tied, so X beats (E); a daihyosen is one-point sudden death, so DH bouts have no encho). RESULT marks ride beside the competitor they name: `Ht` (hantei winner), `Kiken` (withdrawer), `Fus.` (no-show; the Excel export also marks the fusensho winner — the JS uses a bout badge instead). Web score strings read `[left cell] [middle] [right cell]` with `vs` as the plain middle and `–` (or empty) meaning no points — never digits, ippon renders as waza letters only: `M vs –`, `M (E) K`, `M Ht (E) K`. Mirrored helpers: `middleMark`/`sideMarks` (bracket.jsx) ↔ `MiddleMark`/`SideMarks` (internal/export/suffix.go); `enchoLabel` pinned in both languages via `internal/export/testdata/encho_labels.json`.
- **Competitor Eligibility** (`internal/state/competitor_status.go`, `internal/engine/eligibility.go`): a kiken/fusenpai decision auto-writes a `CompetitorStatus{Eligible: false}` for the loser; `engine.StartMatch(compID, matchID)` is the pre-flight gate that returns `*IneligibleCompetitorError` (matches `errors.Is(err, ErrIneligibleCompetitor)`). Maps to HTTP 409. Kiken-injury (FIK Art. 30) sets `CompetitorStatus.Reinstateable: true`; the admin can call `POST /api/competitions/:cid/competitors/:pid/reinstate` to restore eligibility. Kiken-voluntary (Art. 31) and fusenpai are not reinstateable.
- **Team Lineups & Kachinuki** (`internal/domain/team_lineup.go`, `internal/engine/kachinuki.go`): TeamLineup pins position→player for a round. FIK 5-person rule: Senpo + Taisho mandatory; 1 vacancy must be Jiho, 2 must be Jiho+Fukusho, 3+ disqualifies. Kachinuki ("winner-stays-on") dynamically appends bouts via `engine.AdvanceKachinuki` until one team is exhausted (`DecisionKachinukiExhaustion`).
- **Schedule Estimator** (`internal/engine/schedule.go`): `EstimateSchedule(EstimateInput) ScheduleEstimate` produces total/per-court minutes from match duration × multiplier × slowest-court buffer. Exposed via stateless `GET /api/schedule/estimate` on both the CLI web server and the mobile app.
Expand Down
Binary file added docs/screenshots/mobile-encho-overtime.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/mobile-pool-standings.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/viewer-competition.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/viewer-result-encho.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 15 additions & 1 deletion docs/user-guide/court-operators/recording-decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,20 @@ An injury kiken can be reinstated later by the operator if the competitor recove

A hikiwake is a draw. It applies in pool, league, and Swiss matches (not in the knockout phase) and contributes to the standings separately from wins and losses.

## Encho (overtime)

Encho is the extra period played when a knockout match is level at the end of regulation. It follows ippon-shobu rules: the first competitor to score wins.

In the score editor, open the **Overtime** control and tick **Encho started**. A counter appears so you can record how many overtime periods were fought, using the **+** and **-** buttons. The counter starts at 1 and has no upper limit: how many periods are fought, and how a match still level after them is finally settled (a judges' decision for an individual bout, a daihyosen for a team encounter), is the shimpan's call. Record what actually happened on court, however many periods that took.

![The score editor during a second overtime period: the Encho started box is ticked, the period counter reads two, and the header shows the live overtime readout.](../../screenshots/mobile-encho-overtime.png)

Completed results follow the paper score sheet's layout. The centre, between the two sides' points, only ever carries one mark: **vs** when no special mark applies, **X** for a tie, **(E)** for a match that went to overtime, or **(DH)** for a team encounter sent to a representative bout. The marks never combine: a match that went to encho cannot end in a tie, and a daihyosen has no overtime. A side with no points shows a plain dash. A match won by men in regulation reads **M vs -** and the same win in overtime reads **M (E) -** on the court console, the public viewer, and the exported results workbook; the marker is the same however many overtime periods were fought, because the counter records the number for the tournament log but results never show it.

Everything else is a result, written beside the competitor it names: **Ht** next to the winner of a judges' decision, **Kiken** next to the competitor who withdrew, **Fus.** next to a no-show. A match decided by hantei after a tied overtime reads **M Ht (E) K**, with the winner's mark on the winner's side of the centre. A team encounter shows its team totals in the score cell instead, so for those the marks appear on the bracket and in the exported workbook.

![Two completed results on the public viewer: an overtime win reading M, the E marker, then a dash for the side with no points, and a hantei result with the Ht mark beside the winner.](../../screenshots/viewer-result-encho.png)

## Daihyosen

A daihyosen is a representative bout used to break a tie when points and ranking criteria cannot separate two teams.
Expand All @@ -37,7 +51,7 @@ It applies in two situations:

A tie that does not affect advancement is left as a shared rank with no extra bout. In league play, running the daihyosen is the operator's choice rather than an automatic step; see [Team standings and tie-breaks](../organisers/team-tournaments.md#team-standings-and-tie-breaks) for both options.

The bout is a single-point ippon-shobu with no time limit, between one representative from each tied team. The score editor lets you pick each team's representative from its roster. On the court console, the bout appears with a **DH** tag. In pool and league standings, the team that won its daihyosen carries a **DH** badge.
The bout is a single-point ippon-shobu with no time limit, between one representative from each tied team; because it runs until someone scores, a daihyosen has no encho. The score editor lets you pick each team's representative from its roster. On the court console, the bout appears with a **(DH)** mark in the centre of the score. In pool and league standings, the team that won its daihyosen carries a **DH** badge.

## Chusen (drawing lots)

Expand Down
128 changes: 60 additions & 68 deletions internal/export/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,6 @@ func overlayPoolScores(f *excelize.File, pools []helper.Pool, resultByID map[str
}

hantei := mr.DecidedByHantei != nil && *mr.DecidedByHantei
sfx := DecisionSuffix(mr.Decision, mr.Encho, hantei)

var leftScore, rightScore string
if engi {
Expand All @@ -374,9 +373,12 @@ func overlayPoolScores(f *excelize.File, pools []helper.Pool, resultByID map[str
leftScore = IpponsScore(leftIppons)
rightScore = IpponsScore(rightIppons)
}
setCellStr(f, sheetName, lVCol, excelRow, leftScore)
setCellStr(f, sheetName, rVCol, excelRow, rightScore)
if mid := MiddleCellText(mr.Decision, sfx); mid != "" {
// Result marks (Kiken/Fus./Ht) go in the competitor's score
// cell; the middle carries only its single mark (X/(E)).
lMark, rMark := SideMarksLR(mr.Decision, hantei, mr.Winner, mr.SideA, mr.SideB, mirror)
setCellStr(f, sheetName, lVCol, excelRow, joinSp(leftScore, lMark))
setCellStr(f, sheetName, rVCol, excelRow, joinSp(rightScore, rMark))
if mid := MiddleMark(mr.Decision, mr.Encho); mid != "" {
setCellStr(f, sheetName, middleCol, excelRow, mid)
}
}
Expand Down Expand Up @@ -492,15 +494,20 @@ func buildCourtMatchJobs(pools []helper.Pool, numCourts int, poolOrdinals map[st
// rVCol (startCol+5) = right IV, rPCol (startCol+4) = right PW
//
// SideA is Red (left by default), SideB is Shiro (right); mirror swaps sides.
// The middle "vs" cell carries the encounter's decision suffix (DH etc.) or the
// hikiwake "X" marker when the team encounter is a draw.
// The middle "vs" cell carries only the encounter's single middle mark
// (X for a drawn encounter, (DH) when it went to a representative bout);
// encounter-level result marks (Kiken/Fus./Ht) ride in the competitor's IV
// cell, next to their victory count.
func writeTeamSummaryCells(f *excelize.File, sheetName string, courtStartCol, excelRow int, mr state.MatchResult, mirror bool) {
lVCol := colNum(courtStartCol + 1)
lPCol := colNum(courtStartCol + 2)
middleCol := colNum(courtStartCol + 3)
rPCol := colNum(courtStartCol + 4)
rVCol := colNum(courtStartCol + 5)

hantei := mr.DecidedByHantei != nil && *mr.DecidedByHantei
lMark, rMark := SideMarksLR(mr.Decision, hantei, mr.Winner, mr.SideA, mr.SideB, mirror)

line := state.TeamResultFrom(mr.SubResults, mr.SideA, mr.SideB)
if line != nil {
// SideA = Aka, SideB = Shiro. Left is Aka unless mirror.
Expand All @@ -509,26 +516,43 @@ func writeTeamSummaryCells(f *excelize.File, sheetName string, courtStartCol, ex
if mirror {
leftIV, leftPW, rightIV, rightPW = rightIV, rightPW, leftIV, leftPW
}
setIntCellDirect(f, sheetName, lVCol, excelRow, leftIV)
setIVCellWithMark(f, sheetName, lVCol, excelRow, leftIV, lMark)
setIntCellDirect(f, sheetName, lPCol, excelRow, leftPW)
setIntCellDirect(f, sheetName, rVCol, excelRow, rightIV)
setIVCellWithMark(f, sheetName, rVCol, excelRow, rightIV, rMark)
setIntCellDirect(f, sheetName, rPCol, excelRow, rightPW)
} else {
// No summary line (e.g. a forfeit before any bout was fought):
// the result marks still need a home in the competitor's cell.
if lMark != "" {
setCellStr(f, sheetName, lVCol, excelRow, lMark)
}
if rMark != "" {
setCellStr(f, sheetName, rVCol, excelRow, rMark)
}
}

hantei := mr.DecidedByHantei != nil && *mr.DecidedByHantei
sfx := DecisionSuffix(mr.Decision, mr.Encho, hantei)
if mid := MiddleCellText(mr.Decision, sfx); mid != "" {
if mid := MiddleMark(mr.Decision, mr.Encho); mid != "" {
setCellStr(f, sheetName, middleCol, excelRow, mid)
}
}

// setIVCellWithMark writes a team IV count, appending a result mark
// ("2 Kiken") when one applies to that side; a markless cell stays numeric.
func setIVCellWithMark(f *excelize.File, sheetName, col string, row, iv int, mark string) {
if mark == "" {
setIntCellDirect(f, sheetName, col, row, iv)
return
}
setCellStr(f, sheetName, col, row, joinSp(fmt.Sprintf("%d", iv), mark))
}

// writeTeamSubMatchScores writes each sub-bout's ippon letters onto the team
// sub-match rows. Left ippons -> lVCol (startCol+1), right -> rVCol (startCol+5),
// middle "vs" -> tie marker / suffix. subResults are keyed by Position (1-based);
// the daihyosen placeholder (Position < 0) is skipped so its blank row stays clean.
// teamSize bounds the number of sub-match rows the grid actually has; a Position
// outside [1, teamSize] (corrupted state) is skipped rather than writing into the
// next encounter's cells (mirrors the guard in overlayTeamBracketScores).
// next encounter's cells. Shared by the pool sheet and overlayTeamBracketScores.
func writeTeamSubMatchScores(f *excelize.File, sheetName string, courtStartCol, subStartExcelRow int, subResults []state.SubMatchResult, teamSize int, mirror bool) {
lVCol := colNum(courtStartCol + 1)
middleCol := colNum(courtStartCol + 3)
Expand All @@ -545,18 +569,15 @@ func writeTeamSubMatchScores(f *excelize.File, sheetName string, courtStartCol,
if mirror {
leftIppons, rightIppons = sub.IpponsB, sub.IpponsA
}
lScore := IpponsScore(leftIppons)
rScore := IpponsScore(rightIppons)
if lScore != "" {
lMark, rMark := SideMarksLR(sub.Decision, sub.DecidedByHantei, sub.Winner, sub.SideA, sub.SideB, mirror)
if lScore := joinSp(IpponsScore(leftIppons), lMark); lScore != "" {
setCellStr(f, sheetName, lVCol, excelRow, lScore)
}
if rScore != "" {
if rScore := joinSp(IpponsScore(rightIppons), rMark); rScore != "" {
setCellStr(f, sheetName, rVCol, excelRow, rScore)
}

hantei := sub.DecidedByHantei
sfx := DecisionSuffix(sub.Decision, sub.Encho, hantei)
if mid := MiddleCellText(sub.Decision, sfx); mid != "" {
if mid := MiddleMark(sub.Decision, sub.Encho); mid != "" {
setCellStr(f, sheetName, middleCol, excelRow, mid)
}
}
Expand Down Expand Up @@ -873,12 +894,12 @@ func overlayBracketScores(f *excelize.File, bracketByNum map[int]state.BracketMa
}
}

sfx := DecisionSuffix(bm.Decision, bm.Encho, bm.DecidedByHantei)
lMark, rMark := SideMarksLR(bm.Decision, bm.DecidedByHantei, bm.Winner, bm.SideA, bm.SideB, mirror)

setCellStr(f, sheetName, lVCol, excelRow, leftScore)
setCellStr(f, sheetName, rVCol, excelRow, rightScore)
setCellStr(f, sheetName, lVCol, excelRow, joinSp(leftScore, lMark))
setCellStr(f, sheetName, rVCol, excelRow, joinSp(rightScore, rMark))

if mid := MiddleCellText(bm.Decision, sfx); mid != "" {
if mid := MiddleMark(bm.Decision, bm.Encho); mid != "" {
setCellStr(f, sheetName, middleCol, excelRow, mid)
}

Expand Down Expand Up @@ -922,13 +943,7 @@ func overlayTeamBracketScores(f *excelize.File, bracketByNum map[int]state.Brack
}

courtStartCol := headerCol + 1 // 1-based
lVCol := colNum(courtStartCol + 1)
lPCol := colNum(courtStartCol + 2)
middleCol := colNum(courtStartCol + 3)
rPCol := colNum(courtStartCol + 4)
rVCol := colNum(courtStartCol + 5)

headerExcelRow := rowIdx + 1 // H (1-based)
headerExcelRow := rowIdx + 1 // H (1-based)

// For the 3rd Place block write entrant names unconditionally so they
// appear even when the bronze match is not yet played. Sub-match rows,
Expand All @@ -940,46 +955,23 @@ func overlayTeamBracketScores(f *excelize.File, bracketByNum map[int]state.Brack
}
}

// Sub-match ippon letters: Position p sits at H+2+p.
for _, sub := range bm.SubResults {
if sub.Position <= 0 || sub.Position > teamSize {
continue // daihyosen placeholder / out-of-range
}
excelRow := headerExcelRow + 2 + sub.Position
leftIppons, rightIppons := sub.IpponsA, sub.IpponsB
if mirror {
leftIppons, rightIppons = sub.IpponsB, sub.IpponsA
}
if s := IpponsScore(leftIppons); s != "" {
setCellStr(f, sheetName, lVCol, excelRow, s)
}
if s := IpponsScore(rightIppons); s != "" {
setCellStr(f, sheetName, rVCol, excelRow, s)
}
subSfx := DecisionSuffix(sub.Decision, sub.Encho, sub.DecidedByHantei)
if mid := MiddleCellText(sub.Decision, subSfx); mid != "" {
setCellStr(f, sheetName, middleCol, excelRow, mid)
}
}
// Sub-match ippon letters: Position p sits at H+2+p, i.e. the sub
// rows start at H+3. Same writer as the pool sheet.
writeTeamSubMatchScores(f, sheetName, courtStartCol, headerExcelRow+3, bm.SubResults, teamSize, mirror)

// IV/PW summary row = H + 5 + teamSize.
// IV/PW summary row = H + 5 + teamSize. Route through the shared
// pool-sheet writer so the IV-mark contract (and the forfeit
// fallback when no summary line exists) lives in one place.
summaryExcelRow := headerExcelRow + 5 + teamSize
if line := state.TeamResultFrom(bm.SubResults, bm.SideA, bm.SideB); line != nil {
leftIV, leftPW := line.AkaIV, line.AkaPW
rightIV, rightPW := line.ShiroIV, line.ShiroPW
if mirror {
leftIV, leftPW, rightIV, rightPW = rightIV, rightPW, leftIV, leftPW
}
setIntCellDirect(f, sheetName, lVCol, summaryExcelRow, leftIV)
setIntCellDirect(f, sheetName, lPCol, summaryExcelRow, leftPW)
setIntCellDirect(f, sheetName, rVCol, summaryExcelRow, rightIV)
setIntCellDirect(f, sheetName, rPCol, summaryExcelRow, rightPW)
}

sfx := DecisionSuffix(bm.Decision, bm.Encho, bm.DecidedByHantei)
if mid := MiddleCellText(bm.Decision, sfx); mid != "" {
setCellStr(f, sheetName, middleCol, summaryExcelRow, mid)
}
writeTeamSummaryCells(f, sheetName, courtStartCol, summaryExcelRow, state.MatchResult{
SideA: bm.SideA,
SideB: bm.SideB,
Winner: bm.Winner,
Decision: bm.Decision,
Encho: bm.Encho,
DecidedByHantei: &bm.DecidedByHantei,
SubResults: bm.SubResults,
}, mirror)

// Winner marker: the "1." row is 3 rows below the summary row; reuse the
// individual writer, which scans forward for the "1." ordinal.
Expand Down
Loading