---
title: Rework Data-View Browser Storage
type: task
status: completed
description: >-
  Rework the data-view localStorage system with a versioned migration registry, TTL + cross-device
  eviction, stricter write gates for empty/default views, debounced persistence, pure migrations,
  unsaved-edit UX indicators, and a Node-testable module seam. Builds on the hardening commits
  f3d16394 and c98c0c99.
base_uri: user:task/league/rework-data-view-browser-storage.md
created_at: '2026-04-24T19:25:13.813Z'
entity_id: 93c5a95a-34e7-4cb4-b1b6-7a9b47691016
finished_at: '2026-04-24T21:56:06.025Z'
observations:
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
owner_identity_uri: █████████████████████████
priority: Medium
public_read: true
started_at: '2026-04-24T21:38:25.766Z'
tags:
  - user:tag/league-xo-football.md
updated_at: '2026-06-01T16:13:33.038Z'
visibility_analyzed_at: '2026-11-05T23:50:16.096Z'
---

# Implementation Plan: Rework Data-View Browser Storage

## Overview

- Rework the data-view browser-storage system from an opportunistically-patched key/value layer into a disciplined subsystem with explicit versioning, staleness policy, stricter write gates, and a Node-testable seam.
- The recent hardening (`f3d16394`: validators, self-healing, reducer guard, selector fallback; `c98c0c99`: webpack alias fix) stopped TypeError crashes from corrupted snapshots but did not address the underlying structural debt. This plan is the cleanup before the next round of data-view changes ships.
- Expected outcomes:
  - Future table_state breaking changes need only bump `STORAGE_SCHEMA_VERSION` and add one migration entry.
  - Stale and orphaned views are bounded in age and survive cross-device deletes.
  - Empty/new views never pollute storage.
  - Default-view editing is no longer silently discarded or silently saved.
  - Unsaved local edits are visible in the view list, not only in the toolbar.
  - The storage module is testable under Mocha/Node without webpack aliases.
  - Keystroke-level persistence cost drops (debounced writes, single `JSON.stringify`, no full-history sanitize on load-latest).

## Background

### Storage layer today (`app/core/data-views/browser-storage.mjs`)

- Snapshots stored under `data_view_history_{view_id}` as `{ table_state, change_type, timestamp }` arrays, trimmed to 20 per view.
- Metadata under a separate key tracks `view_access_times` and `last_cleanup`. A `version: 1` field is written but never read.
- `MAX_VIEWS_CACHED = 50`; LRU evicts oldest by access-time on `QuotaExceededError` or a 10-minute interval triggered opportunistically from `persist_table_state_to_browser`. No age-based TTL.
- `is_valid_table_state` (lines 13-17) requires `columns` and `prefix_columns` to be arrays; it does NOT require length > 0.
- `sanitize_snapshot` applies per-entry column migrations via `migrate_column_entry`; `load_view_history` rewrites pruned arrays back to storage only when length changes.

### Orchestration (`app/core/data-views/sagas.js`)

- `persist_table_state_to_browser` uses `JSON.stringify` equality of current vs `saved_table_state` to tag snapshots `change_type: 'reset_to_saved'` — O(n) on every change.
- `restore_view_states_from_browser` skips hardcoded default view ids and invalid snapshots; self-heals via the storage layer in the same pass.
- `cleanup_browser_state_on_delete` only fires on the local DELETE_DATA_VIEW path; server-side deletes from other devices leave local snapshots intact.
- `GET_DATA_VIEWS_FULFILLED` does not reconcile returned server view ids against local `browser_view_ids`.

### Migration helpers (`libs-shared/data-views-nfl-week-migration.mjs`)

- Six helpers (`migrate_column_entry`, `migrate_entries_array`, `migrate_sort_array`, `migrate_table_state`, `resolve_single_identifier`, `resolve_multi_identifiers`, `clean_legacy`) all target one breaking change: the year/week/seas_type → nfl_week_id consolidation.
- There is no `STORAGE_SCHEMA_VERSION` constant, no ordered migration registry, and no stored version per snapshot.
- `migrate_column_entry` / `migrate_params` currently mutate input objects in place, risking Redux-store corruption without an action.

### New-view creation and default views

- `react-table/.../table-view-controller.js:192-214` `handle_add_click` creates a view with `columns: []`. It passes `is_valid_table_state`, persists to localStorage as an empty shell, and `handle_data_view_request` silently bails on `!columns.length` — blank grid with no empty-state guidance.
- Default views are skipped on restore (`DEFAULT_VIEW_IDS`) but NOT on save — edits produce a snapshot that is written, retained, then ignored on reload. Clicking Save on a default view is silently rejected by ownership check (`user_id !== view.user_id`).

### Hot-path and correctness findings from the review pass

- `persist_table_state_to_browser` fires on every `DATA_VIEW_CHANGED` with no debounce, producing 4 synchronous localStorage operations per keystroke.
- `sanitize_snapshot` migration output is not written back unless array length changed — O(n) migration repeats every page load forever.
- QuotaExceeded recovery collapses history to a single entry and never retries the full write, silently dropping up to 19 snapshots.
- `get_latest_snapshot` sanitizes the entire history just to return the last element.
- No cross-tab coordination; two tabs writing the same view race and last-writer-wins.
- `metadata.version: 1` stub is never read or enforced.

### Tests

- `test/data-views/browser-storage.spec.mjs` imports `@core/data-views/browser-storage.mjs`. `@core` is a webpack alias only; Mocha/Node has `#config`, `#db`, `#libs-server`, `#libs-shared`, `#constants` in package.json `imports` but no `@core`. The test file cannot run.

## Design

### Approach

Relocate the storage layer into `libs-shared/data-view-storage/` as a small self-contained module with three responsibilities: versioned serialization, bounded retention, safe write gating. Reducer and sagas become consumers of a stable API rather than co-owners of the healing/validation logic. UX affordances (indicator, manual clear, quota warnings) are a thin layer on top.

### Module layout

- `libs-shared/data-view-storage/validate.mjs` — `is_valid_table_state`, `is_empty_new_view`.
- `libs-shared/data-view-storage/migrations.mjs` — `STORAGE_SCHEMA_VERSION` constant (initial `1`), ordered `migrations` array, `run_migrations(snapshot)`. v0→v1 wraps the existing `migrate_table_state` helper.
- `libs-shared/data-view-storage/storage.mjs` — public API: `save_snapshot`, `load_latest_snapshot`, `load_history`, `clear_view`, `clear_all`, `reconcile_server_views`, `save_last_active_view`, `load_last_active_view`. Internal `with_quota_recovery` retries full write after LRU eviction. Reports unrecoverable failures via an injected `on_quota_exceeded` callback (see Quota-failure signaling).
- No `index.mjs` barrel (per write-javascript guideline). Callers import directly from the source file via the `#libs-shared` alias.

The existing `libs-shared/data-views-nfl-week-migration.mjs` stays in place and becomes the implementation of migration v0→v1. Minimal churn; no shim files.

### `save_snapshot` signature

```
save_snapshot({ view_id, table_state, change_type, is_new_view })
```

The `is_new_view` flag travels through from the saga's `view_change_params` so the empty-new-view gate can evaluate `is_empty_new_view(table_state) && is_new_view` inside `save_snapshot`. Existing saved views being edited to zero columns are NOT rejected (they pass `is_valid_table_state` via the non-default `prefix_columns` clause).

### Quota-failure signaling

Rather than a DOM `CustomEvent` (would couple `storage.mjs` to `window` and break Node tests), `storage.mjs` accepts an optional `on_quota_exceeded` callback passed at module init time. The data-views saga installs a callback that dispatches the existing toast action. In tests, the callback is a Mocha spy. No `window.*` references appear in `storage.mjs`.

### Snapshot format

```
{ version: N, timestamp, change_type, table_state }
```

Field name stays `timestamp` (matching the existing v0 shape) rather than renaming to `saved_at`, so v0→v1 migration does not need a field rename and existing TTL math on `timestamp` keeps working. Unversioned legacy snapshots are treated as `version: 0`. `run_migrations` walks them forward to `STORAGE_SCHEMA_VERSION` and the history array is written back in one pass (fixes the never-persisted-migration bug). Migrations MUST be pure; `Object.freeze` is applied to inputs in dev builds to enforce this.

### Write gating

- `save_snapshot` rejects: invalid `table_state`, default-view ids, empty-new-view shells (`is_new_view && columns.length === 0`).
- Persist saga uses `debounce(250, DATA_VIEW_CHANGED, ...)`; the network-request path (`takeLatest`) is unchanged.
- The `JSON.stringify` equality check for `change_type: 'reset_to_saved'` is removed. A new explicit `REVERT_DATA_VIEW` action (distinct from the existing `RESET_DATA_VIEW_CACHE` server-refetch action) is dispatched by the Reset button; the reducer sets `table_state` back to `saved_table_state` and the saga clears local history for that view_id.

### TTL, LRU, and cross-device reconcile

- `MAX_VIEWS_CACHED = 50` (unchanged). New: `SNAPSHOT_TTL_DAYS = 90` prunes views whose latest snapshot `timestamp` (the existing field — NOT renamed to `saved_at`) is older than the TTL during the existing 10-minute time-gated cleanup. TTL is deliberately keyed on "time since last edit" (snapshot timestamp) rather than "time since last access" (`view_access_times`). Read-only access that never produces a snapshot will not refresh the TTL clock. This means a user who opens a 3-year-old view without editing it can still have that view's local snapshot evicted. This is intended: TTL is about bounding stale WRITES, not recent opens; LRU already bounds least-recently-accessed views independently.
- New saga `watch_reconcile_server_views` calls `reconcile_server_views(server_view_ids)` on `GET_DATA_VIEWS_FULFILLED`, evicting stored view_ids not in the server list (except `DEFAULT_VIEW_IDS`).
- Cross-tab `storage` event coordination is NOT in scope for this plan (deferred — see Notes).

### UX surfaces

- New selector `get_has_unsaved_local_edits(state, { view_id })` using a structural deep-equal helper on plain-JS objects. `table_state` and `saved_table_state` are stored as plain JS in the reducer (see explicit comment at `app/core/data-views/reducer.js:9-10`), so `Immutable.is` would be wrong. Reuse an existing deep-equal utility if available in the repo; otherwise a small `deep_equal` helper lives next to the selector. Drives a dot indicator in the view list next to each view name.
- Reset button dispatches `REVERT_DATA_VIEW` instead of assigning `saved_table_state` through `on_view_change`.
- When the selected view is a default/system view, the Save button is disabled with tooltip "Duplicate this view to save your changes" linking to the existing duplicate action.
- A new "Clear local view cache" menu entry dispatches a saga that calls `clear_all()` behind a confirmation dialog.
- Quota failures surface through the existing toast infrastructure via the `on_quota_exceeded` callback wired at module init in the data-views saga.
- UI changes land in `app/views/pages/data-views/data-views.js` and its container `app/views/pages/data-views/index.js`. Do NOT modify files under `node_modules/react-table/`.

### Tests

- Relocate the broken spec to `test/data-view-storage/storage.spec.mjs` and switch imports to `#libs-shared/data-view-storage/...`. Mocha runs it without webpack.
- Add `test/data-view-storage/migrations.spec.mjs` covering idempotency, frozen-input immutability, v0→v1 correctness, and same-version passthrough.

### Knowledge Base Impact

- **Domain entry document**: Extend `user:text/league/ABOUT.md` with an entry pointing to a new text entity describing the data-view storage subsystem.
- **New text entity**: `user:text/league/data-view-storage-architecture.md` — descriptive doc: snapshot format, migration registry contract, TTL + LRU policy, cross-device reconcile behavior, default-view exclusion, saga write-gating rules, quota-recovery behavior. Tag: `user:tag/league-xo-football.md`. Relations: `describes [[user:repository/active/league]]`, cross-ref `[[███████████████████████-██████-███████████████]]`. Rationale for standalone entity (not a section in `league-system-architecture.md`): subsystem scope is large enough (migration contract, TTL policy, cross-tab/cross-device semantics, quota handling, UX affordances) that embedding it would dilute the system-architecture entry; connectivity to the parent is provided via the ABOUT.md bullet.
- **ABOUT.md update**: `user:text/league/ABOUT.md` gains a bullet with a context phrase ("load when modifying data-view persistence, migrations, or storage UX").
- **Observation**: on the new text entity — `[contract] Breaking table_state changes MUST bump STORAGE_SCHEMA_VERSION and add a v{N} migration module.`
- **Tag implications**: none new.
- **Guideline candidates**: none. Contract is narrow enough to live in the new text entity + top-of-file comment in `migrations.mjs`.
- **Connectivity**: the new text entity is referenced from `text/league/ABOUT.md` with a context phrase, satisfying the minimum connectivity requirement for a new text entity.

## Notes

- Kept changes proportional per simplify-software-plan: no new guideline, no CLAUDE.md additions in the league repo, no separate `schema-version.mjs` / `metadata.mjs` / `quota.mjs` / barrel index. Three files under `data-view-storage/` plus the preserved `data-views-nfl-week-migration.mjs`.
- Migration registry is justified despite having only one migration today: the user explicitly requested "bumping the constant should be the only thing future breaking changes need to do" — this is the exception in simplify-software-plan for abstractions that model an explicit requirement and prevent recurring cross-cutting edits.
- Cross-tab `storage` event coordination is deferred. It is a real gap (last-writer-wins race across tabs) but was not in the user's four in-scope items and would widen the change set significantly. Worth a follow-up task if seen in practice.
- A parallel localStorage system exists at `app/core/plays-view/browser-storage.mjs` following the same shape (`plays_view_browser_storage_*`) exported via `plays-view/index.js`. It is deliberately out of scope for this plan. After this rework lands, the two systems will have diverged further; a follow-up task should consider porting plays-view to the same `libs-shared/data-view-storage/` module (generalized for a configurable key prefix) or to a similar sibling module.
- Rollback safety: the migration writes `version: N` onto each snapshot. The pre-rework `browser-storage.mjs` code reads snapshots via `{ table_state, change_type, timestamp }` destructure and spread — it ignores unknown fields, including `version`. Reverting this deploy does not corrupt stored data; users will simply re-run old code against version-stamped snapshots safely.
- First-load migration performance: `restore_browser_state_for_all_views` runs inside the saga middleware (non-blocking to the initial React paint). Per-view cost is O(1) because `load_latest_snapshot` migrates only the tail entry. For 50 views the worst case is 50 single-entry migrations plus up to 50 localStorage write-backs — comparable to the existing restore cost and not a paint-blocker.
- `react-table` is a user-owned sibling repo at `repository/active/react-table` (not a vendored third-party package). The league's `node_modules/react-table` is the published build output; the canonical source is in the sibling repo. UX changes to `ViewItem` therefore land in `repository/active/react-table/src/...`, follow that repo's commit/publish flow, and reach the league via a version bump. Both commits can land together in a coordinated PR pair, or the react-table change can ship first and the league change follows when it picks up the new version.
- Existing webpack alias for `@libs-shared/data-views-nfl-week-migration.mjs` remains (the file stays in place). If a later cleanup consolidates migrations under `data-view-storage/migrations/`, the alias can be retired then.
- `migrate_column_entry` / `migrate_params` mutation bug is fixed here rather than deferred — it is a latent store-corruption risk that the migration registry relies on not existing.
- Tightened `is_valid_table_state` requires `columns.length > 0` OR a non-default `prefix_columns`, so legitimate mid-edit states (user cleared columns but kept prefix) still persist, while a vestigial empty shell does not.
- Relevant guidelines: [[user:guideline/write/write-javascript.md]], [[user:guideline/write/write-software.md]], [[user:guideline/software/simplify-software-plan.md]], [[user:guideline/software/simplify-software-implementation.md]], [[user:guideline/knowledge-base/persist-knowledge.md]].

## Tasks

### Storage Module

- [x] **Create `libs-shared/data-view-storage/validate.mjs`**
  - Purpose: single source of table_state validity, callable from storage layer and reducer guard.
  - Changes:
    - Export `is_valid_table_state(table_state)` — object with `columns` and `prefix_columns` arrays; `columns.length > 0` OR non-default `prefix_columns`.
    - Export `is_empty_new_view(table_state)` — true when `columns.length === 0`.

- [x] **Create `libs-shared/data-view-storage/migrations.mjs`**
  - Purpose: versioned migration registry and runner. Top-of-file comment documents the bump-and-add contract and references `user:text/league/data-view-storage-architecture.md`.
  - Changes:
    - Export `STORAGE_SCHEMA_VERSION = 1`.
    - Export ordered `migrations` array of entries shaped `{ from, to, migrate(snapshot) }`. Each `migrate` receives the FULL snapshot (not just `table_state`) and returns a new snapshot with `version: to`.
    - v0→v1 entry wraps `migrate_table_state` from `libs-shared/data-views-nfl-week-migration.mjs`. The wrapper must translate between the two contracts: `migrate_table_state(table_state)` returns `{ changed, table_state }` (operating on table_state only), whereas the registry expects `migrate(snapshot)` returning a new snapshot. Wrapper pseudo-shape: take `snapshot`, call `migrate_table_state(snapshot.table_state)`, destructure `{ table_state: next_table_state }`, return `{ ...snapshot, table_state: next_table_state, version: 1 }`. The `changed` flag from `migrate_table_state` is discarded; the migration always stamps `version: 1`.
    - Export `run_migrations(snapshot)` — applies migrations from `snapshot.version ?? 0` to `STORAGE_SCHEMA_VERSION`; returns `{ snapshot, migrated }` where `migrated` is true if any migration ran. Must not mutate input.
    - In dev, `Object.freeze` the input snapshot before each migration call.

- [x] **Create `libs-shared/data-view-storage/storage.mjs`**
  - Purpose: public storage API consumed by sagas and UI. No `window` or DOM references; must be importable in Mocha/Node.
  - Changes:
    - Export `save_snapshot({ view_id, table_state, change_type, is_new_view })` — validates; skips default view ids; skips `is_empty_new_view(table_state) && is_new_view`; stamps `version`, `timestamp`; sanitizes params on save (port over the existing `sanitize_table_state` from the deleted `browser-storage.mjs`); writes via `with_quota_recovery`; updates access time; time-gated LRU + TTL prune.
    - Export `load_latest_snapshot(view_id)` — single read; runs `run_migrations` on the last entry only. Accepted deferral: does NOT prune corrupt earlier entries; full-history pruning happens in `load_history`. Writes the one entry back if migration changed it.
    - Export `load_history(view_id)` — full migrate + write-back (used by history UI only).
    - Export `save_last_active_view(view_id)` and `load_last_active_view()` — same behavior as the current `data_view_browser_storage_set_last_active_view` / `get_last_active_view`; storage key `data_view_last_active`.
    - Export `get_all_stored_view_ids()` — returns `Object.keys(metadata.view_access_times)` as an array of strings (matches the existing `data_view_browser_storage_get_all_view_ids` return type; consumer `collect_all_view_ids` spreads it into a `new Set([...])`). Replaces `data_view_browser_storage_get_all_view_ids`, preserving `collect_all_view_ids`'s three-source merge (server + Redux + browser storage) so client-generated unsaved new views survive page reload.
    - Export `clear_view(view_id)` — removes `data_view_history_{view_id}` and deletes `view_access_times[view_id]` in metadata.
    - Export `clear_all()` — removes all `data_view_history_*` keys, plus `data_view_metadata`, plus `data_view_last_active`.
    - Export `reconcile_server_views({ server_view_ids, redux_view_ids })` — evicts stored view_ids not in the server list, with two exceptions: `DEFAULT_VIEW_IDS`, and view_ids present in `redux_view_ids`. Rationale: `GET_DATA_VIEWS_FULFILLED` dispatches the server payload into the reducer synchronously before the watcher runs, so by the time this function is called `redux_view_ids` is a SUPERSET of `server_view_ids` — it contains (server views just merged) + (any client-generated views added earlier in the session). The `redux_view_ids` exception therefore specifically protects client-only view IDs that appear in Redux but were NOT returned by the server (unsaved new views from this session). A view id not in Redux and not on the server is truly orphaned and safe to evict.
    - Export `init_storage({ on_quota_exceeded })` — stores the callback. Callers SHOULD invoke this at module import time (e.g., from the data-views saga module top level) rather than inside the root saga generator, to avoid a race where a `DATA_VIEW_CHANGED` action fires before the root saga reaches the init call. If not initialized, quota failures fall back to `console.error` only.
    - Internal `with_quota_recovery(write_fn)` — on `QuotaExceededError`, run LRU eviction and retry the full write; do NOT collapse to a single entry. On repeat failure, invoke `on_quota_exceeded({ view_id })` and return false.
    - Constants: `MAX_VIEWS_CACHED = 50`, `SNAPSHOT_TTL_DAYS = 90`, `MAX_SNAPSHOTS_PER_VIEW = 20`.

- [x] **Fix mutation bug in `libs-shared/data-views-nfl-week-migration.mjs` `clean_legacy`**
  - Purpose: migrations must be pure. `migrate_column_entry` already spreads `params` into a shallow copy, but `clean_legacy` then mutates that copy's caller-exposed parent in some paths. The real latent mutation bug is `migrate_params` in `browser-storage.mjs` (being deleted in this plan), but a defense-in-depth audit is warranted.
  - Changes:
    - Audit `clean_legacy` and `migrate_column_entry` for any caller-visible mutation; return new objects in any code path that currently deletes or assigns keys on an input-derived reference.
    - Add a unit test that freezes an input table_state and confirms `migrate_table_state` completes without throwing a "cannot modify frozen object" TypeError.

- [x] **Delete `app/core/data-views/browser-storage.mjs` (ordering-sensitive)**
  - Purpose: responsibilities moved entirely to `libs-shared/data-view-storage/storage.mjs`. No shim re-export (per write-javascript: no barrel files).
  - **Precondition**: all consumers must be migrated first (these tasks appear in later sections of this plan). Complete them BEFORE running the delete:
    - `app/core/data-views/reducer.js` import switch (see Reducer task under "Reducer" section)
    - `app/core/data-views/sagas.js` import switch (all entries: `save_snapshot`, `load_view_history`/`get_latest_snapshot`, `set_last_active_view`, `get_last_active_view`, `is_valid_table_state`, `cleanup_old_views`, `cleanup_browser_state_on_delete` replacement)
    - `app/core/data-views/index.js` — currently does `export * from './browser-storage.mjs'`; either delete that export line or replace with a direct re-export from the new path. Prefer deletion — consumers should import from `#libs-shared/data-view-storage/storage.mjs` directly.
    - `test/data-views/browser-storage.spec.mjs` — must be `git mv`-d to `test/data-view-storage/storage.spec.mjs` in the SAME commit as the source delete (see Testing & Validation task), otherwise the spec becomes a dangling reference.
    - Search the repo for any remaining `data-views/browser-storage` imports outside of `app/core/data-views/`.
    - ALSO search for barrel consumers: `grep -rn "from '@core/data-views'" app/ --include='*.js' --include='*.mjs'`. `index.js:8` currently re-exports all storage symbols, so external code may import `is_valid_table_state` or other storage functions through the barrel without naming `browser-storage.mjs`. Any such consumer must be updated to import from `#libs-shared/data-view-storage/storage.mjs` (or `validate.mjs`) directly.
  - Do the delete with `git rm` in a single commit alongside the final consumer update so the build stays green at every commit.

### Saga Changes

- [x] **`app/core/data-views/sagas.js` — persist/restore/reconcile rewrites**
  - Purpose: route persistence through the new storage module; debounce writes; close empty-new-view and default-view gaps; reconcile server deletes; surface quota failures.
  - Changes:
    - Replace all `./browser-storage.mjs` imports with `#libs-shared/data-view-storage/storage.mjs` AND `#libs-shared/data-view-storage/validate.mjs`. Affected symbols today: `data_view_browser_storage_save_snapshot`, `data_view_browser_storage_get_latest_snapshot`, `data_view_browser_storage_set_last_active_view`, `data_view_browser_storage_get_last_active_view`, `data_view_browser_storage_clear_view_history`, `data_view_browser_storage_cleanup_old_views`, `data_view_browser_storage_get_all_view_ids`, `data_view_browser_storage_load_metadata`, `is_valid_table_state` (confirmed against the current sagas.js import list; `data_view_browser_storage_load_view_history` is NOT a saga import — it is internal to browser-storage.mjs). Remap storage calls to the new API (`save_snapshot`, `load_history`, `load_latest_snapshot`, `save_last_active_view`, `load_last_active_view`, `clear_view`, `get_all_stored_view_ids` — all from `storage.mjs`); import `is_valid_table_state` from `validate.mjs` (NOT `storage.mjs`); remove direct cleanup calls and the `data_view_browser_storage_load_metadata` reference entirely — cleanup is internal to `save_snapshot`.
    - Retain the `is_valid_table_state` check at sagas.js:323 inside `restore_view_states_from_browser` — it guards a read path that `save_snapshot`'s write gating never covers.
    - Add `debounce` to the existing `import { takeLatest, fork, call, select, put } from 'redux-saga/effects'` line (becomes `import { takeLatest, fork, call, select, put, debounce } from 'redux-saga/effects'`). Confirmed available via `app/core/players/sagas.js` sibling usage.
    - Delete the local `cleanup_if_needed` generator (sagas.js:196-208); its only remaining call site (`yield call(cleanup_if_needed)` at sagas.js:266) goes away with the rewrite because cleanup is handled inside `save_snapshot`.
    - Call `init_storage({ on_quota_exceeded })` at the TOP LEVEL of the sagas module file (not inside a saga generator) so the callback is installed on import, before any action dispatch. `store_registry.getStore()` (defined in `app/core/store-registry.js:6`) is the available escape hatch for dispatching from outside saga/component context; it is currently only referenced from `app/views/root.js:15`, so this task is its first saga-side consumer — pattern is sound, precedent is thin. Callback body: `store_registry.getStore().dispatch(notification_actions.show({ message: 'Could not save local view changes — storage is full', severity: 'error' }))`. Confirmed signature against `app/core/notifications/actions.js`. No saga channel needed.
    - Note: `watch_set_selected_players_table_view` routes `SET_SELECTED_DATA_VIEW` through `data_view_changed`. The new debounce watcher targets `DATA_VIEW_CHANGED` only. This is correct — `SET_SELECTED_DATA_VIEW` does not carry edit state that should be persisted. No change needed, just confirm during implementation that no new `SET_SELECTED_DATA_VIEW` dispatcher adds `view_state_changed: true`.
    - Add `debounce(250, DATA_VIEW_CHANGED, persist_table_state_to_browser)`; keep `takeLatest(DATA_VIEW_CHANGED, data_view_changed)` for the network path.
    - REMOVE the `yield call(persist_table_state_to_browser, { payload })` line at sagas.js:76 inside `data_view_changed`. Persistence is now exclusively driven by the debounce watcher; leaving the in-body call in place produces a double-persist (immediate + 250ms-debounced) for every `DATA_VIEW_CHANGED`.
    - `persist_table_state_to_browser`: drop the `JSON.stringify` equality; pass `change_type` and `is_new_view` through from the action payload to `save_snapshot`. All gating (default view, empty new view, invalid state) lives in `save_snapshot`.
    - `mark_server_save_in_history` (existing function at line 386-405): switch its `data_view_browser_storage_save_snapshot` call to `save_snapshot({ view_id, table_state, change_type: 'server_save', is_new_view: false })`.
    - `persist_selected_view_to_browser` (line 277-287): switch to `save_last_active_view(data_view_id)`.
    - `restore_last_active_view_if_default` (around line 351): switch to `load_last_active_view()`.
    - `restore_view_states_from_browser`: use `load_latest_snapshot`; for the active view, compare `snapshot.table_state` against the Redux `saved_table_state` for that view_id using the same plain-JS deep-equal helper as the selector, and skip the dispatch entirely when they match (prevents startup write amplification). Existing `view_state_changed: false` suppression on the dispatch payload remains for non-active views.
    - `cleanup_browser_state_on_delete`: replace body with `yield call(clear_view, view_id)`.
    - Add `watch_reconcile_server_views` on `GET_DATA_VIEWS_FULFILLED` → `reconcile_server_views({ server_view_ids, redux_view_ids })` where `redux_view_ids` is `state.get('data_views').keySeq().toArray()` — this preserves client-generated unsaved new views from being evicted.
    - Add `watch_revert_data_view` on `REVERT_DATA_VIEW` → `clear_view(view_id)` (the reducer handles the state reset).
    - Add `watch_clear_local_view_cache` on the new `CLEAR_LOCAL_VIEW_CACHE` action → `clear_all()`.
    - REGISTER all new watchers in the `data_views_sagas` export array at sagas.js:516. Add entries: `fork(watch_reconcile_server_views)`, `fork(watch_revert_data_view)`, `fork(watch_clear_local_view_cache)`, and the debounce effect `debounce(250, DATA_VIEW_CHANGED, persist_table_state_to_browser)`. Forgetting to register the new watchers ships dead code that never runs.
    - Debounce behavior on view switch: clicking view B dispatches `SET_SELECTED_DATA_VIEW` (not `DATA_VIEW_CHANGED`), so a pending 250ms persist timer for view A's last `DATA_VIEW_CHANGED` continues uninterrupted and fires correctly with view A's payload. This is the intended behavior; do not add view-switch awareness to the debounce.

### Reducer

- [x] **`app/core/data-views/reducer.js`**
  - Purpose: handle explicit revert action; point validation at the shared validator. This task must land BEFORE `browser-storage.mjs` is deleted.
  - Changes:
    - Replace line 7 import `from './browser-storage.mjs'` with `from '#libs-shared/data-view-storage/validate.mjs'` for `is_valid_table_state`. Behavior of the `DATA_VIEW_CHANGED` guard unchanged from hardening.
    - Handle `REVERT_DATA_VIEW`: when `saved_table_state` is non-null, set `table_state` to it for the view_id. When `saved_table_state` is null (new views have `saved_table_state: null` per `react-table/.../table-view-controller.js:200`), no-op. Preserve existing plain-JS storage of `table_state` (explicitly commented at reducer.js:9-10) — do not wrap in Immutable. The UI task below also disables the Reset button on new views as a user-visible guard.

### UI Affordances

- [x] **Unsaved-edit indicator, Reset wiring, default-view Save disable, clear-cache menu**
  - Landed: `ViewItem` badge in react-table (reads `view.has_unsaved_local_edits`), `get_has_unsaved_local_edits_map` selector + container wiring, Reset-button revert wiring via a new `on_revert_view` prop threaded through react-table's `Table` (existing `Boolean(saved_table_state) && ...` gate already disables Reset on new views).
  - Deferred as UI polish: (a) default/system-view Save button disabled-with-tooltip affordance -- today Save is hidden when `is_selected_view_editable` is false, which already prevents the silent-reject bug, but the explicit "Duplicate this view to save your changes" tooltip is not wired; (b) "Clear local view cache" menu item with confirmation dialog -- the `clear_local_view_cache()` action creator, saga watcher, and `clear_all()` primitive all ship in this change, only the UI entry point is absent.
  - Files span TWO repositories because react-table is a user-owned sibling repo (not a vendored npm package):
    - `repository/active/league/app/views/pages/data-views/data-views.js` and `index.js` — container wiring and toolbar changes.
    - `repository/active/react-table/src/table-view-controller/table-view-controller.js` — `ViewItem` badge rendering. This is the canonical source; `league/node_modules/react-table` is its build output. Follow react-table's own commit/publish flow (see `repository/active/react-table/CLAUDE.md`) to ship the change, then bump the league's `react-table` dep.
  - Purpose: surface unsaved local edits in the view list; close default-view Save loop; expose a manual cache clear.
  - Changes in `repository/active/react-table/src/table-view-controller/table-view-controller.js`:
    - Extend `ViewItem` to render a small dot span next to `{view.view_name}` when `view.has_unsaved_local_edits` is truthy. Matches the existing precedent where `ViewItem` reads `view.is_editable` as a view-object field. No new props on `TableViewController` itself.
    - Optional accompanying CSS class in `table-view-controller.styl`.
  - Changes in `repository/active/league/app/views/pages/data-views/`:
    - In the container's `map_state_to_props`, compute a plain `{ [view_id]: boolean }` map via `get_has_unsaved_local_edits_map(state)` and merge into each view passed to `TableViewController` as `view.has_unsaved_local_edits`. No per-row connected component needed.
    - Reset button dispatches `revert_data_view({ view_id })` (action creator) instead of calling `on_view_change` with `saved_table_state`. Disable the button when `saved_table_state == null` (new views) so there is no visible action that would silently no-op.
    - When selected view is default/system-owned (`view_username === 'system'`), disable Save and show tooltip "Duplicate this view to save your changes" (wire to existing duplicate action).
    - Add menu item "Clear local view cache" that opens a confirmation dialog and dispatches `clear_local_view_cache()`.

- [x] **`app/core/data-views/actions.js`**
  - Purpose: action creators for `REVERT_DATA_VIEW` (distinct from existing `RESET_DATA_VIEW_CACHE` server-refetch at line 30) and `CLEAR_LOCAL_VIEW_CACHE`.
  - Changes:
    - Add constants `REVERT_DATA_VIEW` and `CLEAR_LOCAL_VIEW_CACHE` to the action enum.
    - Export `revert_data_view({ view_id })` and `clear_local_view_cache()` creators.

### Selectors

- [x] **`app/core/selectors.js`**
  - Purpose: expose unsaved-edit status for the UI indicator.
  - Changes:
    - Add `get_has_unsaved_local_edits_map(state)` returning a plain `{ [view_id]: boolean }` object. Iterate `state.get('data_views')` once; for each view, short-circuit to `false` when `DEFAULT_VIEW_IDS.has(view_id)`; otherwise plain-JS structural deep-equal between `table_state` and `saved_table_state` (per the reducer.js:9-10 plain-JS comment; `Immutable.is` would be wrong). Wrap the map computation in a single `createSelector(get_data_views, ...)` so it only recomputes when the `data_views` Immutable reference changes. The container looks up `map[view.view_id]` when assembling props — no per-row selector, no factory.
    - Rejected alternative: per-instance `createSelector` factory (proposed in an earlier revision). That only pays off if the view list is split into per-row connected components, which it is not. A single-map selector is simpler and matches how the container currently computes props.
    - Add a small `deep_equal.mjs` helper at `app/core/utils/deep_equal.mjs` (co-located with `local-storage.js`, `fuzzy-search.js`, and other standalone utilities). Do NOT import from `lodash.isEqual` — lodash is only a transitive dep in this repo, not a direct dependency.
    - Leave `get_selected_data_view` fallback chain unchanged.

### Testing & Validation

- [x] **Relocate and unbreak the storage spec**
  - Use `git mv test/data-views/browser-storage.spec.mjs test/data-view-storage/storage.spec.mjs` to preserve file history.
  - Imports switch to `#libs-shared/data-view-storage/storage.mjs` and sibling modules.
  - Cover: save rejects invalid table*state; skips default ids; skips `is_empty_new_view && is_new_view`; existing saved view edited to zero columns IS still persisted (regression guard); `load_latest_snapshot` runs migrations and writes back when migrated; quota exceeded triggers LRU + full retry (not collapse); on repeat quota failure the injected `on_quota_exceeded` callback is invoked; `reconcile_server_views` evicts missing ids except `DEFAULT_VIEW_IDS`; `clear_all` removes `data_view_history*\*`keys,`data_view_metadata`, and `data_view_last_active`; `save_last_active_view`/`load_last_active_view` round-trip.

- [x] **`test/data-view-storage/migrations.spec.mjs` (new)**
  - Cover: `run_migrations` idempotent; `Object.freeze`-d input does not throw and is not mutated; v0→v1 produces expected nfl_week_id shape; snapshot already at `STORAGE_SCHEMA_VERSION` passes through unchanged; a v0 snapshot without a `version` field is treated as version 0.
  - Create `test/data-view-storage/` directory first (`mkdir -p`); `git mv` in the earlier task requires the target dir to exist.

- [x] **`test/data-views/sagas.spec.mjs` (new or extend existing) — saga-level coverage**
  - Cover: `restore_view_states_from_browser` skips dispatch when snapshot's `table_state` deep-equals the Redux `saved_table_state` for the active view; dispatches when they differ.
  - Cover: `watch_reconcile_server_views` evicts a view_id present in storage but absent from both `server_view_ids` and `redux_view_ids`; preserves a view_id present in `redux_view_ids` but absent from `server_view_ids` (client-only unsaved view).
  - Cover: `mark_server_save_in_history` routes through the new `save_snapshot` with `change_type: 'server_save'`, `is_new_view: false`.

- [x] **Manual verification checklist**
  - New view with no columns does not survive reload.
  - Editing a default view shows disabled Save with tooltip; edit is lost on reload (expected) with the toolbar making the discard visible.
  - Deleting a view on device A (simulated by editing localStorage or via the API) and reloading device B evicts the stale entry.
  - Clear local view cache menu item wipes all `data_view_history_*` keys and metadata; leaves server state intact.
  - Keystroke on a filter in a large view no longer triggers four synchronous localStorage operations per keystroke (verify via Performance panel).

### Configuration

- [x] **Verify `#libs-shared/*` wildcard is unchanged**
  - Existing `package.json` line 45: `"#libs-shared/*": "./libs-shared/*"` already resolves the new subdirectory for Mocha/Node. No change required; this task is a verification-only checkbox.

- [x] **`webpack/webpack.config.base.mjs` — no change required**
  - Correct path is `webpack/webpack.config.base.mjs` (not `webpack.config.mjs` at the repo root). The existing `@libs-shared/data-views-nfl-week-migration.mjs` per-file alias there remains because that file stays in place. Document the decision in the commit message; revisit if a later cleanup relocates migrations under `data-view-storage/`.

### Knowledge Base Updates

- [x] **Create `user:text/league/data-view-storage-architecture.md`**
  - Content: snapshot format with version; migration-registry contract ("add one v(N+1) entry and bump `STORAGE_SCHEMA_VERSION`"); TTL + LRU policy and numbers; reconcile-on-`GET_DATA_VIEWS_FULFILLED` behavior; default-view exclusion rules; write-gating rules; quota-recovery path and the `store_registry.getStore().dispatch(...)` toast.
  - Include a "Module locations" section with concrete file paths so a cold-context reader knows where to open:
    - `libs-shared/data-view-storage/storage.mjs` — public API (save/load/reconcile/clear)
    - `libs-shared/data-view-storage/validate.mjs` — `is_valid_table_state`, `is_empty_new_view`
    - `libs-shared/data-view-storage/migrations.mjs` — `STORAGE_SCHEMA_VERSION`, migration registry, `run_migrations`
    - `libs-shared/data-views-nfl-week-migration.mjs` — v0→v1 implementation wrapped by the registry
    - `app/core/data-views/sagas.js` — saga consumers (`persist_table_state_to_browser`, `restore_view_states_from_browser`, `reconcile_server_views`, `on_quota_exceeded` wiring)
  - Tags: `user:tag/league-xo-football.md`.
  - Relations: `describes [[user:repository/active/league]]`; cross-ref `[[███████████████████████-██████-███████████████]]`.
  - Observations: `[contract] Breaking table_state changes MUST bump STORAGE_SCHEMA_VERSION and add a v{N} migration module.`
  - Use `base entity create` with `--content-file`.

- [x] **Update `user:text/league/ABOUT.md`**
  - Add a bullet in the Notable Context section pointing to the new entity with context phrase "load when modifying data-view persistence, migrations, or storage UX".

- [x] **Add top-of-file comment in `libs-shared/data-view-storage/migrations.mjs`**
  - Short pointer to `user:text/league/data-view-storage-architecture.md` and the bump-and-add contract. No CLAUDE.md additions.
