---
title: Re-key the client plays store on (season_year, week)
type: task
status: completed
description: >-
  Re-key the league client plays store on (season_year, week) instead of week alone, so past-season
  plays stop colliding into current-week buckets.
base_uri: user:task/league/rekey-plays-store-on-season-and-week.md
created_at: '2026-09-21T13:18:22.706Z'
entity_id: 09a25a10-8e17-4bdf-bc4e-1d72604765a2
finished_at: '2026-09-21T15:46:18.245Z'
owner_identity_uri: █████████████████████████
priority: Medium
public_read: true
relations:
  - relates [[user:task/league/keep-the-live-scoreboard-updating.md]]
tags:
  - user:tag/league-xo-football.md
  - user:tag/software-task.md
updated_at: '2026-09-21T15:46:18.245Z'
visibility_analyzed_at: '2026-11-05T23:50:00.022Z'
---

## Overview

The client plays store is keyed on **week alone**, so plays from different seasons land in the same `plays[week]` bucket. Browsing a past-season matchup can therefore render prior-year plays on the current week, and the collision reaches every `get_plays` consumer — the scoreboard UI, gamelogs, and the player Games tab — not just the live scoreboard.

## Background

Surfaced while scoping the live-scoreboard watermark on [[user:task/league/keep-the-live-scoreboard-updating.md]], and deliberately left out of that lane. `4a9d5538f` filtered `season_year` inside `derive_scoreboard_watermark` so the watermark itself is correct independently, but that fix is scoped to the watermark and does not re-key the store.

The reason the watermark needed it is the reason the store needs it: a backfill re-enriching an old season stamps `updated` with today's instant, so a prior-season play is not safely older than a live one.

### Assumptions

- The store's bucket key is the only thing conflating the two seasons; the rows themselves carry `season_year`. Verify before designing — if any producer drops the year, that producer is part of the work.
- The orphan-playStat branch in `@core/plays/reducer` mints a play from a stat whose play has not arrived and **cannot supply a year**, because `/plays/stats` adds only `week` to play-stat rows. Any re-key has to decide what that branch does rather than assume it can be keyed.

## Design

Re-key the store on `(season_year, week)`. Touches four selectors, the reducer, and `loadPlays`'s request keys.

## Plan

### Establish the blast radius

- Enumerate every reader of the plays store from the code that defines it, not from the names of the symptoms — a defect in shared machinery reaches everything routed through it.
- Confirm with a control that a past-season browse actually renders prior-year plays on the current week today. A reproduction, not a reading of the reducer.

### Re-key

- Move the bucket key to `(season_year, week)` across the reducer, the four selectors, and `loadPlays` request keys.
- Rule on the orphan-playStat branch explicitly: either give `/plays/stats` the year, or key the orphan separately and say why.

### Verify

- Red control first: show the failing case fails before the fix and passes after, and that the two readings differ.
- Re-check the watermark path still behaves, since its own `season_year` filter becomes redundant rather than wrong.

## Blast radius, measured 2026-09-21

Enumerated from the code that defines the store, not from the symptoms.

**The bucket key is week-only in both reducer branches** — `app/core/plays/reducer.js:13` (the `GET_PLAYS_FULFILLED` / scoreboard path) and `:41-44` (the `GET_PLAYSTATS_FULFILLED` path). The base read selector carries the same key: `app/core/selectors.js:107-110`.

**Five sites, not four.** The plan's count was right for direct callers but missed the base selector itself:

| Site | Location |
| --- | --- |
| `get_plays` (the key itself) | `app/core/selectors.js:107-110` |
| `get_plays_for_player` | `app/core/selectors.js:1678-1679` |
| `getScoreboardUpdated` | `app/core/selectors.js:2620-2624` |
| `getPlaysByMatchupId` | `app/core/selectors.js:2668,2698` |
| `getGameStatusByPlayerId` | `app/core/selectors.js:2868,2877` |

`getPlaysByMatchupId` is the one that renders a past-season matchup into the current week's bucket — it passes `week: matchup.week` with no year.

**Out of scope, confirmed rather than assumed:** `app/core/plays-view/` holds zero references to `get_plays`, the reducer, or the `plays` state key. Data-view gamelogs are a separate pipeline.

## The orphan-playStat branch — the design question DISSOLVES

The plan treated this as a genuine design fork, on the premise that the branch cannot supply a year. **It can, and the fix is one line.**

- The branch is at `app/core/plays/reducer.js:47-77`; the minted orphan object (`:70-76`) is hand-built from `esbid, play_id, week, updated, playStats`.
- It carries no year only because the route does not ask for one: `api/routes/plays.mjs:362-380` passes `additional_nfl_plays_columns: ['week']`.
- `build_play_stats_query` (`libs-server/play-stats-utils.mjs:48-131`) already supports arbitrary extra columns, and already filters on `nfl_plays.season_year` internally at `:125` — it simply does not project it.

So the ruling is: **give `/plays/stats` the year** by adding `season_year` to that array, and key the orphan exactly like every other play. No separate orphan keyspace, no asymmetry to document. This was an undone step, not a structural constraint.

## Two corrections to the plan's assumptions

**`loadPlays` request keys are ALREADY year-aware — do not "fix" them.** `app/core/plays/sagas.js:22-24` builds `GET_PLAYS_${target_year}_${target_week}` for anything other than the current week and year. The plan lists them as part of the work; they are not. Touching them risks breaking a guard that is already correct.

**A separate year-drop defect, in scope because it is the same bug:** `get_gamelog_for_player` (`app/core/selectors.js:3132-3172`) takes a `year` parameter defaulting to `current_season.year` and then drops it when calling `get_plays_for_player` at `:3172`. The code marks it itself with `// TODO should handle year` at `:3171`. Re-keying the store without this leaves the player Games tab still reading the wrong season through a producer-side omission.

## Revised plan

1. **Red control first.** Reproduce a past-season browse rendering prior-year plays on the current week, through `getPlaysByMatchupId`. A reproduction, not a reading of the reducer.
2. **Give `/plays/stats` the year** — `season_year` into `additional_nfl_plays_columns` at `api/routes/plays.mjs:371`, and into the minted orphan object.
3. **Re-key** the two reducer branches and the base `get_plays` selector to `(season_year, week)`, then thread the year through the four direct callers above.
4. **Fix the `get_gamelog_for_player` year-drop** at `selectors.js:3172` and retire its TODO.
5. **Leave `loadPlays` request keys alone.**
6. **Verify** the red control now passes and the two readings differ; re-check the watermark path, whose own `season_year` filter becomes redundant rather than wrong.
