---
title: Shade the Selected-Player Games and Seasons Tabs by Percentile, and Surface Rank on Hover
type: task
status: in_progress
description: >-
  PercentileMetric already paints every cell on both tabs but neither passes a percentile_key, so
  both render flat. Wire the existing INDIVIDUAL_GAMELOG keys, generate a season-grain family, then
  design how percentile and rank read together and what a hover reveals.
base_uri: user:task/league/add-percentile-shading-to-selected-player-games-and-seasons.md
created_at: '2026-09-06T00:35:05.706Z'
entity_id: 4334ca06-0b87-489a-9725-758746af16b8
observations:
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
owner_identity_uri: █████████████████████████
priority: Medium
public_read: true
relations:
  - relates [[user:repository/active/league/CLAUDE.md]]
  - depends_on [[████████████████████-██████-█████-████████-████-█████████]]
started_at: '2026-09-06T01:41:45.948Z'
tags:
  - user:tag/league-xo-football.md
updated_at: '2026-09-06T05:55:49.478Z'
visibility_analyzed_at: '2026-11-05T23:48:14.711Z'
---

## Problem

The Games and Seasons tabs of the selected-player panel render every metric as flat black text. The percentile shading that makes the players table and the matchup table readable at a glance is absent from both, and a rank column tells the reader where a player placed without telling them how far apart the places are.

The machinery already exists and is unwired on these two surfaces:

- `PercentileMetric` (`app/views/components/percentile-metric/percentile-metric.js`) paints a cell's background from `percentile.p25/p75/min/max` and already renders every cell on both tabs. It shades only when handed a `percentile_key`.
- `PlayerSelectedRow` accepts `percentile_key`, `percentiles` and `load_percentiles` and fires the load in an effect. **Neither `selected-player-gamelogs.js` nor `selected-player-season-stats.js` passes any of the three**, which is the whole reason both tabs are unshaded. `selected-player-matchup-table.js` does pass them and shades correctly — copy that wiring.
- The API is live: `GET /percentiles/:percentile_key` (`api/routes/percentiles.mjs`) projects `percentile_25 as p25 … maximum_value as max`, so the payload is already in the shape the component reads. The route learned that projection after a column rename silently broke it; keep the aliasing.
- `percentiles` holds **126 keys**, including `INDIVIDUAL_GAMELOG_QB/RB/WR/TE/K/DST` at 83-86 fields each. **The Games tab needs no new data at all** — `INDIVIDUAL_GAMELOG_${position}` is exactly its grain.
- There is **no season-grain individual key**. The Seasons tab needs an `INDIVIDUAL_SEASON_${position}` family generated into the same table, and it should be built after (or alongside) the season-grain columns from [[████████████████████-██████-█████-████████-████-█████████]], since shading a column that renders `-` buys nothing.

## Deliverable

Both tabs shade every metric against its position's peer distribution, and a reader can get the exact percentile and rank for any cell on hover without the resting page gaining any ink.

## Plan

- [x] **Wire the Games tab first — it is pure wiring and needs no new data.** Connect `selected-player-gamelogs` to `percentiles` and `load_percentiles` the way `selected-player-matchup-table/index.js` does, and pass `percentile_key={`INDIVIDUAL_GAMELOG_${position}`}` down to each `PlayerSelectedRow`. Ship this alone and look at it before building anything else; it is the cheapest honest read on whether the shading helps or turns the table into a heat map nobody can scan.
- [ ] **Generate the season-grain percentile keys.** Add `INDIVIDUAL_SEASON_${position}` rows to `percentiles` over the same field set the Seasons tab renders, following whatever script produces the `INDIVIDUAL_GAMELOG_*` rows today. Season and game distributions are different shapes — a season total is not a scaled-up game — so the key must be computed over season rows, never derived from the gamelog key.
- [ ] **Wire the Seasons tab** the same way, then re-look at both together.
- [ ] **Then design the presentation, and treat this as the real work rather than the tail of it.** Read STYLE.md first. Open questions to settle with the operator against real screenshots, not in the abstract:
   - Whether the current blue/red ramp is right for a table this dense, or whether shading should be reserved for the tails.
   - How rank and percentile coexist now that rank columns are narrow and muted (see the 2026-09-05 change to `player-selected-row`); shading the rank cell on the same ramp as its value may double-encode the same fact.
   - What a hover surfaces — the operator asked for the percentile and the rank to be explicitly available on hover. The row already repaints on `:hover` (`$yellowColor`), so a per-cell affordance has to survive that.
- [ ] **Build the hover surface.** Exact percentile, the rank, and enough of the distribution to place the value. Keep it out of the resting page and off the tab order's critical path; check what it does at phone width, where the table scrolls horizontally.

## Measured 2026-09-05, and it changes the plan

The wiring shipped (`39d02d3da`) and the tier scheme shipped (`45d5daba3`). What
the wiring exposed is that the DATA under it is worse than the survey assumed.

- **`INDIVIDUAL_GAMELOG_*` has no producer.** Nothing in the repo writes it.
  `scripts/generate-nfl-team-seasonlogs.mjs` is the only writer of `percentiles`
  and it emits team `{POS}_AGAINST_*` keys only. The rows are orphans of a
  retired script.
- **The stored rows are one PARTIAL season, not full history.** For
  `INDIVIDUAL_GAMELOG_WR.receiving_yards` the stored `min`/`max` is `-8`/`185`,
  which matches 2025 REG cumulative through weeks 11-15 exactly and matches no
  other year and not the full corpus (whose max is 269). Stored `p75` is 42
  against a raw 31, so the retired script also applied a usage filter nobody can
  now read.
- **Coverage is bad, and worst where the stats are most interesting.** Rendered
  fields with no percentile row: QB 27 of 45 (including `pass_epa_per_dropback`
  and the whole passing-efficiency and passing-usage blocks), RB 11 of 45, WR 7
  of 31, TE 7 of 31, DST 4 of 18, K 3 of 10.
- **The fantasy points columns cannot be keyed as they stand.** `points` is
  scoring-format-scoped and `points_added_earned`/`points_added_net` are
  league-format-scoped (`libs-server/attach-format-gamelog-columns.mjs`), while
  `percentiles` has no format dimension. A single `points` row would be correct
  for exactly one format. Resolve before generating: suffix the key with the
  format id, or widen the table.

The producer was built (`scripts/generate-nfl-player-gamelog-percentiles.mjs`,
committed `b43dce881` 2026-09-06). It computes over `player_gamelogs` LEFT JOIN
the passing/rushing/receiving gamelog tables — the same shape
`api/routes/players.mjs` `/:pid/gamelogs` serves — so coverage is complete by
construction rather than by a hand-maintained list. Dry-run is validated
(2026-09-06): 1475 inserts across 474 keys; WR `receiving_yards` max 329 matches
the whole corpus; QB `pass_epa_per_dropback` now present; format-suffixed fantasy
keys (`INDIVIDUAL_GAMELOG_<POS>_<scoring|league_format_id>`) generate, including
league 1's `genesis` and `genesis_10_team`. The legacy partial-season writer
block was removed from `generate-nfl-team-seasonlogs.mjs`, and the League Record
now carries `scoring_format_id`/`league_format_id` so the SPA can build the
suffixed keys. Games tab fantasy and snaps cells now shade through
`PercentileMetric`; the quarter-snap private blue ramp is gone.

One data-shape finding is NOT yet resolved: for league-format keys, kicker
`points_added_net` percentiles drop out of `format_percentile_inserts`
(non-finite), so `INDIVIDUAL_GAMELOG_K_<league_format_id>` carries 1 field while
other positions carry 2. Decide whether that is a real distribution worth
understanding or an acceptable unshaded cell before finalizing.

## Settled by the operator, 2026-09-05

These are rulings, not open questions. Do not re-litigate them.

- **The population is the whole gamelog corpus.** Not a single season, not a
  recent-N window. No usage floor was asked for; if one turns out to be needed to
  keep two-snap cameos from dragging p25 to zero, surface it as a finding with
  the measurement attached rather than applying one quietly.
- **The fantasy-points keys carry the format id as a suffix.** No DDL — the
  `percentiles` table keeps its shape and the key becomes
  `INDIVIDUAL_GAMELOG_<POS>_<format_id>` for the format-scoped fields. `points`
  is scoring-format-scoped, `points_added_earned` and `points_added_net` are
  league-format-scoped, so the two families take different ids; the SPA already
  holds both on the league.
- **The snaps columns and the fantasy points columns are both in scope** and
  must shade like everything else. The fantasy three are already wired through
  `PercentileMetric` and render unshaded pending data. The snaps group is still
  plain divs in `selected-player-gamelogs.js` and needs the same treatment,
  including the quarter-snap cells, which currently paint their own ad-hoc blue
  from `snap_percentage` and should not keep a private ramp once tiers exist.
- **Cell styling is open for a second pass.** The tier ladder itself is settled;
  how a cell PRESENTS it is not. Read STYLE.md, work against real screenshots,
  and bring options rather than a single answer.

## Traps

- `document.fonts.check()`, `scrollWidth > clientWidth` on a parent, and a source read of a stylus mixin all report success when they cannot see the failure. The oracle is the emitted CSS plus a screenshot — STYLE.md § Verifying a Style Change.
- `percentiles` is an Immutable `Map` on this path and a plain object on another; `PercentileMetric` branches on `Map.isMap`. Match the reducer that wrote the slice.
- A percentile key that does not exist returns an empty array, not an error, and every cell then renders unshaded — which is indistinguishable from "this position has no spread". Prove a new key is live by asserting a row count on it before trusting the page.
