---
title: Add an odds timeline to the standings page
type: task
status: completed
description: >-
  A FiveThirtyEight-style standings section charting every team's daily playoff and championship
  odds across a season, marking only big swings with their likely cause (trade, roster move, result,
  injury) and offering controls to show and filter more.
base_uri: user:task/league/add-playoff-odds-trend-to-standings.md
created_at: '2026-10-01T03:01:23.854Z'
entity_id: b0c7a08e-5d64-437f-825a-2bcd86f208f4
finished_at: '2026-10-01T06:07:57.841Z'
observations:
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
owner_identity_uri: █████████████████████████
priority: Medium
public_read: true
relations:
  - relates [[user:task/league/scope-the-team-forecast-join-to-one-league-and-season.md]]
  - relates [[user:task/league/add-trade-analysis-record-and-odds-deltas.md]]
  - relates [[█████████████████████████-██████-████████-████-██-█████-██-███████]]
  - relates [[user:task/league/extract-shared-standings-row-component.md]]
  - relates [[user:task/league/apply-known-absences-to-future-weeks-in-season-forecast.md]]
  - relates [[user:task/league/restore-the-forecast-leverage-columns.md]]
started_at: '2026-10-01T04:32:14.243Z'
tags:
  - user:tag/league-xo-football.md
  - user:tag/software-task.md
updated_at: '2026-11-06T00:03:14.596Z'
visibility_analyzed_at: '2026-11-06T00:17:38.613Z'
---

# Implementation Plan: Odds Timeline on the Standings Page

## Overview

- Add a section to `/leagues/:lid/standings` that charts every team's daily playoff odds (toggle: championship odds) across a season, in a FiveThirtyEight-inspired presentation.
- Annotate only the big moves, each with its likely cause (own trade, rival trade, roster transaction, matchup result, injury, or no recorded event), and give readers controls to show more events and filter by type and team. Default view is all teams.
- Impact: league members see today only the current odds snapshot; this shows how each team got there and why, for the current season and every past season with forecast history (2021 onward for league 1).
- Ordering: no prerequisites. The new endpoint filters by league itself rather than inheriting the unscoped join owned by [[user:task/league/scope-the-team-forecast-join-to-one-league-and-season.md]]. Simulating a proposed trade's odds deltas stays with [[user:task/league/add-trade-analysis-record-and-odds-deltas.md]]. The forecast's injury lag surfaced here is filed as [[user:task/league/apply-known-absences-to-future-weeks-in-season-forecast.md]]; when it lands, injury swings move earlier on the chart with no change here. [[user:task/league/extract-shared-standings-row-component.md]] also edits `standings.js`; whichever lands second rebases the placement.

## Background

- `████████████████████` keeps one snapshot per team per day (the last hourly run of the day, normally 23:30 Eastern). Odds are `numeric(5,4)` from 0 to 1; `generated_at` is timestamptz; `day` is the Eastern day of year; `week` is varchar, `'0'` in the preseason, and flips on the Tuesday 23:30 snapshot. Unique key `(tid, season_year, week, day)` has no `lid`. See [[███████████████████████████████-████████████-████-███████████]].
- League 1 history: daily rows for 2021 through 2026. 2026 rows through 2026-08-30 come from the broken model (pinned 0 and 1); real odds start 2026-08-31. Gaps: 2026-09-13 and 09-14; about ten December days in 2025. Preseason (`week = '0'`) coverage is uneven: 2023 has none, and the last week-0 rows of 2022 and 2025 are in July, two months before week 1.
- Forecast rows end at week 14 or 15 for 2021 to 2024 and run to week 17 in 2025.
- The only reader is `api/routes/leagues/teams.mjs` (latest snapshot per team, no `lid` filter). No route serves the history.
- Weekly results reach the odds in the Tuesday snapshot for 2024 and 2025; in 2026 they arrive staged (Friday, Monday, Tuesday) because the model uses live results.
- Move sizes (playoff points, regular season): median 0.2 to 1.5; noise floor on no-event days p95 2 to 3, max 5.6. Championship noise p95 1 to 3.5. A team averages 2 to 5 playoff moves of 10 or more points a season. Reproduce: [[user:text/league/season-forecast-modeling-contract.md]] § Reading the snapshot history.
- Causes of big mid-week playoff moves: own roster move about 43 percent, any league trade about 25 percent (rival trades move odds as much as own ones), no recorded event about 25 percent (projection refreshes, rival waivers). Reproduce: [[user:text/league/season-forecast-modeling-contract.md]] § Reading the snapshot history.
- Injuries explain about 7 percent of big moves after correcting for chance, and only with a lag of two to four snapshots: the forecast never reads injury status, only vendor projections that refresh overnight (mostly Wednesday). Reproduce: [[user:text/league/season-forecast-modeling-contract.md]] § Reading the snapshot history.
- Large one-day moves with no event that reverse the next day exist (2025 team 12, +55 on 09-28); they are projection glitches, hence the reversal rule.
- Event sources (verified 2026-10-01):
  - `transactions` (`occurred_at`, `tid`, `lid`, `type`; type codes in `libs-shared/constants/transaction-constants.mjs`).
  - `trades` (`propose_tid`, `accept_tid`, `accepted`, `vetoed`, `cancelled`, `rejected`); `trades_players.tid` and `trades_picks.tid` are the team GIVING the asset. League 1 has no vetoed trades.
  - `matchups` holds regular-season weeks only (2025: weeks 1 to 14). Playoff weeks live in `playoffs` (`tid`, `week`, `playoff_week_number`, `points`), one row per team, no opponent.
  - `players_status` (append-only, `observed_at`, no team). Out, Doubtful, IR, PUP and Sus all appear in `source_injury_status`; `game_designation` is blank on IR and `roster_status` holds only ACTIVE/INACTIVE. Before the 2026-09-25 importer fix a cleared designation was never recorded.
  - `player_game_injury_events` (2024 onward only; values `LEFT_QUESTIONABLE`, `LEFT_DOUBTFUL`, `RULED_OUT`, `RETURNED`; no wall-clock time).
  - `rosters_players` (team per week). `league_format_player_projection_values.projected_points_added_net`, keyed `(pid, league_format_id, season_year, week)`, reached through `seasons.league_format_id`.
  - `nfl_games.kickoff_at`: complete 2021 to 2025; 2026 is missing 24 future flex games. No end time in any season.
  - `seasons.wildcard_round` and `seasons.championship_round` (array); `libs-server/get-season-playoff-weeks.mjs` derives the playoff weeks.
  - `teams` per season: `name`, `abbrv`, `primary_color` and `accent_color` as `varchar(6)` without `#`, sometimes null (league 1: two teams in 2021 to 2023, one since).
- Front end: Highcharts 12.6 via `@core/highcharts` (Plex Mono themed globally); no repo code uses annotations, step lines or `states.inactive`. Best pattern is `app/views/components/selected-player-markets/market-event-chart.js` with its JSX-free sibling `market-event-presentation.js`. The existing `probability-leverage-chart` breaks STYLE.md and is not a model. The standings page is built from white `.section` panels under a `.section__band`, all sharing one right edge (`standings.styl`).
- Research: the move, attribution and injury findings and their reproducing SQL are in [[user:text/league/season-forecast-modeling-contract.md]] § Reading the snapshot history; the working files were retired 2026-10-01.

### Assumptions

- One snapshot per team per day with the Tuesday week-label flip — checked (SQL in [[user:text/league/season-forecast-modeling-contract.md]]).
- Noise floor as stated above — checked; SQL in [[user:text/league/season-forecast-modeling-contract.md]].
- Injury lag of two to four snapshots — checked, 127 big moves pooled, so about plus or minus 5 points on the 7 percent.
- 2026's series start (the first snapshot in the 7 days before week 1's first kickoff) falls after the 2026-08-30 model fix — checked 2026-10-01: real odds from 2026-08-31, week-0 rows daily through 2026-09-08. Re-confirmed against the endpoint in the endpoint item.
- The injury loader is fast enough for a cold request under 500 ms — checked 2026-10-01: EXPLAIN ANALYZE on lid 1, 2025 ran 136 ms with existing indexes (`players_status_pid_observed_at_key`, `idx_rosters_players_season_year_week_lid_pid`); snapshot query 1.6 ms. Re-measured on the real loader in the endpoint item.
- `████████████████████` is not in the test reset list and is adjudicated script-only — checked (`db/gates/league-fixture-reset-adjudications.json`).
- `yarn dev:live` cannot show an undeployed route (it calls the production API), so pre-deploy visual checks use `yarn dev:smoke` (working-tree API on 127.0.0.1:8082 over a read-only production tunnel), per [[███████████████████████████████-█████████]].

## Design

- **Endpoint** `GET /api/leagues/:leagueId/forecast-history?year=` in `api/routes/leagues/forecast-history.mjs`, mounted in `api/routes/leagues/index.mjs`, public. Modelled on `teams.mjs`/`matchups.mjs` (not `team-daily-values.mjs`, which predates the conventions): `validate_and_get_league(leagueId, res)`, `year_validator`, `handle_error(err, logger, res)`, a swagger block with a complete 200 schema (colors `nullable`) and `$ref` `BadRequestError` and `InternalServerError`. Returns raw data only:
  - `season`: `season_year`, `weeks` (week number, `started_at` and the distinct `kickoff_times` over REG weeks 1 to 17; unscheduled future games are skipped), `wildcard_round`, `championship_round` (from `seasons`).
  - `teams`: `tid`, `name`, `abbreviation`, `primary_color`, `accent_color` for that season. Kept in the payload rather than read from the teams store, so the section does not depend on which year the store last loaded.
  - `snapshots`: `tid`, `generated_at`, `week`, `playoff_odds`, `championship_odds`. The series starts at the first snapshot within 7 days before week 1's first kickoff; this skips stale July baselines (2022, 2025), handles 2023 (no week-0 rows) and excludes the broken pre-fix 2026 rows without a date constant.
  - `events`: `event_type` in the closed set `trade`, `roster_transaction`, `matchup_result`, `player_injury`, each with `occurred_at`, team id(s), and display fields:
    - `trade`: accepted and not vetoed, cancelled or rejected; `occurred_at` is `accepted`; `propose_tid`, `accept_tid`, and per side the players and picks that team sends.
    - `roster_transaction`: transaction types Roster Add, Practice Add, Super Priority, Poached, Release, Reserve IR, Reserve IR Long-Term, Reserve COVID, Auction Win; within the snapshot range. Trade-generated types, draft, tags, extensions, activate/deactivate and practice protection are excluded.
    - `matchup_result`: one per team-week from `matchups` (regular season, with opponent and score) and `playoffs` (playoff weeks, points only; eliminated when the team is absent from the next playoff week). `occurred_at` is the week's last kickoff.
    - `player_injury`: an onset of `source_injury_status` in Out, Doubtful, IR, PUP or Sus, where an onset is a row in that set whose previous row for the player is outside it or more than 36 hours earlier (moving within the set is not a new event; suspension is included deliberately). Plus 2024-onward in-game exits: `RULED_OUT`, or a `LEFT_*` with no later `RETURNED`, timed at the game's kickoff. The onset maps to the fantasy week of the next snapshot's `week` label; the player must be on the team's roster that week with `projected_points_added_net` of at least 3 in any of weeks max(1, w−4) to w (the −999 sentinel filtered).
  - Cached in `api/cache.mjs` under `/leagues/${lid}/forecast-history/${year}`: no expiry for past seasons, 600 s for the current one (the forecast writer is an offline script with no invalidation hook).
- **Swing detection** in `libs-shared/forecast-swings.mjs`, run in the browser so control changes need no request; the app imports it by subpath (`#libs-shared/forecast-swings.mjs`). Thresholds are the module's exported `forecast_swing_thresholds`, the default of the `thresholds` parameter.
  - `detect_forecast_swings({ snapshots, odds_field, thresholds })`, per team:
    - A step across a gap over 48 hours breaks the series; it is never part of a swing.
    - Split the series into maximal same-direction runs; a step under 0.5 points is neutral (does not break a run, counts toward the window).
    - In each run take the window of at most 4 steps with the largest absolute cumulative change; emit it if it crosses the threshold (playoff: 10 points; championship: 3 points and at least 30 percent of the odds at the snapshot before the window). Repeat on the run's remaining left and right parts, so swings never overlap.
    - Reversal: drop a swing when the next 1 to 2 steps move the opposite way by at least 75 percent of its size and neither span contains a trade or a result step; the reversing leg is dropped with it.
    - A window starts and ends on a non-neutral step, and steps are rounded to the odds' 0.01-point precision before any threshold comparison.
  - `attribute_forecast_swings({ swings, events, snapshots, weeks })`:
    - An event belongs to a swing when `occurred_at` is after the snapshot before the window and at or before the window's last snapshot.
    - A result credits a swing only when the swing's largest step ends on a result step for a week the team has a result in: the week-flip snapshot (Tuesday era) or the first snapshot at least 4 hours after one of that week's kickoffs (staged live-results era). The original Thursday-to-Tuesday window credited 81 percent of swings to results in the population check, because it covers most of every week.
    - An injury of the team's own player credits that team's drops whose first step falls up to 4 snapshots after the onset.
    - Cause priority: `own_trade`, `matchup_result`, `rival_trade`, `roster_transaction`, `player_injury`; otherwise `no_recorded_event` (labelled "No roster move — projection update"). Among several rival trades, credit the one nearest before the window's largest single step.
    - A result explains only a move in its own direction: a win or an advance a gain, a loss or an elimination a drop (a loss had explained a 55-point gain in the 2026 render).
    - Each swing carries its `cause`, the crediting event, and every event in its window; the detail lists them all (so a result swing also names an injured player) without a compound cause value.
- **Front end**:
  - Data: `app/core/league-forecast-history/` (`actions.js`, `sagas.js`, `reducer.js` keyed by league and year, `selectors.js`, `forecast-history-request-key.js`, `index.js`). Dedupe follows the current pattern (`app/core/matchups/matchup-history-requests.js`): request key written on PENDING, deleted on FAILED and CANCELLED in `app/core/api/reducer.js`; `year` passed in the load payload; `create_api_actions` and `create_api_action_types` exported; `['GET_LEAGUE_FORECAST_HISTORY', ['leagueId', 'year']]` registered in `tracked_load_state_key_fields`; watcher in `SUPERVISED_WATCHERS` as `{ label: 'standings odds timeline', ... }`.
  - Component `app/views/components/league-odds-timeline/`: `league-odds-timeline.js` (section, controls row inline, state: odds type, event level `top`/`more`/`all` showing 5/15/every swing, event types, highlighted team, selected team), `odds-timeline-chart.js`, `odds-timeline-swings-list.js`, `odds-timeline-presentation.mjs` (no JSX, no `document`, no webpack aliases: label layout and collision relaxation, contrast-checked line color with color normalisation, cause sentences, signed deltas with U+2212), `league-odds-timeline.styl`, `index.js`.
  - Placement: `app/views/pages/standings/standings.js` after the division tables and before the leverage chart (which renders only for the current year); a `.section` with `.section__head`/`.section-header-title`, inheriting the page's shared right edge. Skeleton while the request is pending; hidden only when loaded and empty.
  - Colors: the prose tokens (`--prose-ink`, `--prose-body`, `--prose-muted`, `--prose-rule`, `--prose-rule-strong`, `--prose-paper`, `--prose-accent`) published on `:root` in `app/styles/index.styl`, where the `$prose_*` variables resolve and the rule is emitted once. `odds-timeline-chart.js` reads them with `getComputedStyle` (precedent: `libs-shared/position-colors.mjs`) and passes them into the pure functions, so no hex lives in JS.
- **Visual spec** (FiveThirtyEight look on app tokens, drawn on the section's white paper):
  - No plot panel; hairline `$prose_rule` gridlines at 0/25/50/75/100; a 1px `$prose_ink` reference line at 50 percent (championship mode: the even share, 1 over the team count); axis labels Plex Mono 11px `$prose_muted`; no axis line or ticks.
  - Week boundaries as hairline x-axis ticks labelled `WK 1` at the bottom, thinned to fit the axis length; a 1px ink `PLAYOFFS` rule at the wildcard round with an Inter 700 11px uppercase label, left of the rule when it sits in the last quarter.
  - Context lines: step lines in `$prose_rule_strong` at 1.25px, `states.inactive.enabled: false`. Highlighted team: 2.5px in its color over a 6px paper-colored underlay; color is primary if it reaches 3:1 on `$prose_paper`, else accent if it does, else ink. Text never takes team color.
  - End labels: team code Plex Mono 11px 600 plus percent (whole numbers, "<1%" below 1), muted at rest and ink when highlighted, paper-colored text outline, collision relaxation with a 13px minimum gap and hairline leaders, sub-1 percent teams merged.
  - Markers: r 3.5 dots with a 2px paper halo, `$prose_body` at rest and team color when highlighted, hover r 5 with an ink ring (never the accent inside the plot). The top three swings get an unboxed two-line label with a 1px muted leader ("Traded for X" in Inter 12px ink; "KYL +18" in mono), above for gains and below for losses; colliding labels drop and keep their marker. Labels are drawn with `chart.renderer` at coordinates the presentation module computes, not the annotations module.
  - Detail: dotted ink crosshair; `useHTML` tooltip on paper with a 1px ink top rule, no border, radius or shadow; figures via `toPercent`.
  - Controls: text rows `PLAYOFF · CHAMPIONSHIP │ TOP · MORE · ALL │ TRADES · MOVES · RESULTS · INJURIES`, then `ALL TEAMS · <team codes>` (wraps); Inter 11px 600 uppercase, muted at rest, ink and 700 when selected with no underline and `cursor default`, accent only on hover and `:focus-visible` (2px outline, 3px offset), copying `.news__band-mode`; radio-group and `aria-pressed` semantics. No `<select>`: it cannot sit in an 11px text row and iOS zooms below 16px.
  - Swings list: numbered `<ol>` in its own block `.odds-timeline-swings__row` (Inter 13px `$prose_body` sentence, `$prose_mono` 11px figures, `$prose_rule` hairline between rows; the league-home serif is scoped to the league edition by operator ruling, so it is not extended here); several roster moves of one kind read "Won X at auction and 13 more"; beside the plot in a 280 to 360px rail when the section's own width (container query) is 960px or more, below otherwise; hover links list and chart both ways.
  - Under 640px section width: plot 260px tall, end labels codes only, alternate week labels, big-move labels become numbered dots keyed to the list, detail docks below the plot.
  - Animation off; color transitions 120ms in CSS.
- **Ruling recorded in STYLE.md** (operator decision 2026-10-01): team color is data encoding allowed only on the highlighted team's line, end dot and markers, never on text, on the same footing as position colors. `league-teams-value-over-time` (every team line in its primary color, league home) predates the ruling and is named there as non-conforming.
- **Not an orchestrator**: bounded, single task.
- **Working tier**: `scratch/league/odds-timeline/` held the research SQL, inspiration captures, check scripts and verify screenshots; retired 2026-10-01 after its threshold rationale was promoted to the forecast contract.
- **Knowledge Base Impact**: `text/league/route-module-map.md` (new route and store on the `/standings` row); `text/league/season-forecast-modeling-contract.md` (a short section on reading the snapshot history, linking the trap sheet rather than restating it); repo `STYLE.md` (team-color ruling; stale segment-mapping sentence). No new docs, tags or ABOUT.md changes.

## Notes

- Inspiration: Google Finance key moments (halo dots, detail on click), cfbfastR win-probability labels (event over team and delta), FiveThirtyEight NFL history (gray field, one highlighted team, picker), FanGraphs playoff odds (end labels), Datawrapper (numbered annotations on phones). Captures in `scratch/league/odds-timeline/inspiration/`.
- "No roster move — projection update" is honest but not proven; the label says what is known (no recorded event) and the likely reason.
- Championship swings after the regular season are mostly results; only 2025 has playoff-week snapshots.
- Alternative views (small multiples per team, a rank chart against the playoff line, a one-week odds-change column in the standings table) are deliberately out of scope.
- `probability-leverage-chart` renders 0 percent ranges because the leverage columns are no longer written; owned by [[user:task/league/restore-the-forecast-leverage-columns.md]], which may retire the chart. The placement here does not depend on it.
- Commands: specs run as `yarn test:isolated test/<file>.spec.mjs` (never bare `yarn test`); a pure spec run alone ends with one expected root `afterAll` zero-pairs failure, so read the passing count, not the exit code. Endpoint specs clear `api/cache.mjs` in `beforeEach`, use distinct tids per league, and delete the `nfl_games`, `players_status` and injury rows they insert.
- Merged to league master as `aa1158630` and deployed 2026-10-01.
- Full suite on the branch 2026-10-01: 14,713 passing, 1 failing (the worktree-only SPA route control, no `dist/`).

## Plan

### Data and Endpoint

- [x] **Shared swing module and spec**
  - Files: `repository/active/league/libs-shared/forecast-swings.mjs`; `test/libs-shared.forecast-swings.spec.mjs`
  - Changes: `detect_forecast_swings`, `attribute_forecast_swings` and `forecast_swing_thresholds` per Design.
  - Scenarios: a single-day trade drop (2025 team WLB, −37.5) attributed to its own trade; a five-day injury slide crossing the threshold cumulatively; a next-day reversal dropped; a reversal kept when a trade sits in the window; sub-threshold noise ignored; a step across a 48-hour gap ignored; the championship relative rule (a 2 to 5 point move counts, a 40 to 43 does not); result windows in the Tuesday era and the staged 2026 era; a rival trade ranked below an own result; a result swing listing an injury event in its window.
  - Verify: `yarn test:isolated test/libs-shared.forecast-swings.spec.mjs` passes; for each rule a named mutant turns its scenario red against an unmutated green run.
  - Done: 14 scenarios; mutants for threshold, gap, reversal, glitch leg, injury credit, relative rule, window length, neutral edge, cause priority, result window and result step each turn a scenario red.

- [x] **Forecast history endpoint, fixtures and spec**
  - Files: `api/routes/leagues/forecast-history.mjs`; mount in `api/routes/leagues/index.mjs`; `db/fixtures/reset-league-tables.mjs`; `db/gates/league-fixture-reset-adjudications.json`; `test/api.league-forecast-history.spec.mjs`
  - Changes: queries per Design; swagger; cache; `████████████████████` added to the reset list and its script-only adjudication dropped.
  - Scenarios: seeded snapshots in two leagues return only the requested league's; series start trimming, including a season with no week-0 rows; each event type present; vetoed trades excluded; invalid league and year return 400.
  - Done: 5 cases pass; mutants on the league filter, series start and vetoed filter go red; fixture gate red with the adjudication present and green without; injury loader EXPLAIN ANALYZE 127 ms (436 ms before materialising snapshot times); cold tunnelled requests 290 to 330 ms; 2026 starts 2026-09-03 with spread odds.
  - Verify: the spec passes; `node db/gates/check-league-fixture-reset-coverage.mjs` red with the adjudication present, green without it; under `yarn dev:smoke`, `curl -s 'http://127.0.0.1:8082/api/leagues/1/forecast-history?year=2025'` shows about 1,100 snapshots and all four event types; the first 2026 snapshot's odds are spread (not 0.5, 0 or 1); EXPLAIN ANALYZE on the injury loader and cold request time under 500 ms recorded in the commit message.

### Front End

- [x] **Style tokens as custom properties**
  - File: `repository/active/league/app/styles/index.styl`
  - Changes: publish the prose tokens listed in Design on `:root`.
  - Verify: in the dev:smoke browser `getComputedStyle(document.documentElement).getPropertyValue('--prose-ink')` returns the hex, with `--pos-QB` as the control; the STYLE.md stray-token regex `/:[^;{}]*\$\s*[a-z_]+/i` finds nothing in the served CSS and does find a deliberate leak.

- [x] **Forecast history store**
  - Files: `app/core/league-forecast-history/*`; wiring in `app/core/api/{service,sagas,index,reducer}.js`, `app/core/api/load-state.mjs`, `app/core/reducers.js`, `app/core/sagas.js`; `test/app.league-forecast-history-requests.spec.mjs`
  - Changes: per Design.
  - Verify: the request-key spec passes; in a dev:smoke Playwright run switching 2025, 2026, 2025 makes exactly 2 `/forecast-history` requests.

- [x] **Presentation module**
  - File: `app/views/components/league-odds-timeline/odds-timeline-presentation.mjs`; `test/app.odds-timeline-presentation.spec.mjs`
  - Changes: end-label and big-move-label layout with collision relaxation; contrast-checked line color (normalising stored colors, null treated as missing); cause sentences and signed deltas with U+2212.
  - Verify: the spec passes (league 1's yellow and missing colors fall back to ink; overlapping labels separate by at least 13px).

- [x] **Chart, swings list and section**
  - Files: `league-odds-timeline.js`, `odds-timeline-chart.js`, `odds-timeline-swings-list.js`, `league-odds-timeline.styl`, `index.js` in `app/views/components/league-odds-timeline/`
  - Changes: the visual spec in Design; no new `@mui` imports; icons from `@components/icon`; named z-index tokens only.
  - Verify: `test/app.mui-import-ratchet.spec.mjs`, `yarn lint`, `yarn check:z-index-scale` and knip pass.

- [x] **Standings page placement**
  - Files: `app/views/pages/standings/standings.js`, `standings.styl`
  - Changes: render the section after the division tables and before the leverage chart; skeleton while pending; hidden when loaded and empty.
  - Verify: under dev:smoke, `/leagues/1/standings` shows the section for 2021 through 2026 and not for 2020.

### Knowledge Base Updates

- [x] **Route module map**
  - File: `text/league/route-module-map.md`
  - Changes: add `league-forecast-history/` and `leagues/forecast-history.mjs` to the `/standings` row.
  - Verify: (no verify — mechanical step)

- [x] **Forecast contract: reading the snapshot history**
  - File: `text/league/season-forecast-modeling-contract.md`
  - Changes: a short section on the noise floor, when results and injuries reach the odds, and why the swing thresholds sit where they do; links the trap sheet; promotion target for the scratch research.
  - Verify: the section cites the reproducing SQL inline, so it survives scratch retirement.

- [x] **STYLE.md team-color ruling**
  - File: `repository/active/league/STYLE.md`
  - Changes: record the operator's ruling with its date and the one non-conforming chart; correct the stale "segments cannot be mapped" sentence (the props spec exempts `key`) in the same commit.
  - Verify: `grep -n "cannot be MAPPED" STYLE.md` returns nothing; the ruling names `league-teams-value-over-time`.

### Testing and Validation

- [x] **Population check across seasons**
  - Script: `scratch/league/odds-timeline/population-check.mjs` fetches `http://127.0.0.1:8082/api/leagues/1/forecast-history?year=` under dev:smoke for 2021 to 2026, imports `repository/active/league/libs-shared/forecast-swings.mjs` by absolute path, runs both functions, and prints swing counts per team-season and the share of each cause.
  - FAIL (the failure modes): a season mean under 0.5 or over 30 swings per team; over 70 percent of non-result swings with no recorded event; a season with no trade or no result cause.
  - WARN (the planning calibration): mean 1 to 8 per team-season, none above 15, `no_recorded_event` 10 to 40 percent of non-result swings. Set from 2024 to 2026 noise; the 2021 and 2022 model is about twice as noisy (quiet-day p95 13 to 14 points against 7 to 8), so those seasons carry more swings under the operator's fixed thresholds.
  - Failure modes caught: odds read on the wrong scale (zero or hundreds of swings), a broken attribution window (nearly all `no_recorded_event`), a season-specific loader gap.
  - Baseline: the daily move distribution SQL in [[user:text/league/season-forecast-modeling-contract.md]] (10-point one-day moves: 50, 31, 17 for 2024, 2025, 2026).
  - Verify: output recorded on the task as an observation.

- [x] **Visual and style verification**
  - Screenshots under dev:smoke at 1280px and 390px for 2025 and 2026, including hover highlight, a selected team, `ALL` events, and the phone numbered-label mode; compared with the FiveThirtyEight and cfbfastR captures.
  - Verify: screenshots saved under `scratch/league/odds-timeline/verify/` and reviewed against the visual spec item by item; `git status -- config/` clean after stopping dev:smoke.
  - Done: 14 captures (2025 and 2026; 1280px, 1100px and 390px; championship, ALL, a selected team, hover, phone tap). Found and fixed: missing swing dots, a gain credited to a loss, `#fff` from compiled CSS defeating the contrast check (every team fell back to ink), overlapping week labels, the PLAYOFFS label colliding with end labels on a phone. The section shows for 2021 to 2026 and not 2020; switching 2026, 2025, 2026, 2025 makes two requests.

### Close

- [x] **Hand the deploy to the operator**
  - Deploys are human-gated: the operator runs `yarn deploy:all` (backend and bundle together).
  - Verify: after deploy, `curl https://xo.football/api/leagues/1/forecast-history?year=2025` returns snapshots, and a `yarn dev:live` screenshot matches the dev:smoke one.
  - Done 2026-10-01 on operator instruction: `DEPLOY_ACK_RANGE=10 yarn deploy:all` from a worktree pinned to `aa1158630` (range cleared with ████████████████████████████ for `bffa80afb`). Both hosts (`league` and the second API host) and the bundle manifest at `aa1158630`; served bundle on disk; live endpoint 1,100 snapshots for 2025 with all four event types; production standings rendered at 1280px and 390px; the sibling's Betting Markets check passed.

- [x] **Retire the scratch slug**
  - Run [[user:workflow/knowledge-base/retire-scratch-domain.md]] on `scratch/league/odds-timeline/` after the contract section and both verifications have landed; repoint this plan's scratch references to the contract section.
  - Verify: `ls scratch/league/odds-timeline` fails and the ABOUT.md deletion is committed.
  - Done 2026-10-01: research promoted to the forecast contract, 81 working files deleted, ABOUT deleted (`scratch: retire league/odds-timeline`).
