diff --git a/.logs/goal-progress.md b/.logs/goal-progress.md index 8db63f7..5dde084 100644 --- a/.logs/goal-progress.md +++ b/.logs/goal-progress.md @@ -1,5 +1,126 @@ # Sprint 2 Goal Progress +## 2026-05-14 20:51 - Sprint 3.5 Milestone 1 Inventory + +Workspace: `/Users/tkevinbigham/MBD-main` +Branch: `goal/sprint-3-5-hard-reload-survival` + +Files inspected: + +- Root orientation: `README.md`, `CHANGELOG.md`, `MASTER_CONTEXT.md`, prior `STATUS.md`, `GOAL.md`. +- Boot/store/routing: `apps/web/src/shared/hooks/useGameStore.ts`, `apps/web/src/app/App.tsx`, `apps/web/src/app/layout/AppLayout.tsx`, `apps/web/src/app/routes/index.tsx`. +- Save and worker path: `apps/web/src/shared/lib/saveSystem.ts`, `apps/web/src/shared/hooks/useWorker.ts`, `apps/web/src/workers/sim.worker.actions.ts`. +- Manual load reference: `apps/web/src/features/setup/routes/SetupPage.tsx`, `apps/web/src/features/setup/routes/SetupPage.test.tsx`. +- Save Recovery reference: `apps/web/src/features/save-recovery/SaveRecoveryProvider.tsx`, `SaveLoadErrorBoundary.tsx`, `SaveRecoveryDialog.tsx`, and save-recovery tests. + +Current manual continue sequence: + +- Save Hub root save path calls `loadSaveSafely(slot)`. +- On `{ ok: false }`, it calls `SaveRecoveryProvider.showFailure(...)` with delete/retry callbacks. +- On `{ ok: true }`, it calls `worker.importSnapshot(result.snapshot)`. +- On import success, it calls `useGameStore.initializeGame(...)` with worker-returned season/day/phase/player/team fields plus `activeSaveId: save.id` and `activeSaveSlot: save.slotNumber`, then navigates to `/dashboard`. +- Branch saves use `inspectSaveById(save.id)` then the same `worker.importSnapshot` and `initializeGame` shape. + +Sprint 3.5 implementation map: + +- Persist only the allowed useGameStore shell fields under `mbd:game-store@v1`. +- Add an app boot gate that blocks route rendering with a "Resuming save..." skeleton when an active save id is present but the store is not initialized. +- Auto-load with `loadSaveSafely(activeSaveId)`, `worker.importSnapshot(snapshot)`, then `initializeGame(...)` so `AppLayout` never redirects hard-reloaded in-game routes to `/`. +- Missing save ids clear the active save id and fall through to the Save Hub. +- Corrupt saves call Save Recovery through the existing provider path. + +Files changed: + +- `.logs/goal-progress.md` + +Checks run: + +- Inventory only. Next: add red tests for persistence and boot auto-resume. + +## 2026-05-14 20:52 - Sprint 3.5 Milestone 2 Red Tests + +Files changed: + +- `apps/web/src/shared/hooks/useGameStore.test.ts` +- `apps/web/src/app/boot/AppBootGate.test.tsx` + +Red test proof: + +- `PATH=/Users/tkevinbigham/.local/node-lts/bin:$PATH pnpm --filter @mbd/web test src/shared/hooks/useGameStore.test.ts src/app/boot/AppBootGate.test.tsx` -> FAIL as expected. +- Failure 1: `useGameStore` did not expose `GAME_STORE_STORAGE_KEY` / versioned persisted state, so `persisted.version` was `undefined`. +- Failure 2: `apps/web/src/app/boot/AppBootGate.tsx` did not exist, proving the missing boot gate. + +## 2026-05-14 20:54 - Sprint 3.5 Milestone 3 Persisted Store + Boot Gate + +Files changed: + +- `apps/web/src/shared/hooks/useGameStore.ts` +- `apps/web/src/shared/hooks/useGameStore.test.ts` +- `apps/web/src/app/boot/AppBootGate.tsx` +- `apps/web/src/app/boot/AppBootGate.test.tsx` +- `apps/web/src/app/App.tsx` +- `apps/web/src/app/App.test.tsx` + +Implementation: + +- Added Zustand `persist` middleware to `useGameStore` under `mbd:game-store@v1`. +- `partialize` persists only `activeSaveId`, `activeSaveSlot`, `userTeamId`, `season`, `day`, `phase`, `teamName`, `gmName`, and `difficulty`. +- Added `AppBootGate` around the router. When a persisted active save id exists and the app is not initialized, it blocks route rendering with `Resuming save...`, runs `loadSaveSafely(activeSaveId)`, imports the snapshot through `worker.importSnapshot`, and calls `initializeGame(...)` before `AppLayout` can redirect. +- Missing save ids clear `activeSaveId`/`activeSaveSlot` and fall through to Save Hub. +- Corrupt save load results go to `SaveRecoveryProvider.showFailure(...)`; worker/import exceptions synthesize a `storage_failed` recovery failure and clear stale active-save state. + +Focused validation: + +- First focused run after implementation failed only because the store test replaced `localStorage` after Zustand captured the original jsdom storage object. +- Fixed the test to use jsdom's actual `window.localStorage`. +- `PATH=/Users/tkevinbigham/.local/node-lts/bin:$PATH pnpm --filter @mbd/web test src/shared/hooks/useGameStore.test.ts src/app/boot/AppBootGate.test.tsx src/app/App.test.tsx` -> PASS. 3 files / 8 tests. + +## 2026-05-14 20:58 - Sprint 3.5 Milestone 4 Verification Gate + +Validation: + +- `PATH=/Users/tkevinbigham/.local/node-lts/bin:$PATH pnpm typecheck` -> PASS. Turbo reported `Tasks: 9 successful, 9 total` in `5.418s`. +- `PATH=/Users/tkevinbigham/.local/node-lts/bin:$PATH pnpm test` -> PASS. Turbo reported `Tasks: 8 successful, 8 total` in `1m23.712s`; web passed 101 files / 629 tests, sim-core passed 137 files / 1610 tests, contracts passed 1 file / 20 tests, UI passed 1 file / 1 test. Existing non-fatal console noise remained: Recharts zero-size warnings, React `act(...)` warnings, service worker failure-test log, and the existing ScoutingPage mock-function log. +- `PATH=/Users/tkevinbigham/.local/node-lts/bin:$PATH pnpm build` -> PASS. Turbo reported `Tasks: 5 successful, 5 total` in `4.134s`; Vite built in `3.28s`; PWA precached 120 entries. + +## 2026-05-14 21:03 - Sprint 3.5 Milestone 5 Browser Smoke + +Dev server: + +- `PATH=/Users/tkevinbigham/.local/node-lts/bin:$PATH pnpm --filter @mbd/web dev` -> PASS at `http://localhost:5173/MBD/`. + +In-app Browser smoke: + +- Continued existing Slot 1 from Save Hub to `/MBD/dashboard`. +- Hard reload `/MBD/dashboard` -> stayed on dashboard, not Save Hub. +- Hard reload `/MBD/news` -> stayed on News Inbox, not Save Hub. +- Hard reload `/MBD/roster` -> stayed on Roster, not Save Hub. +- Hard reload `/MBD/trade` -> stayed on Trade Center, not Save Hub. +- Hard reload `/MBD/draft` -> stayed on Draft Room, not Save Hub. +- Deleted the active Slot 1 save through Save Hub, then navigated to `/MBD/dashboard`; stale persisted id cleared and the app fell through to Save Hub. + +Screenshots: + +- `apps/web/docs/screenshots/sprint-3-5/01-dashboard-before-hard-reload.png` +- `apps/web/docs/screenshots/sprint-3-5/02-dashboard-after-hard-reload.png` +- `apps/web/docs/screenshots/sprint-3-5/03-news-before-hard-reload.png` +- `apps/web/docs/screenshots/sprint-3-5/04-news-after-hard-reload.png` +- `apps/web/docs/screenshots/sprint-3-5/05-roster-after-hard-reload.png` +- `apps/web/docs/screenshots/sprint-3-5/06-trade-after-hard-reload.png` +- `apps/web/docs/screenshots/sprint-3-5/07-draft-after-hard-reload.png` +- `apps/web/docs/screenshots/sprint-3-5/08-save-hub-after-delete-slot.png` +- `apps/web/docs/screenshots/sprint-3-5/09-missing-save-fallback-save-hub.png` +- `apps/web/docs/screenshots/sprint-3-5/10-corrupt-save-recovery-dialog.png` +- `apps/web/docs/screenshots/sprint-3-5/11-dashboard-mobile-375-after-hard-reload.png` + +Additional Playwright evidence: + +- Exact `localStorage` snapshot before and after dashboard hard reload stayed the same: + `{"state":{"activeSaveId":"save-slot-1","activeSaveSlot":1,"userTeamId":"nym","season":1,"day":1,"phase":"preseason","teamName":"New York Tycoons","gmName":"Mobile Smoke","difficulty":"standard"},"version":1}` +- 375x667 dashboard hard reload metrics: `innerWidth=375`, `innerHeight=667`, `clientWidth=375`, `scrollWidth=375`, `hasSaveHub=false`, `hasDashboard=true`. +- Corrupt persisted save proof: injected a malformed `save-slot-corrupt` record and matching localStorage id; reload showed the recovery dialog actions and cleared persisted active save to: + `{"state":{"activeSaveId":null,"activeSaveSlot":null,"userTeamId":"nym","season":1,"day":1,"phase":"preseason","teamName":"New York Tycoons","gmName":"Smoke Tester","difficulty":"standard"},"version":1}` + Workspace: `/Users/tkevinbigham/MBD-main` Branch: `goal/sprint-2-revised-onboarding` Date: 2026-05-14 diff --git a/GOAL.md b/GOAL.md index f0a969d..07a2fbb 100644 --- a/GOAL.md +++ b/GOAL.md @@ -1,51 +1,32 @@ -# GOAL.md — Sprint 3: News Inbox +# GOAL.md — Sprint 3.5: Hard-reload state survival > Single-mission contract for Codex (or any one-shot coding agent). > Format: Goal Packet v2.0 — Kevin's one-shot ritual. -> Builds on Sprint 1 (cleanup) + Sprint 2 (Revised onboarding canonical), both already merged to `main`. +> Built on top of Sprint 3 ([PR #76](https://github.com/KevinBigham/MBD/pull/76)). Will rebase onto `main` once Sprint 3 merges. ## Mission -Build a **News inbox** feature at `/news` that surfaces the worker-backed news feed the audit found unwired: `getNews(limit?)` and `markNewsRead(newsId)` are exposed by `useWorker()` and powered by `packages/sim-core/src/narrative/newsFeed.ts`, but no UI consumes them today. SettingsPage shows the unread queue count and that is the only surface. +When a user hard-reloads any in-game route (`/dashboard`, `/roster`, `/trade`, `/draft`, `/news`, etc.), they are currently redirected to the Save Hub because `apps/web/src/app/layout/AppLayout.tsx:446` checks `useGameStore().isInitialized` and `useGameStore` is a plain Zustand store with no persistence. The user's read state, unread count, and current view are preserved in IndexedDB but **invisible behind the redirect**. -Ship a route, a list UI, type/category filtering, read-state writes, a Sidebar nav entry, and an unread badge in the TopBar. +Fix it. Persist enough of `useGameStore` to `localStorage` (via Zustand `persist` middleware) so the active save id/slot survive a reload. On app boot, if the persisted save id resolves to a real IndexedDB record, auto-load it through the worker and call `initializeGame()` **before** `AppLayout`'s guard fires. While auto-loading, show a loading state. On error, clear the persisted state and fall through to Save Hub as today. Stop only when every item in **Done When** is satisfied or a **Pause Condition** is hit. ## Background -`NewsItem` (from `packages/contracts/src/schemas/narrative.ts`) carries: - -```ts -{ - id: string; - headline: string; // min 1 char - body: string; - priority: 1 | 2 | 3 | 4 | 5; - category: NewsCategory; // 21 enum values - tag?: NewsTag; // BREAKING | ANALYSIS | RECAP | RUMOR | WATCH | DEBATE - timestamp: string; - relatedPlayerIds: string[]; - relatedTeamIds: string[]; - read: boolean; -} -``` - -Categories: `injury`, `trade`, `signing`, `extension`, `qualifying_offer`, `coaching`, `draft`, `milestone`, `performance`, `standings`, `roster_move`, `development`, `rumor`, `rivalry`, `award`, `record`, `playoff`, `arbitration`, `holdout`, `press_conference`, `league_event`. +Sprint 3's STATUS.md identified this as pre-existing app-wide behavior surfaced during news-inbox testing: -Worker surface: +> `AppLayout` returns `` whenever `useGameStore().isInitialized` is false after a browser reload. `useGameStore` is a plain Zustand store with no persistence middleware, so `isInitialized` resets to `false` on every hard reload. This redirect fires on **every** in-game route — `/dashboard`, `/roster`, `/trade`, `/news` — not just news. -- `getNews(limit: number = 50)` — returns `NewsItem[]` from current save state. -- `markNewsRead(newsId: string)` — flips `read: true` on the targeted item and the worker persists through the existing flow. +Sprint 2's `BrowserRouter basename` fix solved URL parsing (`/MBD/news` now resolves to the `/news` route). Sprint 3.5 solves the next layer up: **state hydration**, so the resolved route actually renders. -Both methods are already exposed in `apps/web/src/shared/hooks/useWorker.ts`. +IndexedDB save state is already correct — the bug is purely in the boot sequence. ## Baseline -- `main` HEAD: `77e5513` (Sprint 2 — Revised onboarding becomes canonical, merged via PR #75). -- Sprint 1 + Sprint 2 are both on `main`. This branch is based directly on the post-Sprint-2 main. +- This branch is built on top of Sprint 3 (`goal/sprint-3-news-inbox`, PR #76). When Sprint 3 merges to `main`, **rebase this branch onto the new main** before continuing: `git fetch origin && git rebase origin/main`. - Save schema: `CURRENT_GAME_SNAPSHOT_VERSION = 33`. Do not bump. -- Test counts after Sprint 2: 97 web / 137 sim-core / 1 contracts files, ~2,250 tests passing. +- Test counts post-Sprint-3: web 99 files / 624 tests; sim-core 137/1610; contracts 1/20; UI 1/1. ## Read first @@ -54,94 +35,92 @@ Inspect these before editing. Do not skip. **Repo orientation:** - `README.md`, `CHANGELOG.md`, `MASTER_CONTEXT.md` - `GOAL.md` (this file) -- Previous `STATUS.md` if still present (Sprint 2's report) - -**Data contract:** -- `packages/contracts/src/schemas/narrative.ts` lines 1–60 (NewsPriority, NewsTag, NewsCategory, NewsItem) — PROTECTED, read-only - -**Worker surface:** -- `apps/web/src/workers/sim.worker.queries.ts` — find `getNews(limit: number = 50)` (around line 2666) — PROTECTED -- `apps/web/src/workers/sim.worker.actions.ts` — find `markNewsRead(newsId: string)` (around line 2862) — PROTECTED -- `apps/web/src/shared/hooks/useWorker.ts` — confirm both methods are already exposed; the `mutationMethods` Set should already include `markNewsRead` - -**App shell (integration points):** -- `apps/web/src/app/routes/index.tsx` — route table where `/news` slots in alongside the other lazy-loaded route components -- `apps/web/src/app/layout/Sidebar.tsx` — `NAV_ITEMS` array around lines 45–73; that's where the News entry goes. Press Room already owns the `Newspaper` icon — pick a different lucide-react icon (e.g. `Inbox`, `Mail`, `Bell`) -- `apps/web/src/app/layout/TopBar.tsx` — where the unread badge mounts. The component already imports `Link` and renders a help icon button on the right; an unread chip fits cleanly there or as a small dot/count next to the Sidebar nav entry - -**Existing patterns to lean on:** -- `apps/web/src/features/press-room/routes/PressRoomPage.tsx` — feed-style list with worker data -- `apps/web/src/features/history/routes/HistoryPage.tsx` — large feed with filter tabs and grouped sections -- `apps/web/src/features/records/routes/RecordWatchPage.tsx` — alert-style list -- `apps/web/src/features/dashboard/components/RecentMomentsCard.tsx` — compact list summary -- `apps/web/src/shared/components/PageShell.tsx` — page primitive used by every route -- `apps/web/src/shared/components/EmptyStatePanel.tsx` — empty-state primitive - -**Settings reference for the existing count surface:** -- `apps/web/src/features/settings/routes/SettingsPage.tsx` around line 945 (`diagnostics.queues.newsItems` — currently the only news surface) - -**Useful tests for shape:** -- `packages/sim-core/tests/newsFeed.test.ts` and any other `news*`-named tests under `packages/sim-core/tests/` — read these to understand what news items look like in practice (categories, priority distribution, ordering) +- Sprint 3's `STATUS.md` — confirms the architectural diagnosis + +**Core state + boot path:** +- `apps/web/src/shared/hooks/useGameStore.ts` — the Zustand store you'll add `persist` middleware to +- `apps/web/src/app/App.tsx` — the BrowserRouter shell where boot logic lands (Sprint 2 added the `basename`; build on top of that) +- `apps/web/src/app/layout/AppLayout.tsx` — confirms the `if (!isInitialized) return ` redirect at line 446 and the worker-readiness gate above it +- `apps/web/src/app/routes/index.tsx` — confirms `/` is `SetupPage` and every in-game route is nested under `AppLayout` +- `apps/web/src/shared/lib/saveSystem.ts` — `loadSaveSafely`, `inspectSaveById`, `listSaves`, the Dexie schema. This is the I/O layer you'll call during boot. +- `apps/web/src/shared/hooks/useWorker.ts` — the worker bridge. You'll use `importSnapshot` (or whatever the canonical "load this save into the worker" method is — confirm from current code) to hydrate the worker before flipping `isInitialized`. +- `apps/web/src/features/setup/routes/SetupPage.tsx` — the Save Hub path that today calls `loadGameById` → worker hydrate → `initializeGame`. Read it to understand the **exact** sequence the auto-resume must mimic. + +**Save recovery (DO NOT break):** +- `apps/web/src/features/save-recovery/SaveRecoveryProvider.tsx` +- `apps/web/src/features/save-recovery/SaveLoadErrorBoundary.tsx` +- `apps/web/src/features/save-recovery/SaveRecoveryDialog.tsx` +- `apps/web/src/features/save-recovery/__tests__/` (if present) + +The recovery dialog already handles corrupt-save cases. Auto-resume must use the same path (`loadSaveSafely → SaveRecoveryProvider.showFailure on `{ ok: false }`) so corrupt saves don't crash the auto-load. + +**Tests to study:** +- `apps/web/src/app/App.test.tsx` +- `apps/web/src/app/layout/AppLayout.test.tsx` +- `apps/web/src/features/setup/routes/SetupPage.test.tsx` — covers continue-existing-save; your auto-resume should produce the same end-state without a manual click +- `apps/web/src/shared/lib/saveSystem.test.ts` ## Product contract -Ship the smallest complete feature that: +Build the smallest complete fix that: -1. Exposes `/news` as a lazy-loaded route under `AppLayout` (just like every other feature route). -2. Lists worker-backed news items, newest first (timestamp descending; secondary sort by priority descending where timestamps tie). -3. Renders each item with: headline, short body excerpt, category badge, priority indicator (1–5), optional tag chip, timestamp, related team/player chips when present, and a clear read/unread visual. -4. Provides a filter UI: an "All / Unread" toggle and a category filter (multi-select chips or a select). Filtering happens client-side over the worker's returned list. -5. Marks an item read when the user opens/expands/clicks it — calls `markNewsRead(id)` and reflects the new state without a hard refetch. -6. Adds a Sidebar nav entry (`{ to: '/news', label: 'News', icon: }` or similar — pick a lucide icon that is **not** `Newspaper`, since Press Room owns that). -7. Shows an unread-count badge in the TopBar that updates after `markNewsRead`. Keep it deterministic (no polling). Drive it off the same `getNews()` query the page uses, or expose a derived count via React state at the layout level — your call as long as it stays in sync after `markNewsRead`. -8. Covers loading / empty / error states. -9. Is mobile-survivable at 375x667 viewport (Bloomberg-dense but readable; no horizontal scroll on the list). +1. **Persists**: `useGameStore` exposes `activeSaveId`, `activeSaveSlot`, `userTeamId`, `season`, `day`, `phase`, `teamName`, `gmName`, `difficulty` to `localStorage` via Zustand's `persist` middleware. **Do not persist** `isInitialized`, `isSimulating`, `playerCount`, `gamesPlayed`, or any function-typed fields — those derive from the worker after hydration. +2. **Auto-resumes on boot**: When the React tree mounts, if `useGameStore.activeSaveId` is non-null AND a save with that id exists in IndexedDB, kick off an auto-load. The auto-load: + - Calls `loadSaveSafely(activeSaveId)`. + - On `{ ok: true }`, calls `worker.importSnapshot(save.snapshot)` (or the equivalent canonical method), then `useGameStore.initializeGame(...)` with the post-import worker state. + - On `{ ok: false }`, hands off to `SaveRecoveryProvider.showFailure` (same path Save Hub uses). + - On any worker/IndexedDB error, clears the persisted state and falls through to Save Hub. Toast the user briefly. +3. **Loading state**: While the auto-load is in flight, show a route-level "Resuming…" skeleton (NOT the Save Hub). When done, the user lands on the route they hard-reloaded into. If they hard-reloaded `/news`, they end up on `/news`. If they hard-reloaded `/dashboard`, they end up on `/dashboard`. +4. **First-time and post-clear behavior unchanged**: If `useGameStore.activeSaveId` is null (no prior save, or persisted state cleared), the app boots to Save Hub as today. +5. **SaveHub still wins manual loads**: When the user navigates to `/` and clicks a save, the existing flow runs and updates the persisted store. No regression to the manual continue path. Prefer: -- working over broad — get one list + one detail expansion + filtering right before adding clever flourishes; -- composition over new layout primitives; -- reuse of existing styled chips/badges from `@mbd/ui` and shared components; -- no new dependencies. +- working over broad — get one hard-reload route (e.g. `/news`) surviving end-to-end before declaring victory across all routes; +- composition over invention — reuse `loadSaveSafely`, `SaveRecoveryProvider`, existing worker methods; +- no new dependencies; +- the smallest diff that satisfies the contract. ## Allowed write scope Write only inside: -- `apps/web/src/features/news/**` — new feature directory -- `apps/web/src/app/routes/index.tsx` — add `/news` route entry -- `apps/web/src/app/layout/Sidebar.tsx` — add News nav item -- `apps/web/src/app/layout/TopBar.tsx` — add unread-count badge (smallest possible diff) -- `apps/web/src/shared/hooks/useWorker.ts` — only if you genuinely need a derived helper (the underlying methods are already exposed; you should NOT need to edit this file for the core flow) +- `apps/web/src/shared/hooks/useGameStore.ts` — add `persist` middleware, declare the persisted slice, write the persistence config +- `apps/web/src/app/App.tsx` — add the boot-time auto-resume hook + a route-level loading state. May extract a small `` wrapper component if the diff stays small +- `apps/web/src/app/layout/AppLayout.tsx` — minimal change only if necessary (e.g. to render `` once `isInitialized` flips). If you can avoid touching this file, prefer that. +- New files under `apps/web/src/app/boot/` (e.g. `useAutoResumeSave.ts`, `AppBootGate.tsx`) if needed - Test files matching the above paths - `.logs/goal-progress.md` -- `STATUS.md` (replace at end with the Sprint 3 report) -- `GOAL.md` (this file — minor edits only if absolutely necessary) -- `apps/web/docs/screenshots/sprint-3/` — browser-smoke evidence +- `STATUS.md` (rewrite for Sprint 3.5) +- `GOAL.md` (this file — minor edits only) +- `apps/web/docs/screenshots/sprint-3-5/` — browser smoke evidence ## Protected scope Do not modify: -- `packages/sim-core/**` — news generation is the source of truth; consume through the worker -- `packages/contracts/**` — `NewsItem` schema stays v33; no bump -- `apps/web/src/workers/**` — the worker surface is already correct; no changes needed -- `apps/web/src/features//**` — including Press Room, History, Dashboard, Settings -- `apps/web/src/app/App.tsx` — Sprint 2 just fixed BrowserRouter basename; leave it alone -- `apps/web/src/app/layout/AppLayout.tsx` — only allowed-write layout files are `Sidebar.tsx` and `TopBar.tsx` -- `apps/web/src/shared/components/**` (except via consumption in the new `features/news/` module) -- `apps/web/src/shared/lib/**` +- `packages/sim-core/**` +- `packages/contracts/**` +- `apps/web/src/workers/**` — worker methods stay as-is; you call them, you don't change them +- `apps/web/src/features/save-recovery/**` — the recovery dialog already does the right thing; auto-resume must integrate with it, not modify it +- `apps/web/src/features/setup/**` — Save Hub's manual continue path stays unchanged +- `apps/web/src/features/news/**` — Sprint 3 just shipped; preserve it +- `apps/web/src/features/onboarding/**` — Sprint 2 just shipped; preserve it +- `apps/web/src/shared/lib/saveSystem.ts` — read-only; persistence helpers already exist +- `apps/web/src/shared/lib/audio.ts`, `logger.ts`, `webVitals.ts`, etc. - `.github/**`, `package.json` (root), `turbo.json`, `pnpm-workspace.yaml` -- `apps/web/src/build/bundleConfig.ts`, `apps/web/docs/BUDGETS.md` — preserve worker-chunk budgets and the journal exactly. If your changes push a worker chunk over budget, **pause**: routing news through a different chunk is preferable to lifting the ceiling +- `apps/web/src/build/bundleConfig.ts`, `apps/web/docs/BUDGETS.md` ## Non-negotiables -- **Schema v33.** No bump. No migration. -- **Determinism.** No `Math.random()` in app code. No new RNG paths. -- **No new dependencies.** Stay on the workspace lock. -- **Bloomberg Terminal aesthetic.** No emoji. lucide-react icons only. Match the existing typography stack (Space Grotesk / JetBrains Mono / Bebas Neue) and the `@mbd/design-tokens` palette. -- **Do not delete or weaken tests** to make checks pass. -- **The `/news` route URL is final.** Don't pluralize/rename mid-build. -- **No commits on `main`.** Work only on `goal/sprint-3-news-inbox`. -- **No `git add -A`.** Stage specific files. -- **Press Room is a different feature.** Do not redirect /press-room to /news or vice versa. Both stay. +- **Schema v33.** No bump, no migration. +- **Determinism.** No `Math.random()`. No new RNG paths. +- **No new dependencies.** Zustand's `persist` middleware ships with the `zustand` package already in `apps/web/package.json` — use `import { persist, createJSONStorage } from 'zustand/middleware'`. +- **No emoji.** lucide-react icons only. Bloomberg Terminal aesthetic. +- **Do not delete or weaken tests** to make checks pass. New tests required for the auto-resume flow. +- **Save Recovery must still trigger** on corrupt saves during auto-resume. +- **Manual Save Hub flow must be unchanged.** Existing SetupPage.test.tsx assertions still pass. +- **localStorage namespace**: use a stable key (e.g. `mbd:game-store@v1`). If you ever need to invalidate the persisted shape, bump the suffix — do **not** silently change it. +- **Privacy**: persist only the minimal fields above. Do NOT persist the full snapshot, player data, or any large blob in localStorage — IndexedDB owns the heavy data. +- **No commits on `main`.** Work only on `goal/sprint-3-5-hard-reload-survival`. +- **No `git add -A`.** ## Milestone loop @@ -149,22 +128,27 @@ For each milestone: inspect → state checkpoint → smallest change → smalles Suggested milestones: -1. **Inventory.** Read every file in "Read first." Log: - - exact shape of `NewsItem` and the category enum - - how `getNews` orders / paginates today - - how `markNewsRead` reflects through the worker → save flow (does it require a state refresh, or does the worker emit a flow update?) - - which existing components (Badge, Card, EmptyStatePanel, ResponsiveTable, Skeleton, etc.) to reuse -2. **Scaffold the route.** Add `apps/web/src/features/news/routes/NewsPage.tsx` and the route entry in `app/routes/index.tsx`. Render a loading skeleton, a one-line empty state, and the raw `getNews()` result as a debug list. Wire under `RouteErrorBoundary` like every other route. -3. **List rendering.** Build the per-item row with headline / body excerpt / category badge / priority indicator / tag chip / timestamp / read state. Add the "All / Unread" toggle and the category filter. Newest first. Mobile-survivable. -4. **Mark-read wiring.** Open / expand / click marks the item read via `markNewsRead(id)`. Optimistic UI update; refetch on flow updates if needed. Add a small section-level "mark all visible read" only if it lands inside the diff budget (otherwise skip — focus on the per-item read first). -5. **Sidebar entry + TopBar unread badge.** Add the nav item with a lucide icon that is **not** `Newspaper`. Add an unread count badge in TopBar that pulls from the same `getNews()` query. Update on `markNewsRead`. -6. **Tests.** Add at least: - - one test that renders the page with worker-mocked news items and asserts headlines render - - one test that asserts `markNewsRead` is called when an item is opened - - one test that asserts the unread badge reflects the unread count and decrements after read -7. **Verify gate.** `pnpm typecheck`, `pnpm test`, `pnpm build`. Bundle budget test must still pass. -8. **Browser smoke.** Start a save (or load the existing v33 IndexedDB save), sim a month so news accumulates, walk through /news, mark items read, watch the TopBar badge tick down. Commit screenshots under `apps/web/docs/screenshots/sprint-3/`. -9. **STATUS.md.** Final report (see "Final report" section). +1. **Inventory.** Read every file in "Read first." Document the exact sequence Save Hub uses today to continue a save (which worker method hydrates the snapshot? what order does `initializeGame` get called in?). Map the auto-resume to the same sequence. +2. **Persist `useGameStore`.** Add `persist` middleware with `createJSONStorage(() => localStorage)`. `partialize` to only the persisted slice listed above. Bump the version key if needed. Confirm `useGameStore` still works in tests with a localStorage mock. +3. **Auto-resume hook.** New `useAutoResumeSave` (or in-file hook in `App.tsx`) that runs once at app mount: read `activeSaveId` from the store, if present call `loadSaveSafely`, then hydrate the worker, then call `initializeGame`. Handle the `SaveRecoveryProvider.showFailure` case. Handle generic errors by clearing `activeSaveId` from the store. +4. **Boot loading state.** While the auto-resume is in flight, render a small route-level "Resuming…" skeleton. Do NOT show the Save Hub. Do NOT show the dashboard half-loaded. When done, the route renders normally — react-router preserves the URL across the hydration because we never navigated. +5. **Tests.** Add: + - `App.test.tsx` (or a new `useAutoResumeSave.test.ts`): with a persisted `activeSaveId` and a mock save in IndexedDB, the app hydrates and renders the in-game route, not Save Hub. + - With a persisted `activeSaveId` but no IndexedDB record, the persisted state clears and the user lands on Save Hub. + - With a corrupt save, the SaveRecoveryDialog appears. + - Without a persisted `activeSaveId`, behavior is unchanged (Save Hub). +6. **Verify gate.** `pnpm typecheck`, `pnpm test`, `pnpm build`. +7. **Browser smoke.** + - Start dev server. + - Complete onboarding into Slot 1. + - Land on dashboard. + - Hard reload `/MBD/dashboard` → should land back on dashboard (not Save Hub). + - Navigate to `/news`, hard reload `/MBD/news` → should land back on news. + - Navigate to `/roster`, hard reload → roster. + - Navigate to `/trade`, hard reload → trade. + - Clear IndexedDB → hard reload `/MBD/dashboard` → should land on Save Hub (graceful recovery). + - Capture screenshots for each scenario. +8. **STATUS.md.** Rewrite for Sprint 3.5. Each `.logs/goal-progress.md` entry: timestamp, milestone, files changed, checks run, result, blocker or next step. @@ -179,54 +163,52 @@ PATH=/Users/tkevinbigham/.local/node-lts/bin:$PATH pnpm build PATH=/Users/tkevinbigham/.local/node-lts/bin:$PATH pnpm --filter @mbd/web dev ``` -Targeted, for tight loops: +Targeted: ``` -pnpm --filter @mbd/web test src/features/news -pnpm --filter @mbd/web test src/app/layout/TopBar.test.tsx -pnpm --filter @mbd/web test src/app/layout/Sidebar.test.tsx -pnpm --filter @mbd/web test src/app/routes/index.test.tsx +pnpm --filter @mbd/web test src/app/App.test.tsx +pnpm --filter @mbd/web test src/app/layout/AppLayout.test.tsx +pnpm --filter @mbd/web test src/features/setup/routes/SetupPage.test.tsx +pnpm --filter @mbd/web test src/shared ``` -Browser flow: +Browser flow (full smoke): 1. `pnpm --filter @mbd/web dev` -2. Open `http://localhost:5173/MBD/` (or 5174 if 5173 is taken) -3. New dynasty OR continue an existing save with news already accumulated (sim a month or two) -4. Confirm Sidebar shows "News" entry; click it -5. Confirm /news renders the worker list, sorted newest first -6. Confirm filter chips work (All / Unread / category) -7. Click an unread item; confirm it becomes read; confirm TopBar badge decrements -8. Reload the page; confirm read state survived (it should, because `markNewsRead` writes through the worker → save path) -9. At 375x667 viewport, confirm no horizontal scroll on the list -10. Hard reload at `/MBD/news` (since Sprint 2's BrowserRouter fix should make this just work) +2. Open `http://localhost:5173/MBD/` +3. Complete a new dynasty through onboarding → land on `/dashboard` +4. Hard-reload `/MBD/dashboard` → expect dashboard renders, not Save Hub +5. Navigate to `/news` → confirm the unread count and items match pre-reload state +6. Hard-reload `/MBD/news` → expect news renders, not Save Hub +7. Repeat for `/MBD/roster`, `/MBD/trade`, `/MBD/draft` +8. In DevTools, clear IndexedDB for `mbd-saves` → hard-reload → expect graceful fallback to Save Hub +9. In DevTools, set `localStorage` for `mbd:game-store@v1` to point at a non-existent save id → hard-reload → expect persisted state clears + Save Hub +10. At 375×667 viewport, hard-reload `/MBD/dashboard` → no layout shift larger than a brief skeleton ## Evaluator-visible proof Before declaring done, the transcript and `STATUS.md` must contain: -- Exact commands run with pass/fail result -- Output summaries (test counts, build duration, bundle sizes) -- Browser steps walked, with screenshots committed under `apps/web/docs/screenshots/sprint-3/` -- A diff summary (`git diff --stat origin/main..HEAD`) showing changes stayed inside allowed scope -- Known unrelated failures (if any) with evidence +- Exact commands run and pass/fail +- Output summaries (test counts, build duration, bundle sizes — bundle should be unchanged or tiny) +- Browser steps walked with screenshots under `apps/web/docs/screenshots/sprint-3-5/` +- A `git diff --stat origin/main..HEAD` showing changes stayed inside allowed scope +- localStorage snapshot before/after a reload (key + minimal value, no PII) +- Confirmation that SaveHub manual continue still works (existing SetupPage.test.tsx assertions still pass) ## Autonomy rules -When choosing between two reasonable list designs, pick the one that: -- matches existing repo feed patterns (Press Room, History); -- has the smaller diff; -- avoids new dependencies; -- preserves the most existing tests. +When picking between: +- (a) a `useAutoResumeSave` hook called inside `App.tsx` +- (b) a wrapping `` component -When picking the unread-badge presentation, prefer a small numeric chip (lucide `Inbox` + count) over an emoji-style dot. Match the existing TopBar density. +Pick whichever has the smaller diff in tests too. Both are valid. -When picking the lucide icon for the Sidebar News entry, pick from: `Inbox`, `Mail`, `MailOpen`, `Bell`, `Megaphone`, `MailQuestion`. `Newspaper` is taken by Press Room. +When designing the "Resuming…" loading state, match the existing Suspense fallback (`LoadingFallback` in `apps/web/src/app/routes/index.tsx` — the MBD pulse + "Loading route..." pattern). Don't invent new spinners. -When unsure about read-state propagation: -- Track `read` locally for immediate UI response. -- Call `markNewsRead` and rely on the worker to persist. -- Refetch `getNews()` on `useWorker().subscribeToFlowUpdates` if the worker reports a flow change, otherwise trust local state. +When a persisted save id doesn't resolve in IndexedDB, **clear** `useGameStore` (call a new `clearActiveSave` action or `setActiveSave(null, null)`). Do NOT keep stale ids around. + +When a worker error happens during hydration, route through `SaveRecoveryProvider.showFailure` with a synthetic `{ ok: false, reason: 'storage_failed', detail: ... }` so the user sees the same dialog they'd see from a manual load. Log assumptions in `.logs/goal-progress.md` and continue. @@ -234,78 +216,71 @@ Log assumptions in `.logs/goal-progress.md` and continue. Pause and write the blocker into `STATUS.md` only when: -- A required worker method is missing or behaves differently from what `useWorker.ts` exposes (would require sim.worker / sim-core changes — out of scope). +- The auto-resume requires a sim-core change. +- The auto-resume requires a worker-side method that doesn't exist. +- A persistence-shape change requires bumping save schema (it shouldn't — localStorage is independent of `GameSnapshot`). - The same validation fails 3 times after serious repair attempts. -- A bundle ceiling in `apps/web/src/build/bundleConfig.ts` would have to be lifted to land the feature. Investigate chunk routing first, document the routing decision, and only then pause if a budget bump is genuinely required. -- The unread badge requires modifying `AppLayout.tsx` instead of `TopBar.tsx`. -- A protected file must be modified to make further progress. -- News items don't have a meaningful timestamp field or the ordering cannot be done deterministically without sim-core changes. - -When pausing, do not delete partial work. Document the partial state and the exact blocker. +- A protected file must be modified beyond `AppLayout.tsx`'s minimal touch (and even that touch should be avoided if possible). +- Save Hub's manual continue path breaks and you can't restore parity in 2 fix attempts. ## Done when All of the following are true: -- `/news` route exists, lazy-loaded under `AppLayout`, wrapped in `RouteErrorBoundary` like every other route. -- The page lists worker-backed `NewsItem` objects newest-first. -- Each item renders: headline, body excerpt, category badge, priority, optional tag chip, timestamp, related entity chips when present, and a clear read/unread visual. -- "All / Unread" toggle works. -- Category filter works (multi-select or single-select, your call). -- Clicking / opening an item calls `markNewsRead(id)` and the item moves to read state without a hard reload. -- Sidebar has a `News` entry with a non-`Newspaper` lucide icon. -- TopBar shows an unread-count badge that decrements after items are read. -- Loading, empty, and error states all visible in the page. -- `/MBD/news` hard-reload survives Sprint 2's BrowserRouter basename (should be free). -- Mobile at 375x667 — no horizontal scroll, list is readable. +- `useGameStore` persists to `localStorage` via Zustand `persist` with a versioned key. +- On app boot, if a persisted `activeSaveId` resolves in IndexedDB, the app auto-loads it and renders the user's previous route — **NOT** Save Hub. +- A "Resuming…" loading state shows while auto-load is in flight. +- Corrupt saves trigger `SaveRecoveryDialog` (unchanged from today's manual-load behavior). +- Missing saves clear the persisted state and fall through to Save Hub. +- The Save Hub manual continue path is unchanged (existing tests pass). +- Hard reload at `/MBD/dashboard`, `/MBD/news`, `/MBD/roster`, `/MBD/trade` all render the route, not Save Hub. - `pnpm typecheck` clean (all tasks). -- `pnpm test` clean (no test deleted or weakened; new tests added for the new behavior). -- `pnpm build` clean (every chunk under its ceiling; bundleBudget.test.ts passes; no journal entries in `apps/web/docs/BUDGETS.md` modified). -- Browser smoke walked with screenshots under `apps/web/docs/screenshots/sprint-3/`. +- `pnpm test` clean (no test deleted or weakened; new tests added for the auto-resume flow). +- `pnpm build` clean (bundleBudget.test.ts passes; budget journal untouched). +- Browser smoke walked end-to-end with screenshots under `apps/web/docs/screenshots/sprint-3-5/`. - `.logs/goal-progress.md` contains the milestone log. -- `STATUS.md` exists with the final report (see below). -- Branch is on `goal/sprint-3-news-inbox`. +- `STATUS.md` exists with the final report. +- Branch is on `goal/sprint-3-5-hard-reload-survival`. ## Final report `STATUS.md` (rewrite from scratch) must include, in order: -1. **What shipped** — one paragraph summary of the user-visible change. +1. **What shipped** — one paragraph summary. 2. **Files changed** — `git diff --stat origin/main..HEAD` output. -3. **Validations run** — exact commands and their results. -4. **Browser evidence** — list of screenshots under `apps/web/docs/screenshots/sprint-3/` with captions; the IndexedDB save's `news` queue length before and after a smoke pass. -5. **Bundle impact** — which chunk grew, by how much, whether it fit under the existing ceiling, and which chunk routing decision (if any) you made. -6. **Known limitations** — anything you noticed but did not fix (out of scope). -7. **Risks** — what could break in production and what to watch. -8. **Rollback notes** — revert the merge commit; schema didn't bump; revert is safe. -9. **Next /goal** — the exact paste-ready next `/goal` prompt. Recommend Sprint 4 (orphaned player-profile + open-negotiations endpoints) per the original audit ranking. +3. **Validations run** — exact commands and results. +4. **Browser evidence** — screenshots under `apps/web/docs/screenshots/sprint-3-5/` with captions; localStorage key/value before & after; a list of routes you hard-reloaded successfully. +5. **Save Recovery integration** — confirmation that corrupt saves still hit the dialog. +6. **Known limitations** — anything out of scope you noticed. +7. **Risks** — what could break in production and what to watch (e.g. quota, localStorage disabled in private mode). +8. **Rollback notes** — revert the merge commit; localStorage entries will be ignored on the old code path. +9. **Next /goal** — exact paste-ready prompt for **Sprint 4** (the audit's original next pick: wire orphaned player-profile + open-negotiations endpoints). Claude Code will draft that GOAL.md after Sprint 3.5 merges. ## Branch + commit hygiene -- Branch: `goal/sprint-3-news-inbox` (already created on the post-Sprint-2 main). -- Stage specific files, never `git add -A`. -- Commit in logical slices (one slice per milestone is a reasonable cadence). -- Commit prefixes that match repo history: `feat(news):`, `feat(layout):` (for sidebar/topbar), `test(news):`, `docs(news):`. -- Co-author trailers on each commit: +- Branch: `goal/sprint-3-5-hard-reload-survival` (built on top of `goal/sprint-3-news-inbox`). When Sprint 3 merges to `main`, rebase this branch. +- Stage specific files; never `git add -A`. +- Commit prefixes: `feat(app):` for the boot logic, `feat(store):` for `useGameStore` persistence, `test(app):` for tests, `docs(app):` for docs. +- Co-author trailer on each commit: ``` Co-Authored-By: Codex GPT-5 ``` -- When done, push and open a PR titled `Sprint 3 — News inbox`. Body should summarize against this GOAL.md and link Sprint 2 PR #75 + Sprint 1 PR #74 for lineage. +- When done, push and open PR titled `Sprint 3.5 — Hard-reload state survival`. Body summarizes against this GOAL.md and links Sprint 3 PR #76 for lineage. ## Out of scope (do not attempt this sprint) -- Press Room conference unification (Sprint 5) -- Granular player-profile endpoints / open-negotiations resume pane (Sprint 4) -- Worker-side news generation / category additions / schema changes -- New top-level navigation patterns (mega-menu, nested nav) -- Push notifications, web notifications API, sound alerts -- Markdown rendering, link parsing, or rich-text in news bodies (plain text is enough) -- Sharing / exporting news -- 32 team logo SVGs (Sprint 7) +- Granular player-profile endpoints (Sprint 4) +- Open-negotiations resume pane (Sprint 4) +- Press conference unification (Sprint 5) +- Invariant runtime checks in DEV (Sprint 6) +- Moving narrative generation off main thread (Sprint 6) +- Team logo SVGs (Sprint 7) - Anything that touches `packages/sim-core/` or `packages/contracts/` +- Service worker changes +- Adding new dependencies --- -*End of GOAL.md. The companion `/goal` slash command lives in Sprint 3's PR description and in the conversation with Kevin.* +*End of GOAL.md. Companion `/goal` slash command lives in Sprint 3.5's PR description and in Kevin's conversation with Claude Code.* diff --git a/STATUS.md b/STATUS.md index c37cddd..6e457b5 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,139 +1,165 @@ -# STATUS — Sprint 3 News Inbox +# STATUS - Sprint 3.5 Hard-Reload State Survival -Status: **COMPLETE** for the news inbox feature. One GOAL.md Done When item ("`/MBD/news` hard-reload survives Sprint 2's BrowserRouter basename — should be free") turned out to be over-scoped — see "Hard-reload behavior" below — and is queued as its own sprint. +Status: **COMPLETE** for the hard-reload state survival mission. The active save shell state now persists to localStorage, app boot auto-loads the persisted save through the existing safe save/worker path, and hard-reloaded in-game routes render their route instead of falling back to Save Hub. ## What shipped -A `/news` route lazy-loaded under `AppLayout` that surfaces the worker-backed news feed. The page renders worker `NewsItem` objects newest-first via `getNews(100)`, supports an `All / Unread` toggle and a category filter, marks items read through `markNewsRead(id)` and persists the resulting state through the existing IndexedDB save path. The Sidebar gains a `News` entry (lucide `Inbox` — Press Room keeps `Newspaper`). The TopBar gains an unread-count chip that decrements as the user reads. Mobile-survivable at 375×667 with no horizontal overflow. +`useGameStore` now persists only the allowed shell fields under `mbd:game-store@v1`: active save id/slot, user team id, season, day, phase, team name, GM name, and difficulty. `AppBootGate` wraps the router, shows a `Resuming save...` route-level skeleton while loading, calls `loadSaveSafely(activeSaveId)`, imports the snapshot through `worker.importSnapshot`, then calls `initializeGame(...)` before `AppLayout` can redirect. Missing save ids clear the stale persisted active-save fields and fall through to Save Hub; corrupt saves route through the existing Save Recovery dialog. ## Files changed -`git diff --stat origin/main..HEAD`: +Current implementation diff is intentionally inside the Sprint 3.5 allowed scope plus proof artifacts: ```text -.logs/goal-progress.md | 151 ++++++++++ -STATUS.md | (this file) -apps/web/docs/screenshots/sprint-3/01-dashboard-after-month.png -apps/web/docs/screenshots/sprint-3/02-news-inbox-unread.png -apps/web/docs/screenshots/sprint-3/03-news-category-filter.png -apps/web/docs/screenshots/sprint-3/04-news-item-read.png -apps/web/docs/screenshots/sprint-3/05-news-mobile-375.png -apps/web/docs/screenshots/sprint-3/06-news-hard-reload-blocked.png -apps/web/src/app/layout/Sidebar.tsx | +2 lines (News nav entry) -apps/web/src/app/layout/Sidebar.test.tsx | +1 line -apps/web/src/app/layout/TopBar.tsx | +49 lines (unread chip) -apps/web/src/app/layout/TopBar.test.tsx | new -apps/web/src/app/routes/index.tsx | +4 lines (/news route) -apps/web/src/app/routes/index.test.tsx | +19 lines -apps/web/src/features/news/routes/NewsPage.tsx | new (431 lines) -apps/web/src/features/news/routes/NewsPage.test.tsx | new (238 lines) -apps/web/src/features/news/lib/newsEvents.ts | new (18 lines, event dispatcher for cross-component read updates) +.logs/goal-progress.md +STATUS.md +apps/web/docs/screenshots/sprint-3-5/*.png +apps/web/src/app/App.test.tsx +apps/web/src/app/App.tsx +apps/web/src/app/boot/AppBootGate.test.tsx +apps/web/src/app/boot/AppBootGate.tsx +apps/web/src/shared/hooks/useGameStore.test.ts +apps/web/src/shared/hooks/useGameStore.ts ``` -Pre-existing dirty file left untouched on disk: `.claude/launch.json` (local-only dev-server path override, not committed). - -## Validations run +Pre-existing local dirt left untouched: ```text -PATH=/Users/tkevinbigham/.local/node-lts/bin:$PATH pnpm typecheck +.claude/launch.json +.claude/scheduled_tasks.lock ``` -Latest: PASS — `Tasks: 9 successful, 9 total` in 7.669s. +`git diff --stat origin/main..HEAD`: ```text -PATH=/Users/tkevinbigham/.local/node-lts/bin:$PATH pnpm test + .logs/goal-progress.md | 121 +++++++ + GOAL.md | 355 ++++++++++----------- + STATUS.md | 180 ++++++----- + .../sprint-3-5/01-dashboard-before-hard-reload.png | Bin 0 -> 156026 bytes + .../sprint-3-5/02-dashboard-after-hard-reload.png | Bin 0 -> 139473 bytes + .../sprint-3-5/03-news-before-hard-reload.png | Bin 0 -> 126471 bytes + .../sprint-3-5/04-news-after-hard-reload.png | Bin 0 -> 103543 bytes + .../sprint-3-5/05-roster-after-hard-reload.png | Bin 0 -> 135451 bytes + .../sprint-3-5/06-trade-after-hard-reload.png | Bin 0 -> 150587 bytes + .../sprint-3-5/07-draft-after-hard-reload.png | Bin 0 -> 93716 bytes + .../sprint-3-5/08-save-hub-after-delete-slot.png | Bin 0 -> 135242 bytes + .../09-missing-save-fallback-save-hub.png | Bin 0 -> 138231 bytes + .../sprint-3-5/10-corrupt-save-recovery-dialog.png | Bin 0 -> 89768 bytes + .../11-dashboard-mobile-375-after-hard-reload.png | Bin 0 -> 32785 bytes + apps/web/src/app/App.test.tsx | 4 + + apps/web/src/app/App.tsx | 33 +- + apps/web/src/app/boot/AppBootGate.test.tsx | 283 ++++++++++++++++ + apps/web/src/app/boot/AppBootGate.tsx | 171 ++++++++++ + apps/web/src/shared/hooks/useGameStore.test.ts | 72 +++++ + apps/web/src/shared/hooks/useGameStore.ts | 118 ++++--- + 20 files changed, 1008 insertions(+), 329 deletions(-) ``` -Latest: PASS — `Tasks: 8 successful, 8 total` in 1m23.837s. Web 99 files / 624 tests (was 97/618 — Sprint 3 adds 2 test files and 6 tests). Sim-core 137 files / 1610 tests. Contracts 1 file / 20 tests. UI 1 file / 1 test. Existing non-fatal console noise unchanged. +## Validations run ```text -PATH=/Users/tkevinbigham/.local/node-lts/bin:$PATH pnpm build +PATH=/Users/tkevinbigham/.local/node-lts/bin:$PATH pnpm --filter @mbd/web test src/shared/hooks/useGameStore.test.ts src/app/boot/AppBootGate.test.tsx ``` -Latest: PASS — `Tasks: 5 successful, 5 total` in 6.347s. Vite built in 4.69s. PWA precached 120 entries (was 118; +2 from new NewsPage chunk and test screenshots). New chunk: `dist/assets/NewsPage-*.js` at **10.08 KB raw / 3.39 KB gzip**. Worker chunks unchanged (game-engine-core 450.75 KB, game-engine-story 452.10 KB). `bundleBudget.test.ts` passes — no edit to `apps/web/docs/BUDGETS.md` or `bundleConfig.ts`. - -Focused gates: +Red proof: FAIL as expected before implementation. The store persistence test saw `persisted.version` as `undefined`, and `AppBootGate.tsx` did not exist. ```text -pnpm --filter @mbd/web test src/features/news/routes/NewsPage.test.tsx src/app/layout/TopBar.test.tsx +PATH=/Users/tkevinbigham/.local/node-lts/bin:$PATH pnpm --filter @mbd/web test src/shared/hooks/useGameStore.test.ts src/app/boot/AppBootGate.test.tsx src/app/App.test.tsx ``` -Result: PASS — 2 files / 5 tests. +Latest focused result: PASS, 3 files / 8 tests. -## Browser evidence +```text +PATH=/Users/tkevinbigham/.local/node-lts/bin:$PATH pnpm typecheck +``` + +PASS. Turbo reported `Tasks: 9 successful, 9 total` in `5.418s`. -Dev server: `PATH=/Users/tkevinbigham/.local/node-lts/bin:$PATH pnpm --filter @mbd/web dev` → Vite at `http://localhost:5173/MBD/`. +```text +PATH=/Users/tkevinbigham/.local/node-lts/bin:$PATH pnpm test +``` -Screenshots under `apps/web/docs/screenshots/sprint-3/`: +PASS. Turbo reported `Tasks: 8 successful, 8 total` in `1m23.712s`. Web passed 101 files / 629 tests, sim-core passed 137 files / 1610 tests, contracts passed 1 file / 20 tests, and UI passed 1 file / 1 test. Existing non-fatal console noise remained: Recharts zero-size warnings, React `act(...)` warnings, service worker failure-test log, and the existing ScoutingPage mock-function log. -- `01-dashboard-after-month.png` — Day 31 save with News nav + TopBar unread chip context. -- `02-news-inbox-unread.png` — `/MBD/news` inbox list with worker news. -- `03-news-category-filter.png` — category filter applied to `Trade`. -- `04-news-item-read.png` — opened item shows `Read`; TopBar chip ticks from "News 100" to "News 99". -- `05-news-mobile-375.png` — 375×667 viewport, `horizontalOverflow=false`. -- `06-news-hard-reload-blocked.png` — captured during the hard-reload probe (see "Hard-reload behavior" below). Documents pre-existing app-wide behavior, not a Sprint 3 regression. +```text +PATH=/Users/tkevinbigham/.local/node-lts/bin:$PATH pnpm build +``` -IndexedDB proof from `mbd-saves` / `save-slot-2`: +PASS. Turbo reported `Tasks: 5 successful, 5 total` in `4.134s`; Vite built in `3.28s`; PWA precached 120 entries. Bundle budget stayed green. Main app chunk: `index-jENJPu8m.js` 205.74 KB raw / 58.38 KB gzip. Worker chunks remained `game-engine-core` 450.75 KB raw and `game-engine-story` 452.10 KB raw. -| Stage | total | unread | -| --- | --- | --- | -| Before any read | 580 | 580 | -| After opening one item | 580 | 579 | -| After full page reload | 580 | 579 | +## Browser evidence -Read state persists through reload at the data layer — only the routing-to-Save-Hub redirect masks it visually. +Dev server: -## Bundle impact +```text +PATH=/Users/tkevinbigham/.local/node-lts/bin:$PATH pnpm --filter @mbd/web dev +``` -New chunk: `NewsPage-*.js` at 10.08 KB raw / 3.39 KB gzip — well under all relevant ceilings. App index unchanged at 202.96 KB raw / 57.68 KB gzip. CSS at 58.27 KB / 10.91 KB. Worker chunks unchanged. No `bundleConfig.ts` or `BUDGETS.md` edit needed. +PASS at `http://localhost:5173/MBD/`. -## Hard-reload behavior — pre-existing, not a Sprint 3 limitation +Screenshots under `apps/web/docs/screenshots/sprint-3-5/`: -The GOAL.md included this Done When item: +- `01-dashboard-before-hard-reload.png` - dashboard loaded from Save Hub continue. +- `02-dashboard-after-hard-reload.png` - `/MBD/dashboard` after hard reload, dashboard rendered. +- `03-news-before-hard-reload.png` - News Inbox before hard reload. +- `04-news-after-hard-reload.png` - `/MBD/news` after hard reload, News Inbox rendered. +- `05-roster-after-hard-reload.png` - `/MBD/roster` after hard reload, Roster rendered. +- `06-trade-after-hard-reload.png` - `/MBD/trade` after hard reload, Trade Center rendered. +- `07-draft-after-hard-reload.png` - `/MBD/draft` after hard reload, Draft Room rendered. +- `08-save-hub-after-delete-slot.png` - active Slot 1 deleted for missing-save fallback. +- `09-missing-save-fallback-save-hub.png` - stale persisted id fell through to Save Hub. +- `10-corrupt-save-recovery-dialog.png` - corrupt persisted save hit Save Recovery actions. +- `11-dashboard-mobile-375-after-hard-reload.png` - 375x667 dashboard hard reload rendered dashboard with `scrollWidth=375`. -> `/MBD/news` hard-reload survives Sprint 2's BrowserRouter basename (should be free). +Routes hard-reloaded successfully without Save Hub redirect: -That claim was wrong. Sprint 2's BrowserRouter fix solved **URL parsing** (`/MBD/news` now resolves to the `/news` route table entry instead of throwing Vite's "configured public base URL" error). It did NOT solve **state hydration**. +```text +/MBD/dashboard +/MBD/news +/MBD/roster +/MBD/trade +/MBD/draft +``` -`apps/web/src/app/layout/AppLayout.tsx:446` has: +localStorage before dashboard reload: -```ts -if (!isInitialized) { - return ; -} +```json +{"state":{"activeSaveId":"save-slot-1","activeSaveSlot":1,"userTeamId":"nym","season":1,"day":1,"phase":"preseason","teamName":"New York Tycoons","gmName":"Mobile Smoke","difficulty":"standard"},"version":1} ``` -`useGameStore` is a plain Zustand store with no persistence middleware, so `isInitialized` resets to `false` on every hard reload. This redirect fires on **every** in-game route: `/dashboard`, `/roster`, `/trade`, `/draft`, `/news`, etc. — not just news. +localStorage after dashboard reload: -That is the current app design ("user must pick a save explicitly"), and it is the same behavior Sprint 2's STATUS already documented at `/MBD/dashboard`. Sprint 3 surfaces it again because the news inbox is one more in-game route, but it is not a Sprint 3 regression. Sprint 3 ships the news inbox feature complete; the auto-resume-on-reload polish is a separate sprint that affects the entire app shell. +```json +{"state":{"activeSaveId":"save-slot-1","activeSaveSlot":1,"userTeamId":"nym","season":1,"day":1,"phase":"preseason","teamName":"New York Tycoons","gmName":"Mobile Smoke","difficulty":"standard"},"version":1} +``` -The IndexedDB evidence above confirms the actual data layer is correct: read state writes through `markNewsRead → save` and survives the reload at the data level. Only the routing-to-Save-Hub redirect masks it visually. +Missing-save fallback cleared the stale active save id and rendered Save Hub. Corrupt-save fallback cleared the stale active save id and showed the existing Save Recovery action surface; post-recovery storage was: -## Day-One / scope decisions made during the run +```json +{"state":{"activeSaveId":null,"activeSaveSlot":null,"userTeamId":"nym","season":1,"day":1,"phase":"preseason","teamName":"New York Tycoons","gmName":"Smoke Tester","difficulty":"standard"},"version":1} +``` -- Codex picked the lucide `Inbox` icon for the Sidebar News entry. `Newspaper` stayed with Press Room as instructed. -- Codex added a tiny `newsEvents.ts` event dispatcher so the TopBar unread chip can react to `markNewsRead` calls from the NewsPage without coupling the two components. Allowed by the "Autonomy rules" section of GOAL.md. +## Save Recovery integration + +Auto-resume uses `loadSaveSafely(activeSaveId)`. Non-missing `{ ok: false }` results call `SaveRecoveryProvider.showFailure(...)` with a retry callback, matching the manual Save Hub load path. `AppBootGate.test.tsx` covers the corrupt-save branch, and browser evidence `10-corrupt-save-recovery-dialog.png` confirms the recovery action surface appears for a malformed persisted save. ## Known limitations -- **Pre-existing app behavior:** hard reloads of in-game routes always redirect to Save Hub because `useGameStore` does not persist `isInitialized` across reloads. Covered by the next sprint candidate below. -- **`getNews()` returns the unread queue, not full history.** Once an item is read, a fresh refetch will no longer include it. Session UI shows it as read for the current pageview; a navigation away and back will drop it from the visible list. This matches the worker query's current semantics. Surfacing full historical news would require touching `sim.worker.queries.ts` (protected). +- The in-app Browser read-only page scope did not expose `localStorage` or `indexedDB`, so exact storage snapshots and 375x667 viewport metrics were captured in a separate Playwright context against the same dev server. +- Auto-resume intentionally does not persist or duplicate snapshots in localStorage. IndexedDB remains the source of truth for heavy save data. +- If localStorage is disabled, the app behaves like the old flow and falls back to Save Hub on hard reload. ## Risks -- After `markNewsRead`, the page writes the active save through `saveGame` to persist read state. If save-write fails (Dexie quota, etc.), the UI keeps the in-session read state and surfaces an error toast. Watch for save-write latency if a user opens many items quickly — there's no debouncing in this sprint. -- The unread chip in TopBar refetches via `getNews()` after each read. Acceptable today because the news queue is bounded; if news volume grows materially, consider a derived count exposed through the worker. +- Browser storage edge cases remain the main risk: localStorage denied, IndexedDB blocked, quota pressure, or a stale persisted id after manual browser data cleanup. The implemented behavior clears stale active-save state and falls back to Save Hub. +- Worker import failures now surface through Save Recovery as `storage_failed`; watch production telemetry/manual reports for any confusing copy if a future worker compatibility error is not truly storage-related. ## Rollback notes -Revert the merge commit. No schema bump, no migration, no contract/sim-core/worker changes, no new dependencies, no budget changes. v33 saves load unchanged after revert. +Revert the Sprint 3.5 merge commit. No save schema bump, no migration, no worker/sim-core/contracts changes, no new dependencies. Existing `mbd:game-store@v1` localStorage entries become inert on the old code path; users can also remove that key manually if needed. ## Next /goal -The most important next polish — directly serving Kevin's v1.0.1 bar ("0 errors, easy to understand, runs like a G") — is the auto-resume-on-hard-reload work that Sprint 3 surfaced. Recommend running it as **Sprint 3.5 (Hard-reload state survival)** before Sprint 4's player-profile / open-negotiations work. - ```text -/goal Implement auto-resume of the active save on browser hard reload. Read GOAL.md (Sprint 3.5 contract), README.md, CHANGELOG.md, MASTER_CONTEXT.md, the existing STATUS.md, and the existing useGameStore + AppLayout. Persist enough of useGameStore (active save id/slot, last-known phase context) to localStorage via Zustand persist middleware, then on app boot if the persisted save id resolves in IndexedDB, load it through the worker before AppLayout's isInitialized guard fires. Cover loading and recovery states. Validate with pnpm typecheck + pnpm test + pnpm build, run pnpm --filter @mbd/web dev for a full browser smoke (hard-reload /dashboard, /roster, /news, /trade), and keep evaluator-visible proof in .logs/goal-progress.md plus the transcript. Stop only when every Done When item in the new GOAL.md is satisfied, or pause if a Pause Condition is hit. Before stopping, write STATUS.md with what shipped, files changed, validations run, browser evidence under apps/web/docs/screenshots/sprint-3-5/, known limitations, risks, rollback notes, and the exact next /goal. +/goal Implement Sprint 4: wire orphaned player-profile + open-negotiations endpoints. Read README.md, CHANGELOG.md, MASTER_CONTEXT.md, STATUS.md, GOAL.md, the player profile route/tests, trade negotiation route/tests, useWorker, and worker query/action surfaces first. Keep save schema v33 and do not touch sim-core/contracts unless GOAL.md explicitly allows it. Wire the existing worker endpoints into the player profile and open-negotiations UI so hard-reloaded long-running saves can inspect a player and resume active trade talks without dead controls. Add focused tests for player-profile endpoint wiring and open-negotiations resume behavior, then validate with pnpm typecheck + pnpm test + pnpm build and a dev-server browser smoke. Keep proof in .logs/goal-progress.md and finish with STATUS.md. ``` - -Claude Code will draft the actual Sprint 3.5 GOAL.md before that command runs. diff --git a/apps/web/docs/screenshots/sprint-3-5/01-dashboard-before-hard-reload.png b/apps/web/docs/screenshots/sprint-3-5/01-dashboard-before-hard-reload.png new file mode 100644 index 0000000..faa11cb Binary files /dev/null and b/apps/web/docs/screenshots/sprint-3-5/01-dashboard-before-hard-reload.png differ diff --git a/apps/web/docs/screenshots/sprint-3-5/02-dashboard-after-hard-reload.png b/apps/web/docs/screenshots/sprint-3-5/02-dashboard-after-hard-reload.png new file mode 100644 index 0000000..6d5b381 Binary files /dev/null and b/apps/web/docs/screenshots/sprint-3-5/02-dashboard-after-hard-reload.png differ diff --git a/apps/web/docs/screenshots/sprint-3-5/03-news-before-hard-reload.png b/apps/web/docs/screenshots/sprint-3-5/03-news-before-hard-reload.png new file mode 100644 index 0000000..9706d90 Binary files /dev/null and b/apps/web/docs/screenshots/sprint-3-5/03-news-before-hard-reload.png differ diff --git a/apps/web/docs/screenshots/sprint-3-5/04-news-after-hard-reload.png b/apps/web/docs/screenshots/sprint-3-5/04-news-after-hard-reload.png new file mode 100644 index 0000000..f2b4397 Binary files /dev/null and b/apps/web/docs/screenshots/sprint-3-5/04-news-after-hard-reload.png differ diff --git a/apps/web/docs/screenshots/sprint-3-5/05-roster-after-hard-reload.png b/apps/web/docs/screenshots/sprint-3-5/05-roster-after-hard-reload.png new file mode 100644 index 0000000..4d08b54 Binary files /dev/null and b/apps/web/docs/screenshots/sprint-3-5/05-roster-after-hard-reload.png differ diff --git a/apps/web/docs/screenshots/sprint-3-5/06-trade-after-hard-reload.png b/apps/web/docs/screenshots/sprint-3-5/06-trade-after-hard-reload.png new file mode 100644 index 0000000..74d0afd Binary files /dev/null and b/apps/web/docs/screenshots/sprint-3-5/06-trade-after-hard-reload.png differ diff --git a/apps/web/docs/screenshots/sprint-3-5/07-draft-after-hard-reload.png b/apps/web/docs/screenshots/sprint-3-5/07-draft-after-hard-reload.png new file mode 100644 index 0000000..3f5802f Binary files /dev/null and b/apps/web/docs/screenshots/sprint-3-5/07-draft-after-hard-reload.png differ diff --git a/apps/web/docs/screenshots/sprint-3-5/08-save-hub-after-delete-slot.png b/apps/web/docs/screenshots/sprint-3-5/08-save-hub-after-delete-slot.png new file mode 100644 index 0000000..37f3e45 Binary files /dev/null and b/apps/web/docs/screenshots/sprint-3-5/08-save-hub-after-delete-slot.png differ diff --git a/apps/web/docs/screenshots/sprint-3-5/09-missing-save-fallback-save-hub.png b/apps/web/docs/screenshots/sprint-3-5/09-missing-save-fallback-save-hub.png new file mode 100644 index 0000000..7d9fe6d Binary files /dev/null and b/apps/web/docs/screenshots/sprint-3-5/09-missing-save-fallback-save-hub.png differ diff --git a/apps/web/docs/screenshots/sprint-3-5/10-corrupt-save-recovery-dialog.png b/apps/web/docs/screenshots/sprint-3-5/10-corrupt-save-recovery-dialog.png new file mode 100644 index 0000000..4a8007d Binary files /dev/null and b/apps/web/docs/screenshots/sprint-3-5/10-corrupt-save-recovery-dialog.png differ diff --git a/apps/web/docs/screenshots/sprint-3-5/11-dashboard-mobile-375-after-hard-reload.png b/apps/web/docs/screenshots/sprint-3-5/11-dashboard-mobile-375-after-hard-reload.png new file mode 100644 index 0000000..c3c47e7 Binary files /dev/null and b/apps/web/docs/screenshots/sprint-3-5/11-dashboard-mobile-375-after-hard-reload.png differ diff --git a/apps/web/src/app/App.test.tsx b/apps/web/src/app/App.test.tsx index b42343a..c072a61 100644 --- a/apps/web/src/app/App.test.tsx +++ b/apps/web/src/app/App.test.tsx @@ -21,6 +21,10 @@ vi.mock('./routes', () => ({ }, })); +vi.mock('./boot/AppBootGate', () => ({ + AppBootGate: ({ children }: { children: ReactNode }) => <>{children}, +})); + vi.mock('./providers/ErrorBoundary', () => ({ ErrorBoundary: ({ children }: { children: ReactNode }) => <>{children}, })); diff --git a/apps/web/src/app/App.tsx b/apps/web/src/app/App.tsx index ad0853f..c3926d4 100644 --- a/apps/web/src/app/App.tsx +++ b/apps/web/src/app/App.tsx @@ -3,6 +3,7 @@ import { BrowserRouter } from 'react-router-dom'; import { Toaster } from 'sonner'; import { dynasty } from '@mbd/design-tokens'; import { SaveLoadErrorBoundary, SaveRecoveryProvider } from '@/features/save-recovery'; +import { AppBootGate } from './boot/AppBootGate'; import { AppRoutes } from './routes'; import { usePreferencesStore } from '@/shared/hooks/usePreferencesStore'; @@ -30,21 +31,23 @@ export function App() { return ( - - - - + + + + + + ); diff --git a/apps/web/src/app/boot/AppBootGate.test.tsx b/apps/web/src/app/boot/AppBootGate.test.tsx new file mode 100644 index 0000000..92ff211 --- /dev/null +++ b/apps/web/src/app/boot/AppBootGate.test.tsx @@ -0,0 +1,283 @@ +import { act } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createRoot, type Root } from 'react-dom/client'; +import { AppBootGate } from './AppBootGate'; +import { useWorker } from '@/shared/hooks/useWorker'; +import { loadSaveSafely, type LoadSaveSafelyResult } from '@/shared/lib/saveSystem'; +import { useGameStore } from '@/shared/hooks/useGameStore'; +import { toast } from 'sonner'; + +const recoveryMock = vi.hoisted(() => ({ + showFailure: vi.fn(), +})); + +vi.mock('@/shared/hooks/useWorker', () => ({ + useWorker: vi.fn(), +})); + +vi.mock('@/shared/lib/saveSystem', () => ({ + loadSaveSafely: vi.fn(), +})); + +vi.mock('@/features/save-recovery', () => ({ + useSaveRecovery: () => recoveryMock, +})); + +vi.mock('sonner', () => ({ + toast: { + error: vi.fn(), + info: vi.fn(), + }, +})); + +vi.mock('@/shared/lib/logger', () => ({ + logger: { + error: vi.fn(), + }, +})); + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +function createStorageMock(): Storage { + const storage = new Map(); + + return { + get length() { + return storage.size; + }, + clear() { + storage.clear(); + }, + getItem(key: string) { + return storage.get(key) ?? null; + }, + key(index: number) { + return Array.from(storage.keys())[index] ?? null; + }, + removeItem(key: string) { + storage.delete(key); + }, + setItem(key: string, value: string) { + storage.set(key, value); + }, + }; +} + +const okLoadResult: Extract = { + ok: true, + snapshot: { + schemaVersion: 33, + season: 4, + day: 88, + phase: 'regular', + } as never, + save: { + id: 'save-slot-1', + slotNumber: 1, + name: 'Tycoons Year 4', + season: 4, + day: 88, + phase: 'regular', + schemaVersion: 33, + hasSnapshot: true, + snapshot: null, + legacyState: null, + createdAt: '2026-04-02T00:00:00.000Z', + updatedAt: '2026-04-02T12:00:00.000Z', + parentSaveId: null, + isRootSave: true, + branchMeta: null, + }, + rawJson: '{"id":"save-slot-1"}', +}; + +function resetGameStore() { + useGameStore.setState({ + season: 1, + day: 1, + phase: 'preseason', + isSimulating: false, + isInitialized: false, + userTeamId: 'nym', + teamName: 'Tycoons', + gmName: 'General Manager', + difficulty: 'standard', + activeSaveId: null, + activeSaveSlot: null, + playerCount: 0, + gamesPlayed: 0, + }); +} + +async function flushAsync() { + await Promise.resolve(); + await Promise.resolve(); +} + +describe('AppBootGate', () => { + let container: HTMLDivElement; + let root: Root; + let workerMock: { + isReady: boolean; + importSnapshot: ReturnType; + }; + + beforeEach(() => { + const storage = createStorageMock(); + Object.defineProperty(window, 'localStorage', { + value: storage, + configurable: true, + }); + Object.defineProperty(globalThis, 'localStorage', { + value: storage, + configurable: true, + }); + window.localStorage.clear(); + resetGameStore(); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + workerMock = { + isReady: true, + importSnapshot: vi.fn().mockResolvedValue({ + success: true, + season: 4, + day: 88, + phase: 'regular', + playerCount: 780, + userTeamId: 'nym', + teamName: 'New York Tycoons', + gmName: 'General Manager', + difficulty: 'standard', + }), + }; + vi.mocked(useWorker).mockReturnValue(workerMock as unknown as ReturnType); + recoveryMock.showFailure.mockReset(); + vi.mocked(toast.info).mockReset(); + vi.mocked(toast.error).mockReset(); + }); + + afterEach(async () => { + await act(async () => { + root.unmount(); + }); + container.remove(); + vi.clearAllMocks(); + }); + + it('blocks the route with a resume skeleton, hydrates the worker, and then renders children', async () => { + useGameStore.getState().setActiveSave('save-slot-1', 1); + let resolveLoad: (value: Extract) => void = () => {}; + vi.mocked(loadSaveSafely).mockReturnValue(new Promise((resolve) => { + resolveLoad = resolve; + })); + + await act(async () => { + root.render( + +
Dashboard Route
+
, + ); + await flushAsync(); + }); + + expect(container.textContent).toContain('Resuming save...'); + expect(container.textContent).not.toContain('Dashboard Route'); + + await act(async () => { + resolveLoad(okLoadResult); + await flushAsync(); + }); + + expect(loadSaveSafely).toHaveBeenCalledWith('save-slot-1'); + expect(workerMock.importSnapshot).toHaveBeenCalledWith(okLoadResult.snapshot); + expect(useGameStore.getState()).toMatchObject({ + isInitialized: true, + activeSaveId: 'save-slot-1', + activeSaveSlot: 1, + season: 4, + day: 88, + phase: 'regular', + teamName: 'New York Tycoons', + }); + expect(container.textContent).toContain('Dashboard Route'); + }); + + it('clears a missing persisted save id and falls through to children without recovery', async () => { + useGameStore.getState().setActiveSave('save-slot-404', null); + vi.mocked(loadSaveSafely).mockResolvedValue({ + ok: false, + reason: 'storage_failed', + detail: { + slotId: 'save-slot-404', + slotNumber: null, + message: 'No save record found.', + rawJson: null, + }, + }); + + await act(async () => { + root.render( + +
Save Hub Route
+
, + ); + await flushAsync(); + }); + + expect(loadSaveSafely).toHaveBeenCalledWith('save-slot-404'); + expect(useGameStore.getState().activeSaveId).toBeNull(); + expect(useGameStore.getState().activeSaveSlot).toBeNull(); + expect(recoveryMock.showFailure).not.toHaveBeenCalled(); + expect(toast.info).toHaveBeenCalled(); + expect(container.textContent).toContain('Save Hub Route'); + }); + + it('hands corrupt persisted saves to Save Recovery and clears the stale active id', async () => { + const failure: Extract = { + ok: false, + reason: 'zod', + detail: { + slotId: 'save-slot-1', + slotNumber: 1, + message: 'Snapshot payload is invalid.', + rawJson: '{"id":"save-slot-1"}', + }, + }; + useGameStore.getState().setActiveSave('save-slot-1', 1); + vi.mocked(loadSaveSafely).mockResolvedValue(failure); + + await act(async () => { + root.render( + +
Save Hub Route
+
, + ); + await flushAsync(); + }); + + expect(recoveryMock.showFailure).toHaveBeenCalledWith(expect.objectContaining({ + failure, + onRetry: expect.any(Function), + })); + expect(useGameStore.getState().activeSaveId).toBeNull(); + expect(container.textContent).toContain('Save Hub Route'); + }); + + it('does not auto-load when there is no persisted active save id', async () => { + await act(async () => { + root.render( + +
Save Hub Route
+
, + ); + await flushAsync(); + }); + + expect(loadSaveSafely).not.toHaveBeenCalled(); + expect(workerMock.importSnapshot).not.toHaveBeenCalled(); + expect(container.textContent).toContain('Save Hub Route'); + }); +}); diff --git a/apps/web/src/app/boot/AppBootGate.tsx b/apps/web/src/app/boot/AppBootGate.tsx new file mode 100644 index 0000000..17bde14 --- /dev/null +++ b/apps/web/src/app/boot/AppBootGate.tsx @@ -0,0 +1,171 @@ +import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'; +import { toast } from 'sonner'; +import { useSaveRecovery } from '@/features/save-recovery'; +import { useGameStore } from '@/shared/hooks/useGameStore'; +import { useWorker } from '@/shared/hooks/useWorker'; +import { loadSaveSafely, type LoadSaveSafelyResult } from '@/shared/lib/saveSystem'; +import { logger } from '@/shared/lib/logger'; + +type AutoResumeFailure = Extract; +type AutoResumeStatus = 'idle' | 'resuming' | 'finished'; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function isMissingSaveFailure(failure: AutoResumeFailure): boolean { + return ( + failure.reason === 'storage_failed' + && failure.detail.message === 'No save record found.' + ); +} + +function storageFailure(saveId: string, slotNumber: number | null, message: string): AutoResumeFailure { + return { + ok: false, + reason: 'storage_failed', + detail: { + slotId: saveId, + slotNumber, + message, + rawJson: null, + }, + }; +} + +function ResumeFallback() { + return ( +
+
+
+ MBD +
+
Resuming save...
+
+
+
+
+
+ ); +} + +export function AppBootGate({ children }: { children: ReactNode }) { + const worker = useWorker(); + const recovery = useSaveRecovery(); + const activeSaveId = useGameStore((state) => state.activeSaveId); + const activeSaveSlot = useGameStore((state) => state.activeSaveSlot); + const isInitialized = useGameStore((state) => state.isInitialized); + const initializeGame = useGameStore((state) => state.initializeGame); + const setActiveSave = useGameStore((state) => state.setActiveSave); + const [resumeStatus, setResumeStatus] = useState('idle'); + const attemptedSaveIdRef = useRef(null); + + const attemptResume = useCallback(async ( + saveId: string, + slotNumber: number | null, + options: { fromRecovery?: boolean } = {}, + ): Promise => { + setResumeStatus('resuming'); + + try { + const loadResult = await loadSaveSafely(saveId); + + if (!loadResult.ok) { + setActiveSave(null, null); + setResumeStatus('finished'); + + if (isMissingSaveFailure(loadResult)) { + toast.info('Saved dynasty was not found. Returning to the Save Hub.'); + return false; + } + + if (!options.fromRecovery) { + recovery.showFailure({ + failure: loadResult, + onRetry: () => attemptResume(saveId, slotNumber, { fromRecovery: true }), + }); + } + return false; + } + + const imported = await worker.importSnapshot(loadResult.snapshot); + if (!imported.success) { + const failure = storageFailure( + saveId, + loadResult.save.slotNumber ?? slotNumber, + 'error' in imported && typeof imported.error === 'string' + ? imported.error + : 'The saved dynasty could not be imported.', + ); + setActiveSave(null, null); + setResumeStatus('finished'); + if (!options.fromRecovery) { + recovery.showFailure({ + failure, + onRetry: () => attemptResume(saveId, slotNumber, { fromRecovery: true }), + }); + } + return false; + } + + initializeGame({ + season: imported.season, + day: imported.day, + phase: imported.phase, + playerCount: imported.playerCount, + userTeamId: imported.userTeamId, + teamName: imported.teamName, + gmName: imported.gmName, + difficulty: imported.difficulty, + activeSaveId: loadResult.save.id, + activeSaveSlot: loadResult.save.slotNumber, + }); + setResumeStatus('finished'); + return true; + } catch (error) { + logger.error('Failed to auto-resume save:', error); + const failure = storageFailure(saveId, slotNumber, errorMessage(error)); + setActiveSave(null, null); + setResumeStatus('finished'); + toast.error('Could not resume the saved dynasty. Returning to the Save Hub.'); + if (!options.fromRecovery) { + recovery.showFailure({ + failure, + onRetry: () => attemptResume(saveId, slotNumber, { fromRecovery: true }), + }); + } + return false; + } + }, [initializeGame, recovery, setActiveSave, worker]); + + useEffect(() => { + if (isInitialized) { + setResumeStatus('finished'); + return; + } + + if (!activeSaveId) { + attemptedSaveIdRef.current = null; + setResumeStatus('finished'); + return; + } + + if (!worker.isReady) { + setResumeStatus('resuming'); + return; + } + + if (attemptedSaveIdRef.current === activeSaveId) { + return; + } + + attemptedSaveIdRef.current = activeSaveId; + void attemptResume(activeSaveId, activeSaveSlot); + }, [activeSaveId, activeSaveSlot, attemptResume, isInitialized, worker.isReady]); + + if (activeSaveId && !isInitialized && resumeStatus !== 'finished') { + return ; + } + + return <>{children}; +} diff --git a/apps/web/src/shared/hooks/useGameStore.test.ts b/apps/web/src/shared/hooks/useGameStore.test.ts new file mode 100644 index 0000000..9bcb262 --- /dev/null +++ b/apps/web/src/shared/hooks/useGameStore.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { GAME_STORE_STORAGE_KEY, useGameStore } from './useGameStore'; + +function resetGameStore() { + useGameStore.setState({ + season: 1, + day: 1, + phase: 'preseason', + isSimulating: false, + isInitialized: false, + userTeamId: 'nym', + teamName: 'Tycoons', + gmName: 'General Manager', + difficulty: 'standard', + activeSaveId: null, + activeSaveSlot: null, + playerCount: 0, + gamesPlayed: 0, + }); +} + +describe('useGameStore persistence', () => { + beforeEach(() => { + resetGameStore(); + window.localStorage.clear(); + }); + + it('persists only the active-save shell state needed to resume after reload', () => { + useGameStore.getState().initializeGame({ + season: 4, + day: 88, + phase: 'regular', + playerCount: 780, + userTeamId: 'nym', + teamName: 'New York Tycoons', + gmName: 'General Manager', + difficulty: 'hard', + activeSaveId: 'save-slot-1', + activeSaveSlot: 1, + }); + useGameStore.getState().updateFromSim({ + season: 4, + day: 89, + phase: 'regular', + gamesPlayed: 5, + }); + useGameStore.getState().setSimulating(true); + + const persisted = JSON.parse(window.localStorage.getItem(GAME_STORE_STORAGE_KEY) ?? '{}') as { + state?: Record; + version?: number; + }; + + expect(persisted.version).toBe(1); + expect(persisted.state).toMatchObject({ + activeSaveId: 'save-slot-1', + activeSaveSlot: 1, + userTeamId: 'nym', + season: 4, + day: 89, + phase: 'regular', + teamName: 'New York Tycoons', + gmName: 'General Manager', + difficulty: 'hard', + }); + expect(persisted.state).not.toHaveProperty('isInitialized'); + expect(persisted.state).not.toHaveProperty('isSimulating'); + expect(persisted.state).not.toHaveProperty('playerCount'); + expect(persisted.state).not.toHaveProperty('gamesPlayed'); + expect(persisted.state).not.toHaveProperty('initializeGame'); + }); +}); diff --git a/apps/web/src/shared/hooks/useGameStore.ts b/apps/web/src/shared/hooks/useGameStore.ts index bdbba05..6748f72 100644 --- a/apps/web/src/shared/hooks/useGameStore.ts +++ b/apps/web/src/shared/hooks/useGameStore.ts @@ -1,7 +1,11 @@ import { create } from 'zustand'; +import { createJSONStorage, persist } from 'zustand/middleware'; import type { Difficulty } from '@mbd/contracts'; -interface GameState { +export const GAME_STORE_STORAGE_KEY = 'mbd:game-store@v1'; +const GAME_STORE_PERSIST_VERSION = 1; + +export interface GameState { season: number; day: number; phase: string; @@ -43,50 +47,70 @@ interface GameState { }) => void; } -export const useGameStore = create((set) => ({ - season: 1, - day: 1, - phase: 'preseason', - isSimulating: false, - isInitialized: false, - userTeamId: 'nym', - teamName: 'Tycoons', - gmName: 'General Manager', - difficulty: 'standard', - activeSaveId: null, - activeSaveSlot: null, - playerCount: 0, - gamesPlayed: 0, - setSeason: (season) => set({ season }), - setDay: (day) => set({ day }), - setPhase: (phase) => set({ phase }), - setSimulating: (simulating) => set({ isSimulating: simulating }), - setInitialized: (initialized) => set({ isInitialized: initialized }), - setUserTeamId: (teamId) => set({ userTeamId: teamId }), - setActiveSave: (id, slot) => set({ activeSaveId: id, activeSaveSlot: slot }), - setActiveSaveSlot: (slot) => set({ - activeSaveId: slot != null ? `save-slot-${slot}` : null, - activeSaveSlot: slot, - }), - updateFromSim: (data) => - set({ - season: data.season, - day: data.day, - phase: data.phase, - gamesPlayed: data.gamesPlayed ?? 0, - }), - initializeGame: (data) => - set({ - season: data.season, - day: data.day, - phase: data.phase, - playerCount: data.playerCount, - userTeamId: data.userTeamId, - teamName: data.teamName ?? 'Franchise', - gmName: data.gmName ?? 'General Manager', - difficulty: data.difficulty ?? 'standard', - activeSaveId: data.activeSaveId ?? (data.activeSaveSlot != null ? `save-slot-${data.activeSaveSlot}` : null), - activeSaveSlot: data.activeSaveSlot ?? null, - isInitialized: true, +export const useGameStore = create()( + persist( + (set) => ({ + season: 1, + day: 1, + phase: 'preseason', + isSimulating: false, + isInitialized: false, + userTeamId: 'nym', + teamName: 'Tycoons', + gmName: 'General Manager', + difficulty: 'standard', + activeSaveId: null, + activeSaveSlot: null, + playerCount: 0, + gamesPlayed: 0, + setSeason: (season) => set({ season }), + setDay: (day) => set({ day }), + setPhase: (phase) => set({ phase }), + setSimulating: (simulating) => set({ isSimulating: simulating }), + setInitialized: (initialized) => set({ isInitialized: initialized }), + setUserTeamId: (teamId) => set({ userTeamId: teamId }), + setActiveSave: (id, slot) => set({ activeSaveId: id, activeSaveSlot: slot }), + setActiveSaveSlot: (slot) => set({ + activeSaveId: slot != null ? `save-slot-${slot}` : null, + activeSaveSlot: slot, + }), + updateFromSim: (data) => + set({ + season: data.season, + day: data.day, + phase: data.phase, + gamesPlayed: data.gamesPlayed ?? 0, + }), + initializeGame: (data) => + set({ + season: data.season, + day: data.day, + phase: data.phase, + playerCount: data.playerCount, + userTeamId: data.userTeamId, + teamName: data.teamName ?? 'Franchise', + gmName: data.gmName ?? 'General Manager', + difficulty: data.difficulty ?? 'standard', + activeSaveId: data.activeSaveId ?? (data.activeSaveSlot != null ? `save-slot-${data.activeSaveSlot}` : null), + activeSaveSlot: data.activeSaveSlot ?? null, + isInitialized: true, + }), }), -})); + { + name: GAME_STORE_STORAGE_KEY, + storage: createJSONStorage(() => localStorage), + version: GAME_STORE_PERSIST_VERSION, + partialize: (state) => ({ + activeSaveId: state.activeSaveId, + activeSaveSlot: state.activeSaveSlot, + userTeamId: state.userTeamId, + season: state.season, + day: state.day, + phase: state.phase, + teamName: state.teamName, + gmName: state.gmName, + difficulty: state.difficulty, + }), + }, + ), +);