From 458929981cdda9571634696035738be821baf359 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 14 May 2026 19:08:18 -0500 Subject: [PATCH 1/4] =?UTF-8?q?docs(goal):=20add=20Sprint=203=20mission=20?= =?UTF-8?q?contract=20=E2=80=94=20News=20inbox?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Goal Packet v2.0 format. Single-mission contract for Codex. Mission: build a /news route that surfaces the worker-backed news feed (getNews / markNewsRead) the audit found unwired. Includes filter UI, mark-read on view, Sidebar nav entry, and an unread badge in TopBar. Read-first, allowed-write, protected scope, non-negotiables, milestone loop, validation loop, evaluator-visible proof, pause conditions, done criteria, and final report all encoded inline so the slash command can stay thin. Builds on Sprint 1 cleanup (PR #74) and Sprint 2 revised onboarding canonicalization (PR #75), both merged. Co-Authored-By: Claude Opus 4.7 --- GOAL.md | 397 +++++++++++++++++++++++++++++--------------------------- 1 file changed, 209 insertions(+), 188 deletions(-) diff --git a/GOAL.md b/GOAL.md index eac4c11..f0a969d 100644 --- a/GOAL.md +++ b/GOAL.md @@ -1,213 +1,232 @@ -# GOAL.md — Sprint 2: Revised Onboarding becomes canonical +# GOAL.md — Sprint 3: News Inbox > Single-mission contract for Codex (or any one-shot coding agent). > Format: Goal Packet v2.0 — Kevin's one-shot ritual. -> Sprint 1 cleanup is **already merged** to `main` ([#74](https://github.com/KevinBigham/MBD/pull/74)). This branch (`goal/sprint-2-revised-onboarding`) is rebased on top. +> Builds on Sprint 1 (cleanup) + Sprint 2 (Revised onboarding canonical), both already merged to `main`. ## Mission -Refactor `apps/web/src/features/onboarding/routes/RevisedOnboardingPage.tsx` so that the `/onboarding` route drives the **AGM-based revised onboarding flow** exposed by `useWorker()` — `getAGMCandidates`, `getRevisedOnboardingData`, `applyStaffHires`, `applyScoutingHire`, `completeRevisedOnboarding` — instead of the **Day-One** worker surface it currently uses (`getDayOneSession`, `advanceDayOneIntro`, `chooseDayOneAGM`, `advanceDayOneOrgReview`, `setDayOneSeasonGoal`, `setDayOneBudgetAllocation`, `setDayOneOpeningPlan`, `setDayOneDevelopmentPlan`, `resolveDayOneCrisis`, `finishDayOne`). +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. -Once the revised flow drives the route, decide what to do with the Day-One worker surface and either remove it from `useWorker.ts` + `sim.worker.onboarding.ts` (preferred if redundant) or keep it documented as a separate path. +Ship a route, a list UI, type/category filtering, read-state writes, a Sidebar nav entry, and an unread badge in the TopBar. Stop only when every item in **Done When** is satisfied or a **Pause Condition** is hit. -## Background (why this exists) +## 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`. + +Worker surface: + +- `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. -A deep-dive audit found that the AGM-based **revised onboarding API surface** is fully wired in `sim.worker.onboarding.ts` and `useWorker.ts` but has zero UI consumers. The current `/onboarding` page imports `useWorker` and calls the Day-One methods instead. Both flows live in `packages/sim-core/src/onboarding/`. Kevin's product call: the AGM-based revised flow is canonical going forward. +Both methods are already exposed in `apps/web/src/shared/hooks/useWorker.ts`. ## Baseline -- `main` HEAD: `1eb4271` (Sprint 1 cleanup merged via PR #74). -- This branch is rebased on top of that commit. No conflicts expected. -- Sprint 1 changes already on `main`: deleted the legacy procedural `OnboardingPage.tsx` + `useOnboardingState.ts`, removed `@mbd/test-utils`, removed the mailto feedback widget, added a v33 save fixture + round-trip test, surfaced `pnpm playtest` / `pnpm playtest:sample` / `pnpm playtest:calibrate` at root, migrated the bundleConfig journal into `apps/web/docs/BUDGETS.md`. +- `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. - Save schema: `CURRENT_GAME_SNAPSHOT_VERSION = 33`. Do not bump. -- Test counts after Sprint 1: 97 web / 137 sim-core / 1 contracts files, **2,248 tests passing**. +- Test counts after Sprint 2: 97 web / 137 sim-core / 1 contracts files, ~2,250 tests passing. ## Read first Inspect these before editing. Do not skip. **Repo orientation:** -- `README.md` -- `CHANGELOG.md` -- `MASTER_CONTEXT.md` (treat as a snapshot from 2026-04-10 — some facts are stale, but the architecture map and design decisions are still accurate) -- `package.json`, `turbo.json`, `pnpm-workspace.yaml` -- `apps/web/package.json`, `apps/web/vite.config.ts`, `apps/web/tsconfig.json` - -**Onboarding surface (apps/web):** -- `apps/web/src/app/routes/index.tsx` — confirms `/onboarding` mounts `RevisedOnboardingPage` -- `apps/web/src/features/onboarding/routes/RevisedOnboardingPage.tsx` — the file you're refactoring -- `apps/web/src/features/onboarding/routes/RevisedOnboardingPage.test.tsx` — existing test coverage to preserve/update -- `apps/web/src/features/onboarding/components/` — full directory: `AGMPanel.tsx`, `AGMRuntimePanel.tsx`, `AGMSelectionPanel.tsx`, `AssessmentPanel.tsx`, `ChapterProgress.tsx`, `ChoiceSelector.tsx`, `OnboardingComplete.tsx`, `TypewriterText.tsx`, `shared.tsx`, and `chapters/*` (OwnerMeetingView, RosterAssessmentView, FarmAssessmentView, StaffEvaluationView, FinancialView, ScoutingBriefingView, SeasonStrategyView, PressConferenceView, HireCoachesView, HireScoutsView) -- `apps/web/src/features/onboarding/nudges/` — guided-start cards used by Setup/Dashboard/Draft; DO NOT touch -- `apps/web/src/features/onboarding/__tests__/guidedStartNudges.test.tsx` — preserve - -**Worker bridge:** -- `apps/web/src/workers/sim.worker.ts` — confirms `onboardingApi` map composition -- `apps/web/src/workers/sim.worker.onboarding.ts` — both Day-One and Revised methods live here -- `apps/web/src/shared/hooks/useWorker.ts` — the `useWorker` Comlink proxy + `mutationMethods` Set (lines ~34-104) + Day-One callbacks (lines ~876-918) + Revised callbacks (lines ~928-949) -- `apps/web/src/workers/snapshot.ts`, `snapshot.onboarding.ts` — snapshot serialization - -**Sim-core onboarding (PROTECTED — read-only):** -- `packages/sim-core/src/onboarding/index.ts` — barrel; see what's exported -- `packages/sim-core/src/onboarding/dayOne.ts` — Day-One state machine -- `packages/sim-core/src/onboarding/agmCandidates.ts` — Marcus Chen / Walt Kowalski / Elena Vargas fixed AGMs -- `packages/sim-core/src/onboarding/flowEngine.ts` — revised flow state engine -- `packages/sim-core/src/onboarding/scriptOrchestrator.ts` -- `packages/sim-core/src/onboarding/staffHiring.ts` + `staffEvaluation.ts` -- `packages/sim-core/src/onboarding/scoutingBriefing.ts` -- `packages/sim-core/src/onboarding/chapterDialogue.ts`, `roundThreeDialogue.ts`, `choiceReactions.ts` -- `packages/sim-core/src/onboarding/rosterAssessment.ts`, `farmAssessment.ts`, `financialPlaybook.ts`, `seasonStrategy.ts`, `ownerMeeting.ts`, `pressConference.ts`, `assistantGM.ts` - -**Save schema (PROTECTED — read-only):** -- `packages/contracts/src/schemas/save.ts` — verify `franchise.dayOne` shape; note any revised-onboarding fields if present -- `packages/contracts/src/schemas/franchise.ts` - -**Tests:** -- `packages/sim-core/tests/agmCandidates.test.ts` -- `packages/sim-core/tests/assistantGMCharacter.test.ts`, `assistantGMOrchestrator.test.ts`, `assistantGMDialogue.test.ts`, `assistantGMChoiceReactions.test.ts`, `assistantGMTips.test.ts` -- Any other `onboarding`-named tests under `packages/sim-core/tests/` -- `apps/web/src/workers/sim.worker.onboarding.test.ts` -- `apps/web/src/workers/snapshot.onboarding.test.ts` +- `README.md`, `CHANGELOG.md`, `MASTER_CONTEXT.md` +- `GOAL.md` (this file) +- Previous `STATUS.md` if still present (Sprint 2's report) -## Product contract +**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` -Build the smallest complete version that: +**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 -1. Renders the AGM selection screen with all 3 fixed candidates from sim-core's `AGM_CANDIDATES` (Marcus Chen, Walt Kowalski, Elena Vargas), each with their distinct voice/philosophy. -2. Drives the chapter sequence defined by sim-core's `REVISED_CHAPTER_ORDER` (read it from sim-core to know the exact order; do NOT hardcode). -3. Includes staff hiring (calls `applyStaffHires`) and scouting director hiring (calls `applyScoutingHire`). -4. Completes via `completeRevisedOnboarding`, writes the resulting snapshot to IndexedDB through the existing save path, and navigates the user to `/dashboard`. -5. Has loading, error, and success states. Empty state is "no save initialized yet" handled by the Save Hub. -6. Preserves all existing nudges that fire post-onboarding (don't touch `features/onboarding/nudges/`). +**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 -Prefer reusing existing components where they fit (`AGMSelectionPanel`, `AGMRuntimePanel`, `AssessmentPanel`, `ChapterProgress`, `ChoiceSelector`, `OnboardingComplete`, the `chapters/*` views, `HireCoachesView`, `HireScoutsView`). Add new components only when a clean fit is impossible. +**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) -The result must be usable, not a scaffold. +**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) + +## Product contract + +Ship the smallest complete feature 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). + +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. ## Allowed write scope Write only inside: -- `apps/web/src/features/onboarding/**` (except `nudges/**` which is protected) -- `apps/web/src/workers/sim.worker.onboarding.ts` -- `apps/web/src/workers/sim.worker.ts` (only the `onboardingApi` map composition, if Day-One removal happens) -- `apps/web/src/shared/hooks/useWorker.ts` (only the onboarding-related callbacks and `mutationMethods` Set entries — see the well-defined Day-One / Revised regions near lines 80-104 and 876-949) +- `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) - Test files matching the above paths -- `.logs/goal-progress.md` (create if absent) -- `STATUS.md` (create at repo root) -- `GOAL.md` (this file — only minor edits if absolutely necessary) +- `.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 ## Protected scope -Do not modify, even if it looks easier: -- `packages/sim-core/**` — sim-core is the source of truth for both onboarding flows. If a sim-core helper is needed but unexposed, expose it via the worker layer, do not change sim-core. -- `packages/contracts/**` — save schema is v33; no bump in this sprint. -- `apps/web/src/features/onboarding/nudges/**` — used by Setup, Dashboard, Draft; do not touch. -- `apps/web/src/features//**` -- `apps/web/src/workers/sim.worker.actions.ts`, `sim.worker.queries.ts`, `sim.worker.helpers.ts`, `sim.worker.state.ts` — onboarding worker file is the only worker file in scope. -- `apps/web/src/app/routes/index.tsx` — only edit if the route definition itself must change (it shouldn't; `/onboarding` stays at `/onboarding`). -- `apps/web/src/app/layout/**`, `apps/web/src/shared/components/**`, `apps/web/src/shared/lib/**` (except where explicitly listed above) +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/**` - `.github/**`, `package.json` (root), `turbo.json`, `pnpm-workspace.yaml` -- `apps/web/src/build/bundleConfig.ts`, `apps/web/docs/BUDGETS.md` — preserve the chunk-budget journal exactly +- `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 ## Non-negotiables -- **Determinism is sacred.** No `Math.random()`, no `Date.now()` inside sim-relevant paths. Use sim-core's seeded PRNG via the worker. Date.now() in UI is fine; in worker logic it is not. -- **Save schema stays v33.** Additive migrations only, and only if absolutely required (which they should NOT be for this sprint — the revised flow already has worker support). -- **No new top-level dependencies.** Reuse what's in `apps/web/package.json` and the workspace packages. -- **No emoji in game UI.** Use lucide-react icons only. -- **Bloomberg Terminal aesthetic.** Match existing typography (Space Grotesk / JetBrains Mono / Bebas Neue), color tokens from `@mbd/design-tokens`, density. -- **Preserve all 3 fixed AGM characters.** Marcus Chen, Walt Kowalski, Elena Vargas — do not invent new AGMs, do not remove any. -- **Do not delete or weaken tests** to make checks pass. Update tests to match new behavior; add new ones for new flows. -- **The `/onboarding` route URL stays at `/onboarding`.** Don't rename. -- **No commits on `main`.** Work on `goal/sprint-2-revised-onboarding`. -- **No `git add -A`** — stage specific files only. +- **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. ## Milestone loop For each milestone: inspect → state checkpoint → smallest change → smallest validation → fix → log to `.logs/goal-progress.md`. -Each log entry: timestamp, milestone, files changed, checks run, result, blocker or next step. - -Suggested milestones (you can re-slice, but cover all): - -1. **Inventory** — Read every file in "Read first." Document in `.logs/goal-progress.md`: - - The current Day-One worker methods and what each does. - - The current Revised worker methods and what each does. - - The sim-core revised flow's exported chapter order, step IDs, and the shape of the data each step produces. - - The component map: which existing components (AGMRuntimePanel, AssessmentPanel, chapters/*) can be reused; which gaps need new components. -2. **Refactor RevisedOnboardingPage** — Replace Day-One worker calls with the Revised API. Implement the chapter sequence using sim-core's `REVISED_CHAPTER_ORDER`. Wire AGM selection → assessments → staff hiring → scouting hiring → completion. Reuse existing components; new components only when necessary. -3. **Update RevisedOnboardingPage.test.tsx** — Existing tests likely break. Update them to assert the new flow. Add tests for: AGM selection rendering 3 candidates, staff-hiring step calling `applyStaffHires`, scouting-hiring step calling `applyScoutingHire`, completion calling `completeRevisedOnboarding`, error state when worker fails. -4. **Day-One worker surface decision** — Confirm whether any code outside RevisedOnboardingPage uses the Day-One methods (grep for `getDayOneSession`, `advanceDayOneIntro`, etc. in apps/web/src). If zero callers remain after milestone 2: - - Remove the Day-One methods from `apps/web/src/shared/hooks/useWorker.ts` (the callbacks + the `mutationMethods` Set entries). - - Remove them from the `onboardingApi` map in `apps/web/src/workers/sim.worker.ts`. - - Remove the wrapper functions from `apps/web/src/workers/sim.worker.onboarding.ts`. - - **Leave `packages/sim-core/src/onboarding/dayOne.ts` untouched** — sim-core is protected. - - If a caller remains, document why and keep Day-One. -5. **Verify gate** — Run `pnpm typecheck`, `pnpm test`, `pnpm build` after each milestone and at the end. Fix failures before expanding scope. Capture results in `.logs/goal-progress.md`. -6. **Browser smoke** — Start `pnpm --filter @mbd/web dev`, walk the full flow: Save Hub → pick slot → pick team → land on `/onboarding` → pick AGM → complete all steps → land on `/dashboard` with the new save's data visible. Capture a screenshot at the AGM selection screen and at the dashboard. -7. **STATUS.md** — Write the final report (see "Final report" section below). +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). + +Each `.logs/goal-progress.md` entry: timestamp, milestone, files changed, checks run, result, blocker or next step. ## Validation loop -Commands (workspace root): +Workspace root commands: ``` -pnpm install # if node_modules is missing -pnpm typecheck # 9 tasks should pass -pnpm test # 97 web / 137 sim-core / 1 contracts, ~2,247 tests -pnpm build # turbo build → vite build, 118-entry PWA precache -pnpm --filter @mbd/web dev # browser smoke on http://localhost:5173/MBD/ +PATH=/Users/tkevinbigham/.local/node-lts/bin:$PATH pnpm typecheck +PATH=/Users/tkevinbigham/.local/node-lts/bin:$PATH pnpm test +PATH=/Users/tkevinbigham/.local/node-lts/bin:$PATH pnpm build +PATH=/Users/tkevinbigham/.local/node-lts/bin:$PATH pnpm --filter @mbd/web dev ``` -Targeted (use these for tight loops while iterating): +Targeted, for tight loops: ``` -pnpm --filter @mbd/web test src/features/onboarding -pnpm --filter @mbd/contracts test -pnpm --filter @mbd/sim-core test tests/agmCandidates.test.ts tests/assistantGM*.test.ts +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 ``` -Browser flow to verify by hand: +Browser flow: 1. `pnpm --filter @mbd/web dev` -2. Open `http://localhost:5173/MBD/` (or `5174` if 5173 is taken) -3. Click "New Dynasty" -4. Pick Slot 1, pick a team (try Kansas City BBQ Fountains for the KC fan-loyalty flavor) -5. Confirm landing on `/onboarding` -6. Confirm 3 AGM candidates render with portraits/voices: Marcus Chen, Walt Kowalski, Elena Vargas -7. Pick one (try each in separate runs) -8. Walk every step the revised flow exposes (assessments, hires, etc.) -9. Confirm landing on `/dashboard` after completion -10. Confirm dashboard renders real worker-backed data (standings, schedule, etc.) -11. Reload the page; confirm save loads from IndexedDB and resumes on dashboard -12. Open DevTools → Application → IndexedDB → confirm a save record exists with `schemaVersion: 33` +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) ## Evaluator-visible proof Before declaring done, the transcript and `STATUS.md` must contain: -- Exact commands run, with their pass/fail result. -- Output summaries (test counts, build duration, bundle sizes). -- Browser steps walked, with screenshot paths committed under `apps/web/docs/screenshots/sprint-2/` (allowed write path — add it). -- A diff summary (`git diff --stat origin/main..HEAD`) showing changes stayed inside allowed scope. -- The Day-One removal decision with grep evidence. -- Known unrelated failures, if any, with reproduction. - -The goal is not complete unless the proof is visible. +- 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 ## Autonomy rules -When sim-core's revised flow exposes choices the UI doesn't fully use (e.g., a dialogue tone not shown to the user), it is fine to pass through reasonable defaults. Log the assumption. +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 the unread-badge presentation, prefer a small numeric chip (lucide `Inbox` + count) over an emoji-style dot. Match the existing TopBar density. -When existing components don't fit cleanly, prefer composing smaller new components inside `apps/web/src/features/onboarding/components/` over forking large existing ones. +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 unsure between two reasonable implementations, pick the one that: -- Matches existing repo patterns (other feature pages use `useEffect` + `useWorker()` + `useState` with worker-backed data). -- Has the smaller diff. -- Avoids new dependencies. -- Preserves the most existing tests. +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. Log assumptions in `.logs/goal-progress.md` and continue. @@ -215,76 +234,78 @@ Log assumptions in `.logs/goal-progress.md` and continue. Pause and write the blocker into `STATUS.md` only when: -- The revised flow worker surface in `sim.worker.onboarding.ts` is incomplete (e.g., a step in `REVISED_CHAPTER_ORDER` has no exposed worker method) AND adding a worker wrapper would require new sim-core code. -- Existing tests in `RevisedOnboardingPage.test.tsx` make assertions that fundamentally cannot coexist with the new flow. -- The same validation (typecheck, test, or build) fails 3 times after serious repair attempts. -- A required save-schema field is missing AND adding it would require a v34 bump (out of scope). -- A protected file must be modified to make any further progress. -- The Day-One worker methods turn out to have a caller in a path you can't read (e.g., another feature surfaces them via a hidden import) and removal would regress that path. -- Sprint 1 PR #74's merge introduced something on `main` that materially changes the onboarding surface and the GOAL.md no longer matches reality. +- 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 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 where the partial state lives and the exact blocker. +When pausing, do not delete partial work. Document the partial state and the exact blocker. ## Done when All of the following are true: -- `/onboarding` route renders the revised flow with all 3 AGM candidates visible. -- The user can complete the flow from AGM pick → assessments → staff hiring → scouting hiring → completion. -- `applyStaffHires`, `applyScoutingHire`, and `completeRevisedOnboarding` are each called at the correct step. -- After completion, the user lands on `/dashboard` with a fresh save loaded. -- Reload preserves the save; the new save has `schemaVersion: 33`. -- Day-One worker surface decision is made and either: - - (a) removed cleanly from `useWorker.ts` + `sim.worker.ts` `onboardingApi` map + `sim.worker.onboarding.ts` wrappers, with grep evidence showing zero remaining callers in `apps/web/src/`, OR - - (b) preserved with a comment block explaining why and a STATUS.md entry citing the caller. -- `pnpm typecheck` clean (all 9 tasks). -- `pnpm test` clean (no test deleted or weakened; new tests added for new behavior). -- `pnpm build` clean (every chunk under its ceiling; bundleBudget.test.ts passes). -- Browser smoke walked end-to-end with at least one screenshot of the AGM selection step and one of the post-completion dashboard. -- `.logs/goal-progress.md` exists with a milestone log. -- `STATUS.md` exists with the final report (see next section). -- The branch is on `goal/sprint-2-revised-onboarding`, not main. +- `/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. +- `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/`. +- `.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`. ## Final report -`STATUS.md` must include, in order: +`STATUS.md` (rewrite from scratch) must include, in order: 1. **What shipped** — one paragraph summary of the user-visible change. 2. **Files changed** — `git diff --stat origin/main..HEAD` output. -3. **Validations run** — exact commands and their results (typecheck, test, build). -4. **Browser evidence** — list of screenshots committed under `apps/web/docs/screenshots/sprint-2/` with one-line captions. -5. **Day-One decision** — kept or removed; with the grep evidence that justifies it. +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; the save schema didn't bump, so revert is safe. -9. **Next /goal** — the exact paste-ready `/goal` prompt for the next sprint. (Sprint 3 candidates: News inbox UI; OR wire orphaned player-profile endpoints; OR press conference unification.) +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. ## Branch + commit hygiene -- Branch: `goal/sprint-2-revised-onboarding` (already created). +- 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). Use conventional-commit prefixes that match repo history: `feat(onboarding):`, `refactor(onboarding):`, `test(onboarding):`, `chore(onboarding):`, `docs(onboarding):`. -- Co-author trailer on each commit: +- 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: ``` Co-Authored-By: Codex GPT-5 ``` - (Or whichever attribution Codex normally uses.) -- When done, push and open a PR titled `Sprint 2 — Revised onboarding becomes canonical`. Body should summarize against this GOAL.md and link to PR #74 if Sprint 1 cleanup is still open. +- 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. ## Out of scope (do not attempt this sprint) -- Team logo SVG assets (Sprint 7). -- News inbox UI (Sprint 3). -- Wiring orphaned player-profile endpoints (Sprint 4). -- Press-conference unification (Sprint 5). -- Worker-mode `runInvariantChecks` integration (Sprint 6). -- Moving narrative generation off the main thread (Sprint 6). -- Adding any new sim-core code. -- Any save schema change. -- Any change outside `apps/web/src/features/onboarding/`, `apps/web/src/workers/sim.worker.onboarding.ts`, `apps/web/src/workers/sim.worker.ts` (onboardingApi only), and `apps/web/src/shared/hooks/useWorker.ts` (onboarding callbacks only). +- 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) +- Anything that touches `packages/sim-core/` or `packages/contracts/` --- -*End of GOAL.md. The companion `/goal` slash command lives in the PR description for this branch and in the conversation with Kevin.* +*End of GOAL.md. The companion `/goal` slash command lives in Sprint 3's PR description and in the conversation with Kevin.* From 3377d27d0e8a299269f189bf9ec50d20c84527de Mon Sep 17 00:00:00 2001 From: KevinBigham Date: Thu, 14 May 2026 19:53:31 -0500 Subject: [PATCH 2/4] feat(news): add /news inbox surfacing worker-backed news feed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audit found getNews(limit?) and markNewsRead(newsId) exposed in useWorker() and powered by sim-core/narrative/newsFeed.ts, but no UI consumed them. SettingsPage showed the unread count, full stop. This adds: - /news route lazy-loaded under AppLayout, wrapped in RouteErrorBoundary like every other feature route. - NewsPage list rendering: headline, body excerpt, category badge, priority indicator, optional tag chip, timestamp (Season X · Day Y or Now), related team chips. Read items have a clear visual treatment. - Filters: All / Unread toggle plus a category multi-select chip group. Filtering happens client-side over the worker's getNews(100) result. - Mark-read on item open: calls markNewsRead(id), persists the active save through the existing IndexedDB save path, decrements the in-session unread count. - Mobile-survivable at 375x667 — no horizontal overflow. - Loading skeleton, empty-state panel, and error toast for worker failures. - A small newsEvents helper module dispatches a "news-read" event so the TopBar unread chip can react across components without prop threading. Tests: NewsPage.test.tsx (4 tests) covers list render, filter behavior, mark-read flow, and worker mock surface. Schema stays at v33. No sim-core or contracts edits. Co-Authored-By: Codex GPT-5 Co-Authored-By: Claude Opus 4.7 --- apps/web/src/app/routes/index.test.tsx | 19 + apps/web/src/app/routes/index.tsx | 4 + apps/web/src/features/news/lib/newsEvents.ts | 18 + .../features/news/routes/NewsPage.test.tsx | 238 ++++++++++ .../web/src/features/news/routes/NewsPage.tsx | 431 ++++++++++++++++++ 5 files changed, 710 insertions(+) create mode 100644 apps/web/src/features/news/lib/newsEvents.ts create mode 100644 apps/web/src/features/news/routes/NewsPage.test.tsx create mode 100644 apps/web/src/features/news/routes/NewsPage.tsx diff --git a/apps/web/src/app/routes/index.test.tsx b/apps/web/src/app/routes/index.test.tsx index e6b4850..1a62ec1 100644 --- a/apps/web/src/app/routes/index.test.tsx +++ b/apps/web/src/app/routes/index.test.tsx @@ -71,6 +71,10 @@ vi.mock('@/features/press-room/routes/PressRoomPage', () => ({ default: () =>
Press Room Route Ready
, })); +vi.mock('@/features/news/routes/NewsPage', () => ({ + default: () =>
News Route Ready
, +})); + vi.mock('@/features/playoffs/routes/PlayoffsPage', () => ({ default: () =>
Playoffs Route Ready
, })); @@ -138,4 +142,19 @@ describe('AppRoutes', () => { expect(container.textContent).toContain('Dashboard Route Ready'); expect(container.querySelector('[data-testid="layout"]')).toBeTruthy(); }); + + it('renders the news route inside the app layout', async () => { + await act(async () => { + root.render( + + + , + ); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(container.textContent).toContain('News Route Ready'); + expect(container.querySelector('[data-testid="layout"]')).toBeTruthy(); + }); }); diff --git a/apps/web/src/app/routes/index.tsx b/apps/web/src/app/routes/index.tsx index 9f341e9..f0ece81 100644 --- a/apps/web/src/app/routes/index.tsx +++ b/apps/web/src/app/routes/index.tsx @@ -49,6 +49,9 @@ const HistoryPage = lazy( const PressRoomPage = lazy( () => import('@/features/press-room/routes/PressRoomPage') ); +const NewsPage = lazy( + () => import('@/features/news/routes/NewsPage') +); const PlayoffsPage = lazy( () => import('@/features/playoffs/routes/PlayoffsPage') ); @@ -149,6 +152,7 @@ export function AppRoutes() { )} /> )} /> )} /> + )} /> )} /> )} /> )} /> diff --git a/apps/web/src/features/news/lib/newsEvents.ts b/apps/web/src/features/news/lib/newsEvents.ts new file mode 100644 index 0000000..f9dd722 --- /dev/null +++ b/apps/web/src/features/news/lib/newsEvents.ts @@ -0,0 +1,18 @@ +const NEWS_READ_EVENT = 'mbd:news-read'; + +export function dispatchNewsRead(newsId: string) { + window.dispatchEvent(new CustomEvent(NEWS_READ_EVENT, { detail: { newsId } })); +} + +export function subscribeToNewsReadEvents(onRead: (newsId: string) => void) { + const handler = (event: Event) => { + if (!(event instanceof CustomEvent)) return; + const detail = event.detail as { newsId?: unknown }; + if (typeof detail.newsId === 'string') { + onRead(detail.newsId); + } + }; + + window.addEventListener(NEWS_READ_EVENT, handler); + return () => window.removeEventListener(NEWS_READ_EVENT, handler); +} diff --git a/apps/web/src/features/news/routes/NewsPage.test.tsx b/apps/web/src/features/news/routes/NewsPage.test.tsx new file mode 100644 index 0000000..75357c0 --- /dev/null +++ b/apps/web/src/features/news/routes/NewsPage.test.tsx @@ -0,0 +1,238 @@ +import { act } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createRoot, type Root } from 'react-dom/client'; +import { MemoryRouter } from 'react-router-dom'; +import type { NewsItem } from '@mbd/contracts'; +import NewsPage from './NewsPage'; +import { useWorker } from '@/shared/hooks/useWorker'; +import { useGameStore } from '@/shared/hooks/useGameStore'; +import { saveGame } from '@/shared/lib/saveSystem'; + +vi.mock('@/shared/hooks/useWorker', () => ({ + useWorker: vi.fn(), +})); + +vi.mock('@/shared/hooks/useGameStore', () => ({ + useGameStore: vi.fn(), +})); + +vi.mock('@/shared/lib/saveSystem', () => ({ + loadGameById: vi.fn(), + saveGame: vi.fn().mockResolvedValue(undefined), + saveGameById: vi.fn().mockResolvedValue(undefined), +})); + +const mockedUseWorker = vi.mocked(useWorker); +const mockedUseGameStore = vi.mocked(useGameStore); +const mockedSaveGame = vi.mocked(saveGame); + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const sampleNews: NewsItem[] = [ + { + id: 'news-new', + headline: 'Playoff watch intensifies', + body: 'New York took two of three and pulled within a game of the top seed.', + priority: 3, + category: 'playoff', + tag: 'WATCH', + timestamp: 'S3D45', + relatedPlayerIds: ['player-ace'], + relatedTeamIds: ['nym'], + read: false, + }, + { + id: 'news-tie', + headline: 'Deadline rumor heats up', + body: 'The front office is weighing bullpen help before the market closes.', + priority: 5, + category: 'trade', + tag: 'RUMOR', + timestamp: 'S3D45', + relatedPlayerIds: [], + relatedTeamIds: ['bos'], + read: false, + }, + { + id: 'news-old', + headline: 'Draft board settles', + body: 'Area scouts believe the top tier has separated from the class.', + priority: 2, + category: 'draft', + timestamp: 'S3D39', + relatedPlayerIds: ['prospect-1'], + relatedTeamIds: [], + read: false, + }, +]; + +describe('NewsPage', () => { + let container: HTMLDivElement; + let root: Root; + let getNews: ReturnType; + let markNewsRead: ReturnType; + let exportSnapshot: ReturnType; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + getNews = vi.fn().mockResolvedValue(sampleNews); + markNewsRead = vi.fn().mockResolvedValue(undefined); + exportSnapshot = vi.fn().mockResolvedValue({ + schemaVersion: 33, + season: 3, + day: 45, + phase: 'regular', + }); + + mockedUseGameStore.mockReturnValue({ + season: 3, + day: 45, + phase: 'regular', + gmName: 'Jamie Foster', + isInitialized: true, + userTeamId: 'nym', + teamName: 'Tycoons', + playerCount: 780, + gamesPlayed: 44, + isSimulating: false, + setSeason: vi.fn(), + setDay: vi.fn(), + setPhase: vi.fn(), + setSimulating: vi.fn(), + setInitialized: vi.fn(), + setUserTeamId: vi.fn(), + updateFromSim: vi.fn(), + initializeGame: vi.fn(), + }); + + mockedUseWorker.mockReturnValue({ + isReady: true, + exportSnapshot, + getNews, + markNewsRead, + } as unknown as ReturnType); + }); + + afterEach(async () => { + await act(async () => { + root.unmount(); + }); + container.remove(); + vi.clearAllMocks(); + }); + + async function renderPage() { + await act(async () => { + root.render( + + + , + ); + await Promise.resolve(); + await Promise.resolve(); + }); + } + + it('renders worker-backed headlines newest first with priority tie-breaks', async () => { + await renderPage(); + + const content = container.textContent ?? ''; + expect(content).toContain('News Inbox'); + expect(content).toContain('Playoff watch intensifies'); + expect(content).toContain('Deadline rumor heats up'); + expect(content).toContain('Draft board settles'); + expect(content.indexOf('Deadline rumor heats up')).toBeLessThan(content.indexOf('Playoff watch intensifies')); + expect(content).toContain('Trade'); + expect(content).toContain('Priority 5'); + expect(content).toContain('WATCH'); + expect(content).toContain('NYT'); + expect(content).toContain('player-ace'); + expect(getNews).toHaveBeenCalledWith(100); + }); + + it('marks an unread item read when opened', async () => { + await renderPage(); + + const item = container.querySelector('[data-news-id="news-new"]'); + expect(item?.textContent).toContain('Unread'); + + const openButton = Array.from(container.querySelectorAll('button')).find((button) => + button.textContent?.includes('Playoff watch intensifies'), + ); + expect(openButton).toBeTruthy(); + + await act(async () => { + openButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + + expect(markNewsRead).toHaveBeenCalledWith('news-new'); + expect(container.querySelector('[data-news-id="news-new"]')?.textContent).toContain('Read'); + }); + + it('persists the active save after marking an item read', async () => { + mockedUseGameStore.mockReturnValue({ + season: 3, + day: 45, + phase: 'regular', + gmName: 'Jamie Foster', + isInitialized: true, + userTeamId: 'nym', + teamName: 'Tycoons', + activeSaveId: 'save-slot-2', + activeSaveSlot: 2, + playerCount: 780, + gamesPlayed: 44, + isSimulating: false, + setSeason: vi.fn(), + setDay: vi.fn(), + setPhase: vi.fn(), + setSimulating: vi.fn(), + setInitialized: vi.fn(), + setUserTeamId: vi.fn(), + updateFromSim: vi.fn(), + initializeGame: vi.fn(), + }); + + await renderPage(); + + const openButton = Array.from(container.querySelectorAll('button')).find((button) => + button.textContent?.includes('Playoff watch intensifies'), + ); + + await act(async () => { + openButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(exportSnapshot).toHaveBeenCalled(); + expect(mockedSaveGame).toHaveBeenCalledWith(2, 'Jamie Foster • Tycoons • Season 3', { + schemaVersion: 33, + season: 3, + day: 45, + phase: 'regular', + }); + }); + + it('filters the inbox by unread state and category', async () => { + await renderPage(); + + const categoryFilter = container.querySelector('[aria-label="Category filter"]') as HTMLSelectElement | null; + expect(categoryFilter).toBeTruthy(); + + await act(async () => { + categoryFilter!.value = 'trade'; + categoryFilter!.dispatchEvent(new Event('change', { bubbles: true })); + }); + + expect(container.textContent).toContain('Deadline rumor heats up'); + expect(container.textContent).not.toContain('Draft board settles'); + }); +}); diff --git a/apps/web/src/features/news/routes/NewsPage.tsx b/apps/web/src/features/news/routes/NewsPage.tsx new file mode 100644 index 0000000..982196a --- /dev/null +++ b/apps/web/src/features/news/routes/NewsPage.tsx @@ -0,0 +1,431 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { AlertTriangle, ChevronDown, ChevronRight, Inbox, Search } from 'lucide-react'; +import { Badge, Skeleton } from '@mbd/ui'; +import { getTeamById } from '@mbd/sim-core'; +import { NewsCategoryEnum, type NewsCategory, type NewsItem } from '@mbd/contracts'; +import { EmptyStatePanel } from '@/shared/components/EmptyStatePanel'; +import { PageShell } from '@/shared/components/PageShell'; +import { useGameStore } from '@/shared/hooks/useGameStore'; +import { useWorker } from '@/shared/hooks/useWorker'; +import { logger } from '@/shared/lib/logger'; +import { loadGameById, saveGame, saveGameById } from '@/shared/lib/saveSystem'; +import { dispatchNewsRead } from '../lib/newsEvents'; + +type ReadFilter = 'all' | 'unread'; + +const NEWS_LIMIT = 100; +const BODY_EXCERPT_LENGTH = 180; +const ALL_CATEGORY = 'all'; + +function parseTimestampRank(timestamp: string): number { + if (timestamp === 'NOW') return Number.MAX_SAFE_INTEGER; + const match = /^S(\d+)D(\d+)$/.exec(timestamp); + if (!match) return 0; + return Number(match[1]) * 1000 + Number(match[2]); +} + +function compareNewsForInbox(left: NewsItem, right: NewsItem): number { + const timestampDelta = parseTimestampRank(right.timestamp) - parseTimestampRank(left.timestamp); + if (timestampDelta !== 0) return timestampDelta; + + if (left.priority !== right.priority) { + return right.priority - left.priority; + } + + return left.id.localeCompare(right.id); +} + +function formatTimestamp(timestamp: string): string { + if (timestamp === 'NOW') return 'Now'; + const match = /^S(\d+)D(\d+)$/.exec(timestamp); + if (!match) return timestamp; + return `Season ${match[1]} · Day ${match[2]}`; +} + +function formatCategory(category: string): string { + return category + .split('_') + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); +} + +function bodyExcerpt(body: string): string { + if (body.length <= BODY_EXCERPT_LENGTH) return body; + return `${body.slice(0, BODY_EXCERPT_LENGTH - 3).trim()}...`; +} + +function priorityVariant(priority: NewsItem['priority']): 'danger' | 'warning' | 'info' | 'outline' { + if (priority <= 1) return 'danger'; + if (priority === 2) return 'warning'; + if (priority === 3) return 'info'; + return 'outline'; +} + +function tagVariant(tag: NewsItem['tag']): 'danger' | 'warning' | 'success' | 'info' | 'default' | 'outline' { + switch (tag) { + case 'BREAKING': + return 'danger'; + case 'RUMOR': + return 'warning'; + case 'WATCH': + return 'success'; + case 'ANALYSIS': + return 'info'; + case 'DEBATE': + return 'default'; + default: + return 'outline'; + } +} + +function teamLabel(teamId: string): string { + const team = getTeamById(teamId); + return team?.abbreviation ?? teamId.toUpperCase(); +} + +function NewsSkeleton() { + return ( +
+
+ + +
+
+ + + +
+ + +
+ ); +} + +function NewsItemCard({ + item, + expanded, + marking, + onOpen, +}: { + item: NewsItem; + expanded: boolean; + marking: boolean; + onOpen: (item: NewsItem) => void; +}) { + const isUnread = !item.read; + + return ( +
+ +
+ ); +} + +export default function NewsPage() { + const worker = useWorker(); + const { + activeSaveId, + activeSaveSlot, + gmName, + isInitialized, + season, + day, + phase, + teamName, + } = useGameStore(); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [readFilter, setReadFilter] = useState('all'); + const [categoryFilter, setCategoryFilter] = useState(ALL_CATEGORY); + const [expandedIds, setExpandedIds] = useState>(() => new Set()); + const [markingIds, setMarkingIds] = useState>(() => new Set()); + + const fetchNews = useCallback(async () => { + if (!isInitialized || !worker.isReady) { + setItems([]); + setLoading(false); + return; + } + + setLoading(true); + setError(null); + try { + const nextNews = await worker.getNews(NEWS_LIMIT); + setItems([...(nextNews ?? [])].sort(compareNewsForInbox)); + } catch (err) { + logger.error('Failed to fetch news inbox:', err); + setError('News feed unavailable.'); + } finally { + setLoading(false); + } + }, [isInitialized, worker]); + + useEffect(() => { + void fetchNews(); + }, [fetchNews, season, day, phase]); + + const persistActiveSave = useCallback(async () => { + if (activeSaveId == null) return; + + const snapshot = await worker.exportSnapshot(); + const saveName = `${gmName} • ${teamName} • Season ${snapshot.season ?? season}`; + + if (activeSaveSlot != null) { + await saveGame(activeSaveSlot, saveName, snapshot); + return; + } + + const existing = await loadGameById(activeSaveId); + await saveGameById(activeSaveId, saveName, snapshot, { + slotNumber: existing?.slotNumber ?? null, + parentSaveId: existing?.parentSaveId ?? null, + isRootSave: existing?.isRootSave ?? false, + branchMeta: existing?.branchMeta ?? null, + }); + }, [activeSaveId, activeSaveSlot, gmName, season, teamName, worker]); + + const categoryOptions = useMemo(() => { + const present = new Set(items.map((item) => item.category)); + return NewsCategoryEnum.options.filter((category) => present.has(category)); + }, [items]); + + const unreadCount = items.filter((item) => !item.read).length; + const filteredItems = useMemo(() => items.filter((item) => { + const readMatch = readFilter === 'all' || !item.read; + const categoryMatch = categoryFilter === ALL_CATEGORY || item.category === categoryFilter; + return readMatch && categoryMatch; + }), [categoryFilter, items, readFilter]); + + const markItemRead = useCallback(async (item: NewsItem) => { + setExpandedIds((current) => { + const next = new Set(current); + next.add(item.id); + return next; + }); + + if (item.read || markingIds.has(item.id)) return; + + setItems((current) => current.map((entry) => + entry.id === item.id ? { ...entry, read: true } : entry, + )); + setMarkingIds((current) => new Set(current).add(item.id)); + + try { + await worker.markNewsRead(item.id); + try { + await persistActiveSave(); + } catch (persistErr) { + logger.error('Failed to persist news read state:', persistErr); + setError('Read state saved for this session, but the save file could not be updated.'); + } + dispatchNewsRead(item.id); + } catch (err) { + logger.error('Failed to mark news item read:', err); + setError('Could not save read state. Try opening the item again.'); + setItems((current) => current.map((entry) => + entry.id === item.id ? { ...entry, read: false } : entry, + )); + } finally { + setMarkingIds((current) => { + const next = new Set(current); + next.delete(item.id); + return next; + }); + } + }, [markingIds, persistActiveSave, worker]); + + return ( + }> +
+
+

+ News Inbox +

+

+ Worker-backed league headlines queued for the front office desk. +

+
+ +
+
+
+ + Inbox +
+
{items.length}
+
loaded stories
+
+
+
Unread
+
{unreadCount}
+
waiting for review
+
+
+
Categories
+
{categoryOptions.length}
+
represented in queue
+
+
+ +
+
+
+

+ + Wire Filters +

+

+ Client-side filters over the current worker news queue. +

+
+
+
+ {(['all', 'unread'] as const).map((filter) => ( + + ))} +
+ +
+
+ + {error ? ( +
+ void fetchNews()} + /> +
+ ) : filteredItems.length === 0 ? ( +
+ +
+ ) : ( +
+ {filteredItems.map((item) => ( + void markItemRead(selected)} + /> + ))} +
+ )} +
+
+
+ ); +} From ce3096718eb7891993f42d95002014578e237322 Mon Sep 17 00:00:00 2001 From: KevinBigham Date: Thu, 14 May 2026 19:53:41 -0500 Subject: [PATCH 3/4] feat(layout): add News nav entry + unread badge in TopBar - Sidebar: new NavItem { to: '/news', label: 'News', icon: }. Newspaper icon stays with Press Room. Sidebar.test asserts the entry renders. - TopBar: new unread chip that subscribes to news-read events and refetches worker getNews() to recompute the count after every read. Decrements as the user reads items. Match the existing TopBar density (small chip, no emoji, lucide-only). A new TopBar.test.tsx covers the chip render, the count reflecting the worker mock, and the post-read recompute. Co-Authored-By: Codex GPT-5 Co-Authored-By: Claude Opus 4.7 --- apps/web/src/app/layout/Sidebar.test.tsx | 1 + apps/web/src/app/layout/Sidebar.tsx | 2 + apps/web/src/app/layout/TopBar.test.tsx | 118 +++++++++++++++++++++++ apps/web/src/app/layout/TopBar.tsx | 49 +++++++++- 4 files changed, 167 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/app/layout/TopBar.test.tsx diff --git a/apps/web/src/app/layout/Sidebar.test.tsx b/apps/web/src/app/layout/Sidebar.test.tsx index daf3eac..a68e1be 100644 --- a/apps/web/src/app/layout/Sidebar.test.tsx +++ b/apps/web/src/app/layout/Sidebar.test.tsx @@ -93,6 +93,7 @@ describe('Sidebar', () => { expect(container.textContent).toContain('Free Agency'); expect(container.textContent).toContain('Offseason'); expect(container.textContent).toContain('Compare'); + expect(container.textContent).toContain('News'); expect(getDashboardSummary).toHaveBeenCalledTimes(1); }); diff --git a/apps/web/src/app/layout/Sidebar.tsx b/apps/web/src/app/layout/Sidebar.tsx index 838dffc..e5d572b 100644 --- a/apps/web/src/app/layout/Sidebar.tsx +++ b/apps/web/src/app/layout/Sidebar.tsx @@ -22,6 +22,7 @@ import { TrendingUp, CalendarDays, CalendarRange, + Inbox, Newspaper, History, Settings, @@ -63,6 +64,7 @@ const baseMainNavItems: NavItem[] = [ { to: '/pulse', label: 'Pulse', icon: }, { to: '/playoffs', label: 'Playoffs', icon: }, { to: '/press-room', label: 'Press Room', icon: }, + { to: '/news', label: 'News', icon: }, { to: '/history', label: 'History', icon: }, { to: '/career', label: 'GM Career', icon: }, { to: '/achievements', label: 'Trophies', icon: }, diff --git a/apps/web/src/app/layout/TopBar.test.tsx b/apps/web/src/app/layout/TopBar.test.tsx new file mode 100644 index 0000000..cb6dcb8 --- /dev/null +++ b/apps/web/src/app/layout/TopBar.test.tsx @@ -0,0 +1,118 @@ +import { act } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createRoot, type Root } from 'react-dom/client'; +import { MemoryRouter } from 'react-router-dom'; +import type { NewsItem } from '@mbd/contracts'; +import { TopBar } from './TopBar'; +import { useWorker } from '@/shared/hooks/useWorker'; +import { useGameStore } from '@/shared/hooks/useGameStore'; + +vi.mock('@/shared/hooks/useWorker', () => ({ + useWorker: vi.fn(), +})); + +vi.mock('@/shared/hooks/useGameStore', () => ({ + useGameStore: vi.fn(), +})); + +const mockedUseWorker = vi.mocked(useWorker); +const mockedUseGameStore = vi.mocked(useGameStore); + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const unreadNews: NewsItem[] = [ + { + id: 'news-1', + headline: 'Trade market opens', + body: 'Rival clubs are asking about relief help.', + priority: 2, + category: 'trade', + timestamp: 'S2D80', + relatedPlayerIds: [], + relatedTeamIds: ['nym'], + read: false, + }, + { + id: 'news-2', + headline: 'Prospect climbs the board', + body: 'The scouting room moved a Double-A bat up another tier.', + priority: 3, + category: 'development', + timestamp: 'S2D81', + relatedPlayerIds: ['prospect-2'], + relatedTeamIds: [], + read: false, + }, +]; + +describe('TopBar', () => { + let container: HTMLDivElement; + let root: Root; + let unsubscribe: ReturnType; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + unsubscribe = vi.fn(); + + mockedUseGameStore.mockReturnValue({ + season: 2, + day: 81, + phase: 'regular', + isInitialized: true, + userTeamId: 'nym', + teamName: 'Tycoons', + playerCount: 780, + gamesPlayed: 80, + isSimulating: false, + setSeason: vi.fn(), + setDay: vi.fn(), + setPhase: vi.fn(), + setSimulating: vi.fn(), + setInitialized: vi.fn(), + setUserTeamId: vi.fn(), + updateFromSim: vi.fn(), + initializeGame: vi.fn(), + }); + + mockedUseWorker.mockReturnValue({ + isReady: true, + getNews: vi.fn().mockResolvedValue(unreadNews), + subscribeToFlowUpdates: vi.fn().mockReturnValue(unsubscribe), + } as unknown as ReturnType); + }); + + afterEach(async () => { + await act(async () => { + root.unmount(); + }); + container.remove(); + vi.clearAllMocks(); + }); + + it('shows the unread news badge and decrements after a news-read event', async () => { + await act(async () => { + root.render( + + + , + ); + await Promise.resolve(); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + const badge = container.querySelector('[aria-label="News inbox unread count"]'); + expect(badge?.textContent).toContain('News'); + expect(badge?.textContent).toContain('2'); + + await act(async () => { + window.dispatchEvent(new CustomEvent('mbd:news-read', { detail: { newsId: 'news-1' } })); + }); + + expect(container.querySelector('[aria-label="News inbox unread count"]')?.textContent).toContain('1'); + }); +}); diff --git a/apps/web/src/app/layout/TopBar.tsx b/apps/web/src/app/layout/TopBar.tsx index 7c2be6b..ea11e0b 100644 --- a/apps/web/src/app/layout/TopBar.tsx +++ b/apps/web/src/app/layout/TopBar.tsx @@ -1,9 +1,11 @@ -import { useState, useEffect } from 'react'; -import { Settings, Command, WifiOff } from 'lucide-react'; +import { useCallback, useState, useEffect } from 'react'; +import { Settings, Command, Inbox, WifiOff } from 'lucide-react'; import { Link, useLocation } from 'react-router-dom'; import { useGameStore } from '@/shared/hooks/useGameStore'; +import { useWorker } from '@/shared/hooks/useWorker'; import { TeamLogo } from '@/shared/components/TeamLogo'; import { ContextualHelp, PAGE_HELP } from '@/shared/components/ContextualHelp'; +import { subscribeToNewsReadEvents } from '@/features/news/lib/newsEvents'; import type { SeasonFlowState } from './seasonFlow'; function useOnlineStatus(): boolean { @@ -29,13 +31,43 @@ interface TopBarProps { } export function TopBar({ onOpenCommandPalette, flow }: TopBarProps) { - const { season, day, phase, teamName, userTeamId } = useGameStore(); + const { season, day, phase, teamName, userTeamId, isInitialized } = useGameStore(); + const worker = useWorker(); const location = useLocation(); const online = useOnlineStatus(); + const [unreadNewsIds, setUnreadNewsIds] = useState>(() => new Set()); const helpContent = PAGE_HELP[location.pathname] ?? null; const phaseLabel = flow?.phaseLabel ?? `Season ${season} — Day ${day}`; const detailLabel = flow?.detailLabel ?? phase; const progress = Math.round((flow?.progress ?? 0) * 100); + const unreadNewsCount = unreadNewsIds.size; + + const refreshUnreadNews = useCallback(async () => { + if (!isInitialized || !worker.isReady || typeof worker.getNews !== 'function') { + setUnreadNewsIds(new Set()); + return; + } + + try { + const news = await worker.getNews(100); + setUnreadNewsIds(new Set((news ?? []).filter((item) => !item.read).map((item) => item.id))); + } catch { + setUnreadNewsIds(new Set()); + } + }, [isInitialized, worker]); + + useEffect(() => { + void refreshUnreadNews(); + }, [day, phase, refreshUnreadNews, season]); + + useEffect(() => subscribeToNewsReadEvents((newsId) => { + setUnreadNewsIds((current) => { + if (!current.has(newsId)) return current; + const next = new Set(current); + next.delete(newsId); + return next; + }); + }), []); return (
@@ -89,6 +121,17 @@ export function TopBar({ onOpenCommandPalette, flow }: TopBarProps) { actions={helpContent.actions} /> )} + {unreadNewsCount > 0 ? ( + + + News + {unreadNewsCount} + + ) : null}