---
title: Migrate remaining weekly data view fields to single_nfl_week_id
type: task
status: completed
description: >-
  Introduce a single_nfl_week_id param, migrate remaining weekly fields (games_played, rankings, DFS
  salary/ownership, practice, weekly projected market salary, betting game-props, player_nfl_teams,
  league roster status) onto nfl_week_id or single_nfl_week_id, split rankings into season and week
  variants, fix the player_nfl_teams multi-year routing defect, and execute a column-scoped
  saved-view migration.
base_uri: user:task/league/migrate-weekly-fields-to-single-nfl-week-id.md
created_at: '2026-04-22T15:07:06.382Z'
entity_id: e78a853a-e0c8-48a7-8a64-636fd657fe87
finished_at: '2026-04-22T16:18:14.681Z'
observations:
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
owner_identity_uri: █████████████████████████
priority: Medium
public_read: true
relations:
  - succeeds [[user:task/league/improve-nfl-week-selector-ux.md]]
tags:
  - user:tag/league-xo-football.md
updated_at: '2026-06-01T16:54:16.134Z'
visibility_analyzed_at: '2026-11-06T00:17:57.980Z'
---

# Implementation Plan: Migrate Remaining Weekly Fields to `single_nfl_week_id`

## Overview

- Introduce a new `single_nfl_week_id` column param for intrinsically single-week data-view columns, mirroring the `single_year` / `year` pairing that already exists alongside the multi-select `nfl_week_id`
- Migrate every remaining weekly data-view field off separate `year` / `week` / `seas_type` params onto either `nfl_week_id` (multi-week aggregation) or `single_nfl_week_id` (single-week point-in-time), enforcing one consistent selector component and UX across the codebase
- Split the `player_rankings` field family into `player_season_*_ranking` (year-level) and `player_week_*_ranking` (week-level) so each has clean, single-grained semantics
- Fix `player_nfl_teams` to route through the multi-week `nfl_week_id` CTE path so multi-year and multi-week selections actually work (currently silently fall back to `player.current_nfl_team`)
- Execute a column-scoped saved-view migration on production for both `user_data_views.table_state` and `user_plays_views.table_state`, plus an equivalent browser localStorage migration

## Background

**Current state after task `improve-nfl-week-selector-ux` (completed 2026-03-28):**

- `libs-shared/common-column-params.mjs` exports `nfl_week_id` (multi) with `column_name: 'nfl_week_id'`, dynamic values `current_nfl_week`, `last_n_nfl_weeks`, `current_year_reg_weeks`, `last_n_nfl_years`, `next_n_nfl_years`
- `libs-shared/nfl-week-identifier.mjs` provides `get_all_nfl_week_identifiers`, `parse_nfl_week_identifier`, `format_nfl_week_identifier`, `decompose_nfl_weeks`, `apply_year_offset_to_nfl_weeks`
- `app/views/components/column-param-nfl-week-filter/` renders the selector for `nfl_week_id`, wired via `app/core/data-views-fields/index.js`
- `libs-server/data-views/resolve-nfl-week-id-from-year-param.mjs` provides backward compatibility for columns that have `year` but not `nfl_week_id` in saved views
- Six weekly server column definitions already dual-support `nfl_week_id` with year/week fallback: `player-rankings`, `player-dfs-salaries`, `player-practice`, `player-projected`, `game`, `player-dfs-ownership`
- Generated `nfl_week_id` columns exist on `nfl_games`, `nfl_plays`, `projections`, `projections_index`, `player_rankings_index`, `practice`

**Remaining fields still on `year` / `week` / `seas_type`:**

Frontend field files (`app/core/data-views-fields/`):

- `player-games-played-table-fields.js:4-17` — `year`, `week`, `year_offset`, `career_year`
- `player-rankings-table-fields.js:7-74` — `year: single_year`, `week: single_week` (with custom `{value: 0, label: 'Season'}` entry)
- `player-dfs-salaries-table-fields.js:8-28` — `year: single_year`, `week: single_week`, `platform_source_id`, `career_year`, `career_game`
- `practice-table-fields.js:5-17` — `year: single_year`, `single_week`
- `fantasy-league-table-fields.js:35-49` — single field `player_week_projected_market_salary` uses `year: single_year`, `week: single_week`
- `betting-market-table-fields.js:53-74` — game-prop variants use `year`, `week: single_week`, `seas_type: single_seas_type`

Server column definitions still reading `params.year` / `params.week` without `nfl_week_id` support:

- `player-betting-market-column-definitions.mjs` (655 lines, ~40 reference sites; scalar year/week/seas_type throughout)
- `player-team-column-definition.mjs` (destructures `week` but never uses it; multi-year selection silently ignored)
- `data-views-column-definitions/index.mjs:84-107` (`player_league_roster_status` reads `params.year` + `params.week` directly)

**`player_nfl_teams` defect analysis:**

The routing conditional `(year.length === 1 && year[0] !== current_season.year) || splits.length` in `player-team-column-definition.mjs` is wrong for three cases:

1. Multi-year selection falls through to `player.current_nfl_team` even though the user asked for historical teams across multiple seasons
2. Week-specific selection is ignored entirely (`week` is destructured but never referenced)
3. The downstream `rate-type-per-game.mjs` CTE already aggregates via `array_agg(distinct tm)` grouped by `pid` and correctly filters by `nfl_week_id`, so the underlying SQL supports the requested semantics — only the frontend column definition's routing logic and param contract need repair

**`player_dfs_ownership_pct` latent bug:**

`player-dfs-ownership-column-definitions.mjs:61-63` parses `nfl_week_id` with `nwi.split('-')` and reads `parts[2]` for week. The identifier format is underscore-delimited (`2024_REG_WEEK_5`), so `split('-')` returns the entire string as element 0 and `parts[2]` is `undefined`. This is dead code today because no UI surface sends `nfl_week_id` to this column, but it must be fixed before migration routes user input through this path.

**Fantasy roster status:**

`player_league_roster_status` in `index.mjs:84-107` joins `rosters_players` on `year` + `week` + `lid`. Fantasy rosters exist only for REG weeks of a given league year; the table has no `nfl_week_id` column. Migration keeps internal year/week filtering by decomposing the `single_nfl_week_id` param value via `parse_nfl_week_identifier` at the top of the join function.

## Design

### Three param flavors, one convention

| Flavor                    | Param                      | Cardinality    | Used by                                                                                                                                            |
| ------------------------- | -------------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| Season-level              | `single_year` / `year`     | scalar / multi | ADP, KTC, contracts, seasonlogs, season-level projections, season-level rankings, ESPN scores, team DVOA, supplier grades, format logs             |
| Single-week point-in-time | `single_nfl_week_id` (new) | scalar         | DFS salary, DFS ownership, practice designation, weekly rankings, weekly projected market salary, betting game-prop markets, fantasy roster status |
| Multi-week aggregation    | `nfl_week_id`              | multi          | play-by-play stats, games played, player-teams history                                                                                             |

`single_nfl_week_id` accepts PRE, REG, and POST weeks — identical value set to `nfl_week_id`. Season-type coverage is a per-column data reality, not a param-layer restriction. Columns without data for a chosen week return empty, same as picking any week outside a column's coverage window.

### New shared param `single_nfl_week_id`

Added to `libs-shared/common-column-params.mjs`:

```
export const single_nfl_week_id = {
  data_type: table_constants.TABLE_DATA_TYPES.SELECT,
  single: true,
  column_name: 'nfl_week_id',
  label: 'NFL Week',
  values: get_all_nfl_week_identifiers(),
  default_value: { dynamic_type: 'current_nfl_week' },
  format_param_values: format_single_nfl_week_id,
  dynamic_values: [
    { dynamic_type: 'current_nfl_week', label: 'Current NFL Week' }
  ]
}
```

Only `current_nfl_week` is exposed as a dynamic; the other existing dynamics (`last_n_nfl_weeks`, `current_year_reg_weeks`, `last_n_nfl_years`, `next_n_nfl_years`) are multi-valued and do not apply. No grammar changes to `nfl_week_id` identifiers — there is no "season" slot; season-level data uses the `year` family.

### Selector component consolidation

`ColumnParamNflWeekFilter` handles both `nfl_week_id` and `single_nfl_week_id`. When `column_param_definition.single === true`:

- Builder switches to a single-row picker (type then year then week cascading selects)
- Selection summary shows one chip; drag-range interaction is disabled
- Dynamic value row filters to single-compatible entries
- Emits a single-element array `[identifier]` via `handle_change`, matching the existing react-table `column-param-select-filter.js` contract (`single: true` always stores as `[value]`, never a scalar). Persisted state, server `resolve_single_nfl_week_id`, and the DB/localStorage migrations all treat the value as a one-element array.

Same styl file, same mental model for users.

### Server extraction helper

`libs-server/data-views/resolve-single-nfl-week-id.mjs`:

```
import { format_nfl_week_identifier } from '#libs-shared/nfl-week-identifier.mjs'

export default function resolve_single_nfl_week_id({ params = {} } = {}) {
  const single_value = Array.isArray(params.single_nfl_week_id)
    ? params.single_nfl_week_id[0]
    : params.single_nfl_week_id
  if (single_value) return single_value

  const multi_value = Array.isArray(params.nfl_week_id)
    ? params.nfl_week_id[0]
    : params.nfl_week_id
  if (multi_value) return multi_value

  const year = Array.isArray(params.year) ? params.year[0] : params.year
  const week = Array.isArray(params.week) ? params.week[0] : params.week
  const seas_type_param = Array.isArray(params.seas_type)
    ? params.seas_type[0]
    : params.seas_type
  if (year != null && week != null) {
    return format_nfl_week_identifier({
      year,
      seas_type: seas_type_param || 'REG',
      week
    })
  }
  return null
}
```

Every single-week column definition calls this helper, parses the result with `parse_nfl_week_identifier`, and consumes year/week/seas_type components as needed. Mirrors the existing multi-valued `resolve-nfl-week-id-from-year-param.mjs` pattern; single vs. multi helpers make the column-level cardinality contract obvious at a glance.

### Ranking field split

Current 6 rankings fields become 12:

| Old field                           | New season field                           | New week field                           |
| ----------------------------------- | ------------------------------------------ | ---------------------------------------- |
| `player_average_ranking`            | `player_season_average_ranking`            | `player_week_average_ranking`            |
| `player_overall_ranking`            | `player_season_overall_ranking`            | `player_week_overall_ranking`            |
| `player_position_ranking`           | `player_season_position_ranking`           | `player_week_position_ranking`           |
| `player_min_ranking`                | `player_season_min_ranking`                | `player_week_min_ranking`                |
| `player_max_ranking`                | `player_season_max_ranking`                | `player_week_max_ranking`                |
| `player_ranking_standard_deviation` | `player_season_ranking_standard_deviation` | `player_week_ranking_standard_deviation` |

Season variants take `single_year` + `ranking_source_id` + `ranking_type`, filter `player_rankings_index` with `week = 0` and `seas_type = REG`. Week variants take `single_nfl_week_id` + same extras, filter with `nfl_week_id = ?`. Naming follows the existing `player_week_projected_*` / `player_season_projected_*` convention in `projected-table-fields.js`.

### `player_nfl_teams` fix

Moves to multi `nfl_week_id`. Routing condition becomes "any filter present":

```
const nfl_week_id = resolve_nfl_week_id_from_year_param(params)
const has_filter =
  nfl_week_id.length > 0 ||
  career_year.length > 0 ||
  career_game.length > 0 ||
  splits.length > 0
```

When `has_filter`, route to the per-game CTE (which already aggregates `array_agg(distinct tm)`). When no filter, use the fast path `player.current_nfl_team`. Fixes multi-year, week-specific, and multi-week selection all at once.

### Saved view migration (executed on production)

Single column-scoped script `scripts/migrate-data-views-single-nfl-week.mjs`:

For each row in `user_data_views` and `user_plays_views`, iterate `table_state.columns` and `table_state.where`:

1. **Single-week columns** (`player_dfs_salary`, `player_dfs_ownership_pct`, all `player_practice_*`, `player_week_projected_market_salary`, `player_league_roster_status`, all weekly betting-market game-prop column names): resolve the new `single_nfl_week_id` value via the same precedence as the server helper — first use `params.nfl_week_id[0]` if present (covers the `player_dfs_ownership_pct` case where the server already accepts `nfl_week_id`), otherwise construct from `params.year[0]` + `params.week[0]` + `params.seas_type[0]` (default seas_type `REG`); persist as a single-element array `[identifier]`; delete legacy keys
2. **Multi-week columns** (`player_games_played`, `player_nfl_teams`): construct `nfl_week_id` array from year times week times seas_type cross-product; delete legacy keys
3. **Ranking column rename**: for each old `player_{avg,overall,position,min,max,std}_ranking`, inspect `params.week`; rename column to `player_season_*_ranking` (week absent or 0) with `params.year` preserved, or `player_week_*_ranking` with `single_nfl_week_id` constructed; delete legacy `week` key

Dry-run by default, `--apply` to write; run on production, then `yarn export:schema`. Browser localStorage migration applies identical column-scoped logic in `app/core/data-views/browser-storage.mjs` on state restoration.

### Knowledge Base Impact

- **Domain entry docs**: update `docs/data-views-system.md` in the league repo to document the three param flavors, the `player_season_*` / `player_week_*` field naming convention, and the `single_nfl_week_id` migration mechanics
- **ABOUT.md files**: none affected outside the league repo
- **Tag implications**: none
- **Observation / guideline candidates**: none; the conventions documented in the league repo's own docs are sufficient and do not rise to cross-cutting base-layer guidelines
- **Relations**: this task `succeeds` `improve-nfl-week-selector-ux`

## Notes

- `player_nfl_teams.supported_splits` stays `['year', 'week']`; only the column definition's routing fires the CTE correctly now
- Betting-market column definition keeps its 40+ scalar reference sites unchanged — only the top of `get_default_params` changes to decompose `single_nfl_week_id` into year/week/seas_type locals via `parse_nfl_week_identifier`
- `prop_markets_index` does not need a `nfl_week_id` generated column; it is filtered via `prop_markets_index.year` plus a join to `nfl_games.week` / `nfl_games.seas_type`, all derived from the decomposed single identifier
- `rosters_players` does not need a `nfl_week_id` column; the join stays on year + week, derived from the decomposed `single_nfl_week_id`
- The existing multi-helper file is `resolve-nfl-week-id-from-year-param.mjs`; leave the filename as-is to avoid unrelated churn
- Rename ordering in the migration script matters: rank column rename runs before single-week param rewrites, or rank params would be mangled under the old column name
- Saved views referencing `player_rankings_index` week=0 rows expect season-ranking semantics; the split guarantees those resolve to `player_season_*_ranking`
- Test fixtures in `test/data-views-nfl-week.spec.mjs` must be updated for the new param key and ranking field names; running without update will fail
- `ColumnParamNflWeekFilter` single-mode changes are backward-compatible with the existing multi path; the `single` flag is the only branch point
- `player-team-column-definition.mjs` currently reads `params.week` but never uses it — delete the dead destructure alongside the migration

## Plan

### Phase 1: Shared Param and Helpers

- [x] **Add `single_nfl_week_id` param to common-column-params**
  - File: `libs-shared/common-column-params.mjs`
  - Purpose: Define the scalar variant of `nfl_week_id` used by all single-week columns
  - Changes:
    - Export `single_nfl_week_id` with `data_type: SELECT`, `single: true`, `column_name: 'nfl_week_id'`, `label: 'NFL Week'`, `values: get_all_nfl_week_identifiers()`, `default_value: { dynamic_type: 'current_nfl_week' }`
    - Include only the `current_nfl_week` entry in `dynamic_values`
    - Reuse the existing `format_nfl_week_param_values` helper directly; since `single: true` stores values as a one-element array, passing it through produces "Week N YYYY" with no range collapsing needed. Do not add a new formatter.

- [x] **Add `resolve_single_nfl_week_id` server helper**
  - File: `libs-server/data-views/resolve-single-nfl-week-id.mjs` (new)
  - Purpose: Centralize scalar extraction with legacy year/week/seas_type fallback
  - Changes:
    - Export default function taking `{ params }` and returning a scalar `nfl_week_id` string or `null`
    - Precedence: `params.single_nfl_week_id` first, then `params.nfl_week_id[0]`, then constructed from `params.year` + `params.week` + `params.seas_type || 'REG'`

- [x] **Extend `ColumnParamNflWeekFilter` with single-select mode**
  - File: `app/views/components/column-param-nfl-week-filter/column-param-nfl-week-filter.js` and sibling files under the same directory
  - Purpose: Honor `column_param_definition.single === true` so one component serves both param flavors
  - Changes:
    - Branch render logic on `column_param_definition.single`; in single mode, show a single-row picker (type, year, week), hide range-drag affordances, collapse summary to one chip
    - Filter dynamic value choices to scalar-compatible entries
    - Emit a one-element array `[identifier]` via `handle_change` in single mode (matches the react-table `column-param-select-filter.js` `single: true` contract); keep multi-element array emission in multi mode. The storage layer, server helper, and migrations all treat `single_nfl_week_id` as an array containing one value.

- [x] **Wire `single_nfl_week_id` component attachment in data-views-fields index**
  - File: `app/core/data-views-fields/index.js`
  - Purpose: Ensure the custom component is attached to `single_nfl_week_id` param definitions on field assembly
  - Changes:
    - In the field assembly loop, attach `ColumnParamNflWeekFilter` to `field.column_params.single_nfl_week_id.component` when present, mirroring the existing `nfl_week_id` attachment

### Phase 2: Frontend Field Migrations

- [x] **Migrate `player_games_played` to `nfl_week_id`**
  - File: `app/core/data-views-fields/player-games-played-table-fields.js`
  - Purpose: Align UI param with the multi-week CTE filter that `rate-type-per-game.mjs` already applies
  - Changes:
    - Replace `year, week` destructure with `nfl_week_id`
    - Update `column_params` to `{ nfl_week_id, year_offset, career_year }`
    - Keep `splits: ['year']`

- [x] **Split `player_rankings` fields into season and week variants**
  - File: `app/core/data-views-fields/player-rankings-table-fields.js`
  - Purpose: Clean-grained semantics; season fields use `single_year`, week fields use `single_nfl_week_id`
  - Changes:
    - Remove current 6 exports; add 12 exports (6 `player_season_*_ranking`, 6 `player_week_*_ranking`)
    - Season variants: `column_params: { year: single_year, ranking_source_id, ranking_type }`, `splits: ['year']`
    - Week variants: `column_params: { single_nfl_week_id, ranking_source_id, ranking_type }`, `splits: ['year', 'week']`
    - Remove the `{value: 0, label: 'Season'}` custom week value injection

- [x] **Migrate `player_dfs_salary` to `single_nfl_week_id`**
  - File: `app/core/data-views-fields/player-dfs-salaries-table-fields.js`
  - Changes:
    - Replace `year: single_year, week: single_week` with `single_nfl_week_id`
    - Keep `platform_source_id, career_year, career_game`

- [x] **Migrate `player_practice_*` to `single_nfl_week_id`**
  - File: `app/core/data-views-fields/practice-table-fields.js`
  - Changes:
    - Replace `year: single_year, single_week` with `single_nfl_week_id` in `create_practice_field`

- [x] **Migrate `player_week_projected_market_salary` to `single_nfl_week_id`**
  - File: `app/core/data-views-fields/fantasy-league-table-fields.js`
  - Changes:
    - In the `player_week_projected_market_salary` entry, replace `{ year: single_year, week: single_week }` with `{ single_nfl_week_id }`; leave surrounding seasonal fields unchanged

- [x] **Migrate weekly betting-market game-prop fields to `single_nfl_week_id`**
  - File: `app/core/data-views-fields/betting-market-table-fields.js`
  - Changes:
    - In `create_base_column_params`, replace `year` + `week: single_week` with `single_nfl_week_id`; `create_game_prop_column_params`, `create_team_game_prop_column_params`, and `create_historical_game_prop_column_params` all spread the base and inherit the change. Historical game-prop fields select a specific game (the `single_nfl_week_id` row) and the `historical_range` param controls which prior window of stats to aggregate — these are complementary, not conflicting, so `single_nfl_week_id` is correct semantics for historical game-prop columns as well
    - In `create_game_prop_column_params`, drop `seas_type: single_seas_type` (season type is embedded in the identifier)
    - Leave `player_season_prop_line_from_betting_markets` unchanged — it defines its own inline `column_params` without spreading the base, and it uses season-level semantics

### Phase 3: Server Column Definition Migrations

- [x] **Split `player_rankings` server column definitions**
  - File: `libs-server/data-views-column-definitions/player-rankings-column-definitions.mjs`
  - Changes:
    - Export 12 fields matching the frontend split
    - Season branch: `get_default_params` extracts `year` scalar, `add_with_statement` filters `where('week', 0).andWhere('seas_type', 'REG').whereIn('year', [...])`
    - Week branch: extract scalar via `resolve_single_nfl_week_id`, filter `where('nfl_week_id', identifier)`; retain legacy fallback for saved views that still pass `year` + `week > 0`
    - Separate `generate_table_alias` per branch:
      - Season alias key: `player_season_rankings_${year}_${ranking_source_id}_${ranking_type}`
      - Week alias key: `player_week_rankings_${nfl_week_id}_${ranking_source_id}_${ranking_type}`
      - Both hashed via `get_table_hash`. The distinct prefixes prevent cache collisions between the two branches even if identifier strings were to overlap

- [x] **Migrate `player_dfs_salaries` server def to `resolve_single_nfl_week_id`**
  - File: `libs-server/data-views-column-definitions/player-dfs-salaries-column-definitions.mjs`
  - Changes:
    - Replace the current year/week fallback block with `resolve_single_nfl_week_id({ params })`
    - Parse the result to year and week for internal use; filter `nfl_games.nfl_week_id = ?` with a single value

- [x] **Migrate `player_practice` server def to `resolve_single_nfl_week_id`**
  - File: `libs-server/data-views-column-definitions/player-practice-column-definitions.mjs`
  - Changes:
    - Replace year/week fallback with `resolve_single_nfl_week_id`
    - Filter `practice.nfl_week_id = ?` scalar

- [x] **Fix and migrate `player_dfs_ownership_pct`**
  - File: `libs-server/data-views-column-definitions/player-dfs-ownership-column-definitions.mjs`
  - Changes:
    - Fix the `nwi.split('-')` parse bug inside `add_player_dfs_ownership_with_statement` (lines 60-63 — the `year_week_pairs` construction), not in `get_params`. Replace the split-based parse with `parse_nfl_week_identifier` from `#libs-shared/nfl-week-identifier.mjs`
    - Switch param extraction to `resolve_single_nfl_week_id`; with a single identifier, `year_week_pairs` collapses to a single pair
    - Rebuild the raw SQL to use a single decomposed `(dc.year = ? AND dc.week = ?)` pair instead of an OR list

- [x] **Tighten `player_projected` weekly branch to `resolve_single_nfl_week_id`**
  - File: `libs-server/data-views-column-definitions/player-projected-column-definitions.mjs`
  - Changes:
    - In `get_default_params` for the weekly projection path, replace the ad-hoc `params.nfl_week_id[0]` extraction with a call to `resolve_single_nfl_week_id` for consistency with the other single-week columns
    - This is a pure non-behavioral refactor — the file already correctly handles `params.nfl_week_id` via `parse_nfl_week_identifier`. The change introduces no semantic difference; it exists to establish `resolve_single_nfl_week_id` as the shared convention across all single-week columns. Note this in the commit message to aid code review
    - Season-level projection branch unchanged

- [x] **Migrate `player_betting_market` server def**
  - File: `libs-server/data-views-column-definitions/player-betting-market-column-definitions.mjs`
  - Changes:
    - At the top of `get_default_params`, call `resolve_single_nfl_week_id`; if it resolves, use `parse_nfl_week_identifier` to produce scalar `year`, `week`, `seas_type` locals; else fall back to current scalar extraction from `params.year` / `week` / `seas_type`
    - Leave the 40+ downstream reference sites (`prop_markets_index.year`, `nfl_games.week`, `nfl_games.seas_type`) unchanged; they consume the same scalar locals
    - Update alias hash formula to include the `single_nfl_week_id` value when present, otherwise the existing `${year}_seas_type_${seas_type}_week_${week}` string

- [x] **Fix `player_nfl_teams` routing and migrate to `nfl_week_id`**
  - File: `libs-server/data-views-column-definitions/player-team-column-definition.mjs`
  - Changes:
    - Remove the `week` destructure in `get_default_params` (dead code)
    - Replace the routing conditional with `has_filter = nfl_week_id.length > 0 || career_year.length > 0 || career_game.length > 0 || splits.length > 0` using `resolve_nfl_week_id_from_year_param` for nfl_week_id extraction
    - The conditional is currently duplicated across six functions: `is_where_column_array`, `table_alias`, `main_select`, `main_where`, `main_group_by`, and `join`. All six must be updated to the new `has_filter` check; consider extracting a single `should_use_cte({ params, splits })` helper at the top of the file and calling it from each to prevent future drift
    - When `has_filter`, route through the per-game CTE (which correctly aggregates via `array_agg(distinct tm)` across selected weeks and years)
    - When no filter, keep the fast path `player.current_nfl_team`

- [x] **Migrate `player_league_roster_status` join to `single_nfl_week_id`**
  - File: `libs-server/data-views-column-definitions/index.mjs` (lines 84-107)
  - Changes:
    - Call `resolve_single_nfl_week_id` to get a scalar identifier string. When it returns a value, `parse_nfl_week_identifier` decomposes it to `year` and `week` for the `rosters_players.year`/`rosters_players.week` join conditions
    - When it returns null, preserve the existing async league-championship-round defaulting block exactly as-is (the current `await getLeague({ lid, year })` logic that computes `week = Math.min(current_season.week, championship_round || current_season.finalWeek)`). Do not simplify this to a plain `current_season.week` default — it correctly clamps to the fantasy league's championship round for playoff-aware week resolution

### Phase 4: Migrations

- [x] **Write saved-view DB migration script**
  - File: `scripts/migrate-data-views-single-nfl-week.mjs` (new)
  - Purpose: Column-scoped rename of params and columns in `user_data_views.table_state` and `user_plays_views.table_state`
  - Changes:
    - Iterate both tables; for each row's JSON, process every entry in `table_state.columns` and `table_state.where`
    - Maintain three explicit sets: `SINGLE_WEEK_COLUMNS`, `MULTI_WEEK_COLUMNS`, `RANKING_OLD_NAMES` (with mapping to new season/week names)
    - Apply ranking column rename first (week=0 / absent becomes `player_season_*`, week>0 becomes `player_week_*`), then single-week param consolidation, then multi-week param consolidation
    - Dry-run default; `--apply` flag writes; log per-row transformations
    - After running on production, run `yarn export:schema`

- [x] **Extend browser localStorage migration**
  - File: `app/core/data-views/browser-storage.mjs`
  - Changes:
    - Add the same column-scoped rewrite applied in the DB script to the state-restore path
    - Preserve the existing `nfl_week` to `nfl_week_id` migration already in place

### Phase 5: Tests

- [x] **Update and add data-views nfl_week tests**
  - File: `test/data-views-nfl-week.spec.mjs`
  - Changes:
    - Add cases for `single_nfl_week_id` legacy fallback (year + week produces scalar identifier)
    - Add case exercising `resolve_single_nfl_week_id` precedence
    - Update existing assertions using old ranking field keys to the new split names

- [x] **Add ranking split tests**
  - File: `test/data-views-ranking-split.spec.mjs` (new)
  - Changes:
    - Verify `player_season_average_ranking` returns week=0, seas_type=REG rows only
    - Verify `player_week_average_ranking` returns rows for the single specified nfl_week_id
    - Verify migration script correctly renames a saved view containing `player_average_ranking` with week=0 and with week=5

- [x] **Add browser localStorage migration tests**
  - File: `test/data-views/browser-storage.spec.mjs` (extend existing)
  - Changes:
    - Exercise the column-scoped rewrite on state restoration: a stored snapshot containing `player_dfs_salary` with `year`/`week` params restores with `single_nfl_week_id`
    - A snapshot containing old-name ranking columns renames them correctly (season vs week variants based on `week` value)
    - A snapshot containing the prior `nfl_week` to `nfl_week_id` migration already applied is a no-op (idempotency)
    - A snapshot with missing `week` key defaults through `single_nfl_week_id` null path cleanly without throwing

- [x] **Add `player_nfl_teams` coverage tests**
  - File: existing data-views tests
  - Changes:
    - Test: single year equal to `current_season.year` returns `player.current_nfl_team`
    - Test: multi-year selection returns CTE `array_agg(distinct tm)` (previously broken)
    - Test: specific `nfl_week_id` selection returns the teams the player played for in those weeks

### Phase 6: Documentation

- [x] **Update data views system documentation**
  - File: `docs/data-views-system.md` (in the league repo)
  - Changes:
    - Document the three param flavors (`single_year` / `year`, `single_nfl_week_id`, `nfl_week_id`) and when to use each
    - Document the `player_season_*` / `player_week_*` naming convention for fields with both grains
    - Document the column-scoped saved-view migration executed by this task and the behavior of legacy saved views that still carry `year` + `week` keys
