---
title: Rework the Stats and Standings Table Presentation
type: task
status: completed
description: >-
  Collapse the stats page's Lineup and Positional panels into one By Slot / By Position toggle, and
  give /standings its first presentation pass. Carries the design decision that ruled out centering
  on the sticky-column ground.
base_uri: user:task/league/rework-league-stats-and-standings-presentation.md
created_at: '2026-09-19T16:26:45.879Z'
entity_id: 1c9979f1-45a5-48cd-83ec-8a8e3f2751ed
finished_at: '2026-09-19T17:30:42.874Z'
observations:
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
owner_identity_uri: █████████████████████████
priority: Medium
public_read: true
started_at: '2026-09-19T16:39:49.541Z'
tags:
  - user:tag/league-xo-football.md
updated_at: '2026-09-19T17:30:42.874Z'
visibility_analyzed_at: '2026-11-06T00:18:04.009Z'
---

# Rework the Stats and Standings Table Presentation

`/leagues/:lid/stats` was built table-by-table rather than designed, and `/standings` has never had a presentation pass at all. A 2026-09-19 session fixed the page's one functional defect and made four presentation changes; this task carries the design decision the operator approved and the remaining scope.

Precedent worth reading first: [[user:task/league/rework-matchup-history-table-presentation.md]] is the same shape of work on the matchup page, completed 2026-09-11.

## What already landed

Four commits, all pushed, `origin/master` at `735b7d877` with a `pass` verdict in the local verify store.

- `0d6cef023` — the functional defect. The 2026-08-16 position-code conformance renamed `league_team_seasonlogs.starter_points_qb/rb/wr/te/k/dst` to full-word tokens; league `f1999a21e` moved the writer onto the new names and left the stats page building the key from the short abbreviation. Every cell of Positional Stats rendered `-` at `0.0%`, for every season, since the rename. Fixed by reading `starter_points_league_columns`, the map the writer pins to.
- `cfae8e530` — `SelectYear` now orders its menu newest-first. `league.years` arrived unordered (`2022, 2025, 2023, 2024, 2026, 2020, 2021` on genesis). Reaches the four pages mounting the control: stats, standings, schedule, matchup.
- `50959dec2` — one right edge (released `.section`'s `max-width: fit-content`, which left four panel edges at 1190/1092/1090/970 and pushed the widest table's overflow onto the page); group heads on Lineup and Positional in place of thirteen bare `%` labels; Positional drops a position the league neither rosters nor scored; Positional's team cell made sticky like the other two.
- `735b7d877` — SEASON and ALL-TIME bands opening each half, with the year control inside the season band's head; and the columns absorb panel slack so no table trails dead white row.

## The approved design decision

**Collapse Lineup Stats and Positional Stats into one panel with a By Slot / By Position toggle.**

They are two cuts of the same quantity — points by roster slot and points by position — with QB/RB/WR/TE/DST appearing in both, stacked one above the other. A toggle is what that shape wants. It also drops the season group from three panels to two, at which point the remaining width gap is small enough that the existing slack distribution handles it invisibly.

This was chosen over three alternatives, and the reasoning should not be rediscovered:

- **Centering the tables is ruled out on a hard technical ground**, not taste. `.sticky__column` is `position: sticky; left: 0` (`app/styles/table.styl:176`). A centered table sits at a computed auto margin — 33px for League Stats, 94px for Positional in the 1158px panel — and an auto margin cannot be repeated as a sticky offset, so the team column would slide out of its own table's left edge on horizontal scroll. That is the trap STYLE.md documents at line 502, where the at-rest screenshot looks correct. Centering also moves the team column, which is the one thing all four tables share, and has no answer for Career Stats at 2478px, wider than the panel.
- **Tabs across the whole season group** (League / Lineup / Positional) remain the fallback if the toggle does not go far enough. Costs a click to compare across groups.
- **Dropping the card for these tables** — head, rule, table, natural width — is the direction the newspaper-worksheet aesthetic points, and is how the sports-reference class of sites avoids the problem entirely. Honest caveat: STYLE.md's no-cards rule is written for the prose tier, not app chrome, so it is a direction rather than a binding rule here.

## Plan

- [x] Build the By Slot / By Position toggle and merge the two stats panels into one
- [x] Re-measure the season group at desktop and 390px, and confirm the slack distribution still lands trailing-gap 0
- [x] Drive `/standings` in a browser and decide what its presentation pass is
- [x] Apply the standings pass, including whether it takes the same SEASON band treatment
- [x] Give the Career Stats `Seasons` / `Division Wins` singles a group head so the band is not empty and the label does not wrap
- [x] Check `weekly_score_deviation` in `league_team_seasonlogs` to settle whether the `Stdev` dashes are real
- [x] Deploy, which nothing in this chain has yet done (`yarn deploy:all`, human-gated)

## Deployed and verified on production 2026-09-19

All seven commits are live. They shipped inside the `███████████████████████████████████` session's revert deploy of `5304f8a7a`, which was holding a production outage open — that session took the whole of `origin/master`, so this chain went out as a passenger rather than on its own deploy.

Verified against `https://xo.football` itself rather than the dev server, with `location.href` asserted on every read:

- **API healthy across a sampled window** — 16 requests over ~32s alternating `leagues/1` and `sources`, each cache-busted with a query nonce and `cache: no-store`. First 293ms, then 27–45ms for all fifteen that followed, every one 200, no upward trend.
- **`/stats`** — 35 rows; League Stats, Starter Points and Career Stats all sharing right edge 1692; Season and All-Time bands; section head 28px at 12px/600; zero MUI toolbars; the toggle painting one uniform `rgb(201,203,207)` border with the selected segment on `rgb(233,233,236)`.
- **`/standings`** — panel and chart both ending at 1563, head row back to its 64px floor, the `Odds` band live, and the row sequence `Bye Teams` / 2 teams / `Wildcard Teams` / 4 teams / `Outside the Field` / 4 teams. **Each label heads the group it names**, which is the defect `18fb116c0` fixed and the one thing this chain had never seen against live data.

### A verification trap worth not repeating

The first production reading was not production. The local browser profile allowlists `localhost` only, so `open https://xo.football/...` was REFUSED — and the refusal goes to stderr, which a `| grep title` pipes away, while the daemon keeps serving the page it already had. `evaluate` then answers from the local dev server with entirely plausible numbers. `location.href` is the only thing that catches it; there is a separate profile for production.

## What landed 2026-09-19

Three more commits, all on `master`, taking the chain to seven.

- `6693fc0bd` — the approved toggle. Lineup Stats and Positional Stats are one `Starter Points` panel with By Slot / By Position segments, written out rather than mapped. Career Stats loses its two loose head cells at the same time: `number_seasons` heads its own `Seasons` band taking the `#` the Post Season group already uses, and `division_wins` joins `Regular Season` as `Div Wins`. The band opening each half moves to `.section__band` in `section.styl`, because standings now carries the same object.
- `7c85544bf` — the centering corollary the predecessor deferred, now that the toggle has settled the layout it was measured against. It extends the sticky-offset trap rather than repeating it: the existing entry implies a repair — move one number and move the other — and the auto-margin case is the one where no such number exists.
- `18fb116c0` — the standings pass, four changes. One right edge (the Overall panel was 760px beside a 1158px chart); one `Odds` band over Playoff/Bye/Champ, which is what stops the head wrapping; the seed band labels now OPEN their group instead of closing it; and the year control moves into a `.section__band` with the page name beside it.

### The divider labels were saying the wrong thing

Worth stating because it is the only functional finding of the standings pass and it was invisible. The dividers were emitted AFTER the seed that ends a band, so `Bye Teams` sat immediately below the two bye teams and immediately above the four wildcards. A reader going down a column reads a label as heading what follows it, so the page asserted that the wildcard teams were the bye teams. Both readings put a grey rule in the same place, so no screenshot could discriminate it.

Emitting on the band CHANGE instead also retired two arithmetic edge cases the old form had no answer for: a league with no byes emitted `Bye Teams` at seed 0 over an empty band, and one whose bye count equalled its playoff field emitted both labels against the same seed.

### Measurements after

At 1440, all three stats panels and the standings panel land at 1158px against a right edge of 1424, which the probability chart also shares. Trailing row is 2px on League Stats, 4px on Starter Points, 0 on standings. The standings head row is back to its 64px floor from 63px-of-wrapping. Career Stats still scrolls at its natural 2470px inside its own container, as designed.

At 390 (headless, `window.innerWidth` asserted 390), the sticky team column holds at 16px across a 300px horizontal scroll while a control cell travels the full distance — the discriminating pair STYLE.md asks for, with the control proving the reading can move.

`test/app.connected-component-props.spec.mjs` and `test/app.mui-import-ratchet.spec.mjs` are 8 passing with the summary line present; both carry their own negative controls. The compiled CSS for all three changed stylesheets holds no stray stylus ident, against a pattern first proven to fire on a deliberately undeclared variable.

### `Stdev` is settled, and it is not a defect

`weekly_score_deviation` is populated for every row of every season in `lid = 1`. 2026 is not NULL — it is exactly `0.00` for all ten teams, which is correct, because one week has been played and a single observation has zero deviation. The 2025-2020 rows run 8.91 to 33.80.

The dash comes from `PercentileMetric`, whose `format_value` opens `if (!value) return '-'`, so a real zero renders as absent. That is deliberate and app-wide — the component's own comment says so, because shading a zero would paint empty preseason rows in full colour. It is the same zero-versus-nothing distinction the stats page's `share_of_points` helper was written to preserve, resolved the other way in the shared component. Changing it reaches every table in the app and is not this task's to make.

## Still open

**Nothing from this chain is deployed.** All six commits are frontend-only and human-gated behind `yarn deploy:all`. On 2026-09-19 the `███████████████████████████████████` session asked for deploy clearance on a range including the chain's first four commits; this lane acked those four and explicitly told it not to wait for the two new ones. So `0d6cef023` may go live ahead of the rest.

**The division panels were never driven.** Genesis is single-division, so `has_divisions` is false and `Standings` never mounts. It takes the same `StandingsHead` and the same stylesheet as `Overall`, but that path is unverified in a browser.

## Remaining scope

### The toggle

Build the By Slot / By Position control and merge the two panels. Watch the segmented-control trap in STYLE.md: a non-`.button` direct child of `<ButtonGroup>` silently un-paints the group, and a group of segments cannot be mapped because `test/app.connected-component-props.spec.mjs` fails a `Button` handed a `key`. Write the segments out as separate children.

### `/standings`

Added to scope by the operator and not yet examined. Known from a read of `app/views/pages/standings/standings.js`: it stacks two `.section` panels (the per-division tables and Overall) whose column sets are identical, so it has none of the ragged-width problem stats had. It carries `SelectYear`, so it already inherits the sorted menu. Whether it wants the same band treatment, and what else is wrong with it, is an open question — drive it in a browser before deciding.

Related but a different lane: [[user:task/league/extract-shared-standings-row-component.md]] (Planned, blocked by the Material UI removal).

### Smaller items noticed and not fixed

- **Career Stats header.** `Seasons` and `Division Wins` are loose cells sitting before the grouped bands, so `Division Wins` wraps to two lines and both sit under an empty band. Give them a group head or restructure.
- **Long team name overruns the sticky lead cell** by 15px and takes the ellipsis — measured `clientWidth 249` against `scrollWidth 264` for "So You're Telling Me There's a Chance". Cosmetic; the lead cell is a fixed `flex: 0 0 250px` by design.
- **`Stdev` is all dashes for 2026.** Believed genuinely unpopulated this early in the season rather than a second column-name defect, but that was never verified — check `weekly_score_deviation` in `league_team_seasonlogs` before assuming.
- **League Stats columns land at 64–66px rather than one width.** Equal group growth cannot be weighted by cell count without a per-group inline factor, and League Stats is the only table whose groups differ in size. Under 2px a column; fix only if the toggle work makes it free.

## Key locations

- `app/views/pages/stats/stats.js`, `stats.styl` — the page and its scoped overrides
- `app/views/pages/standings/standings.js` — the second page in scope
- `app/views/components/select-year/select-year.js` — shared by four pages
- `app/styles/table.styl` — `.table__cell`, `.row__group`, `.sticky__column`
- `app/styles/section.styl` — the `max-width: fit-content` the stats page overrides
- `STYLE.md` — the design system; § Traps That Fail Silently and § Verifying a Style Change are the load-bearing ones here
- [[███████████████████████████████-█████████]] — `yarn dev:live`, port discipline, and browser verification
