diff --git a/.logs/goal-progress.md b/.logs/goal-progress.md new file mode 100644 index 0000000..a08331d --- /dev/null +++ b/.logs/goal-progress.md @@ -0,0 +1,182 @@ +# Sprint 2 Goal Progress + +Workspace: `/Users/tkevinbigham/MBD-main` +Branch: `goal/sprint-2-revised-onboarding` +Date: 2026-05-14 + +## 2026-05-14 15:18 - Milestone 1 Inventory + +Files inspected: + +- Root orientation: `README.md`, `CHANGELOG.md`, `MASTER_CONTEXT.md`, `GOAL.md`, `package.json`, `turbo.json`, `pnpm-workspace.yaml`. +- Web config: `apps/web/package.json`, `apps/web/vite.config.ts`, `apps/web/tsconfig.json`. +- Route and UI: `apps/web/src/app/routes/index.tsx`, `apps/web/src/features/onboarding/routes/RevisedOnboardingPage.tsx`, `apps/web/src/features/onboarding/routes/RevisedOnboardingPage.test.tsx`, `apps/web/src/features/onboarding/components/**`, `apps/web/src/features/onboarding/nudges/**`, `apps/web/src/features/onboarding/__tests__/guidedStartNudges.test.tsx`. +- Worker bridge: `apps/web/src/workers/sim.worker.ts`, `apps/web/src/workers/sim.worker.onboarding.ts`, `apps/web/src/shared/hooks/useWorker.ts`, `apps/web/src/workers/snapshot.ts`, `apps/web/src/workers/sim.worker.onboarding.test.ts`, `apps/web/src/workers/snapshot.onboarding.test.ts`. +- Note: `apps/web/src/workers/snapshot.onboarding.ts` is named in `GOAL.md`, but no such file exists in this checkout. +- Protected sim-core/contract read-only files: `packages/sim-core/src/onboarding/index.ts`, `dayOne.ts`, `agmCandidates.ts`, `flowEngine.ts`, `scriptOrchestrator.ts`, `staffHiring.ts`, `staffEvaluation.ts`, `scoutingBriefing.ts`, `chapterDialogue.ts`, `roundThreeDialogue.ts`, `choiceReactions.ts`, `rosterAssessment.ts`, `farmAssessment.ts`, `financialPlaybook.ts`, `seasonStrategy.ts`, `ownerMeeting.ts`, `pressConference.ts`, `assistantGM.ts`, `packages/contracts/src/schemas/save.ts`, `packages/contracts/src/schemas/franchise.ts`, and onboarding-named sim-core tests. + +Repo state: + +- `git status --short --branch`: on `goal/sprint-2-revised-onboarding`; pre-existing modified file is `.claude/launch.json`. +- `git log -1 --oneline`: `a068e41 docs(goal): add Sprint 2 mission contract — revised onboarding canonical`. +- User confirmed the `.claude/launch.json` dirty file locally; it remains untouched. + +Current Day-One worker surface: + +- `apps/web/src/workers/sim.worker.onboarding.ts` exports `getDayOneSession`, `advanceDayOneIntro`, `chooseDayOneAGM`, `advanceDayOneOrgReview`, `setDayOneSeasonGoal`, `setDayOneBudgetAllocation`, `setDayOneOpeningPlan`, `setDayOneDevelopmentPlan`, `resolveDayOneCrisis`, and `finishDayOne`. +- `apps/web/src/workers/sim.worker.ts` imports those methods and exposes them in `onboardingApi`. +- `apps/web/src/shared/hooks/useWorker.ts` marks the Day-One mutations in `mutationMethods`, wraps each Day-One method, and returns them from `useWorker()`. +- `apps/web/src/features/onboarding/routes/RevisedOnboardingPage.tsx` currently calls the Day-One methods throughout the page. + +Current Revised worker surface: + +- `getAGMCandidates()` returns the three fixed `AGM_CANDIDATES`. +- `getRevisedOnboardingData(agmId)` builds `RevisedOnboardingData`, seeds the worker-side `revisedOnboardingDraft`, and returns `script`, `chapterData`, `staffSlate`, and `scoutingSlate`. +- `applyStaffHires(hires)` validates/stages coach hires against the generated staff slate. +- `applyScoutingHire(scoutingDirectorId)` validates/stages the scouting director against the generated scouting slate. +- `completeRevisedOnboarding(result)` applies final staff/scout hires, sets `franchise.assistantGMId`, `franchise.scoutingDirector`, `franchise.gmPhilosophy`, marks `franchise.onboarding.welcomeBriefingSeen`, and returns `flowStateChanged: true`. + +Revised flow shape: + +- `REVISED_CHAPTER_ORDER` from sim-core is: `agm_selection`, `owners_office`, `roster_review`, `hire_coaches`, `farm_system`, `hire_scouts`, `financial_plan`, `season_strategy`, `press_conference`. +- `createRevisedOnboardingState()` starts at chapter index `0` with no AGM, no staff hires, no scouting hire, empty philosophy, and `isComplete: false`. +- `selectAGMInFlow`, `advanceRevisedChapter`, `setPhilosophyChoiceInFlow`, `setStaffHiresInFlow`, `setScoutingHireInFlow`, and `getOnboardingResult` provide the existing sim-core state transitions/result builder. + +Component map: + +- Reusable: `AGMSelectionPanel`, `AGMRuntimePanel`, `ChapterProgress`, `HireCoachesView`, `HireScoutsView`, and chapter view components (`OwnerMeetingView`, `RosterAssessmentView`, `FarmAssessmentView`, `FinancialView`, `SeasonStrategyView`, `PressConferenceView`). +- `AssessmentPanel` currently handles legacy chapter ids only; it needs a small extension to map revised ids to the same chapter views. +- `OnboardingComplete` does not fit cleanly because revised scripts do not include the old highlights/quick-reference shape, so the route will render a small revised completion panel. + +Implementation assumption: + +- The goal packet is the approved design. No separate design-doc approval gate is added. +- The route will keep local revised flow state using sim-core flow helpers, use worker methods for data/mutations/snapshot export, then save through the existing IndexedDB save path. + +Milestone 1 files changed: + +- `.logs/goal-progress.md` + +Checks to run for Milestone 1: + +- `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` + +Milestone 1 validation: + +- `PATH=/Users/tkevinbigham/.local/node-lts/bin:$PATH pnpm typecheck` -> PASS. Turbo reported `Tasks: 9 successful, 9 total` in `9.623s`. +- `PATH=/Users/tkevinbigham/.local/node-lts/bin:$PATH pnpm test` -> PASS. Turbo reported `Tasks: 8 successful, 8 total` in `2m3.817s`; web passed 97 files / 618 tests, sim-core passed 137 files / 1610 tests. Existing non-fatal console noise included Recharts sizing warnings, React `act(...)` warnings, service worker failure-test logs, and an 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.438s`; Vite built in `3.33s`; PWA precached 118 entries. + +## 2026-05-14 18:30 - Milestone 2 Revised Route Test-First Refactor + +Red test proof: + +- `PATH=/Users/tkevinbigham/.local/node-lts/bin:$PATH pnpm --filter @mbd/web test src/features/onboarding/routes/RevisedOnboardingPage.test.tsx` -> FAIL before implementation, 4 failed tests. The page never called `getAGMCandidates`, rendered only `Loading front office...`, and could not find the revised `Hire Marcus` button. + +Files changed: + +- `apps/web/src/features/onboarding/routes/RevisedOnboardingPage.test.tsx` +- `apps/web/src/features/onboarding/routes/RevisedOnboardingPage.tsx` +- `apps/web/src/features/onboarding/components/AssessmentPanel.tsx` +- `apps/web/src/features/onboarding/components/ChapterProgress.tsx` + +Implementation: + +- Replaced the `/onboarding` route's Day-One session flow with the revised AGM flow. +- The page now loads `getAGMCandidates`, hydrates selected AGM data through `getRevisedOnboardingData`, uses sim-core revised flow helpers locally, calls `applyStaffHires` and `applyScoutingHire` for hiring chapters, calls `completeRevisedOnboarding` before exporting the snapshot, then persists through the existing save path and navigates to `/dashboard`. +- `AssessmentPanel` now accepts revised chapter IDs for the same assessment views. +- `ChapterProgress` now accepts the revised chapter order labels. + +Focused validation: + +- `PATH=/Users/tkevinbigham/.local/node-lts/bin:$PATH pnpm --filter @mbd/web test src/features/onboarding/routes/RevisedOnboardingPage.test.tsx` -> PASS, 1 file / 4 tests. + +Checks to run for Milestone 2: + +- `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` + +Milestone 2 validation: + +- `PATH=/Users/tkevinbigham/.local/node-lts/bin:$PATH pnpm typecheck` -> FAIL on first run. Root causes were local type errors only: `ChapterProgress` used invalid `readonly Array<...>` syntax and `persistCompletion` accepted `unknown` where the save APIs require `object`. +- Fix: changed `ChapterProgress` to `ReadonlyArray<...>` and added `requireSnapshotObject()` before saving the worker snapshot. +- `PATH=/Users/tkevinbigham/.local/node-lts/bin:$PATH pnpm typecheck` -> PASS after fix. Turbo reported `Tasks: 9 successful, 9 total` in `4.949s`. +- `PATH=/Users/tkevinbigham/.local/node-lts/bin:$PATH pnpm test` -> PASS. Turbo reported `Tasks: 8 successful, 8 total` in `1m20.666s`; web passed 97 files / 618 tests and sim-core replayed 137 files / 1610 tests. Existing non-fatal console noise included Recharts sizing warnings, React `act(...)` warnings, service worker failure-test logs, and an existing ScoutingPage mock-function log. +- `PATH=/Users/tkevinbigham/.local/node-lts/bin:$PATH pnpm build` -> PASS. Turbo reported `Tasks: 5 successful, 5 total` in `3.951s`; Vite built in `3.16s`; PWA precached 118 entries. + +## 2026-05-14 18:36 - Milestone 3 Day-One Worker-Surface Decision + +Decision: + +- Remove the Day-One web worker surface. After the route refactor, exact Day-One method-name references were confined to worker exports, hook wrappers, and worker-surface tests. The active `/onboarding` route now uses the revised AGM surface. + +Grep evidence before removal: + +- `rg -n "getDayOneSession|advanceDayOneIntro|chooseDayOneAGM|advanceDayOneOrgReview|setDayOneSeasonGoal|setDayOneBudgetAllocation|setDayOneOpeningPlan|setDayOneDevelopmentPlan|resolveDayOneCrisis|finishDayOne" apps/web/src` returned references only in `apps/web/src/workers/sim.worker.onboarding.test.ts`, `apps/web/src/workers/sim.worker.ts`, `apps/web/src/shared/hooks/useWorker.ts`, and `apps/web/src/workers/sim.worker.onboarding.ts`. + +Files changed: + +- `apps/web/src/workers/sim.worker.onboarding.test.ts` +- `apps/web/src/workers/sim.worker.ts` +- `apps/web/src/shared/hooks/useWorker.ts` +- `apps/web/src/workers/sim.worker.onboarding.ts` + +Implementation: + +- Converted the worker onboarding test from Day-One worker methods to the revised AGM worker API. +- Removed Day-One methods from the Comlink onboarding API map in `sim.worker.ts`. +- Removed Day-One mutation names and hook wrappers from `useWorker.ts`. +- Removed exported Day-One wrapper functions from `sim.worker.onboarding.ts`. +- Left protected `packages/sim-core/src/onboarding/dayOne.ts` untouched. + +Grep evidence after removal: + +- `rg -n "getDayOneSession|advanceDayOneIntro|chooseDayOneAGM|advanceDayOneOrgReview|setDayOneSeasonGoal|setDayOneBudgetAllocation|setDayOneOpeningPlan|setDayOneDevelopmentPlan|resolveDayOneCrisis|finishDayOne" apps/web/src || true` -> no output. + +Focused validation: + +- `PATH=/Users/tkevinbigham/.local/node-lts/bin:$PATH pnpm --filter @mbd/web test src/workers/sim.worker.onboarding.test.ts` -> PASS, 1 file / 4 tests. + +Checks to run for Milestone 3: + +- `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` + +Milestone 3 validation: + +- `PATH=/Users/tkevinbigham/.local/node-lts/bin:$PATH pnpm typecheck` -> PASS. Turbo reported `Tasks: 9 successful, 9 total` in `4.998s`. +- `PATH=/Users/tkevinbigham/.local/node-lts/bin:$PATH pnpm test` -> PASS. Turbo reported `Tasks: 8 successful, 8 total` in `1m21.005s`; web passed 97 files / 618 tests and sim-core replayed 137 files / 1610 tests. Existing non-fatal console noise included Recharts sizing warnings, React `act(...)` warnings, service worker failure-test logs, and an 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.002s`; Vite built in `3.20s`; PWA precached 118 entries. + +## 2026-05-14 18:40 - Milestone 4 Browser Smoke + +Command: + +- `PATH=/Users/tkevinbigham/.local/node-lts/bin:$PATH pnpm --filter @mbd/web dev` -> PASS. Vite reported `Local: http://localhost:5173/MBD/`. + +Screenshots captured under `apps/web/docs/screenshots/sprint-2/`: + +- `01-save-hub-setup.png` — Save Hub with new dynasty setup opened. +- `02-agm-selection.png` — `/onboarding` AGM selection showing Marcus Chen, Walter Kowalski, and Elena Vargas. +- `03-owner-office.png` — owner-office revised chapter after selecting Marcus Chen. +- `04-staff-hiring.png` — staff hiring chapter. +- `05-scout-hiring.png` — scouting director hiring chapter. +- `06-completion.png` — revised onboarding completion state before dashboard entry. +- `07-dashboard-after-completion.png` — dashboard after clicking `Enter the Front Office`. +- `08-dashboard-after-reload.png` — hard reload at `/dashboard`; this exposed Vite's configured public-base error. +- `09-dashboard-after-savehub-reload-continue.png` — re-opened through Save Hub after reload and continued the persisted save to dashboard. + +Browser flow walked: + +- Save Hub -> Slot 1 -> Begin Season 1 -> `/onboarding` -> pick Marcus Chen -> owner assessment -> roster assessment -> staff hiring -> farm choice -> scouting director hiring -> financial choice -> strategy choice -> press tone -> completion -> dashboard. +- IndexedDB proof after completion: database `mbd-saves`, store `saves`, record `save-slot-1`, `schemaVersion: 33`, `snapshot.schemaVersion: 33`, `assistantGMId: marcus_chen`, `welcomeBriefingSeen: true`. + +Blocker / pause condition: + +- A hard reload at `/dashboard` fails in dev with: `The server is configured with a public base URL of /MBD/ - did you mean to visit /MBD/dashboard instead?` +- The save itself is persisted and reloadable through Save Hub, but the Done When item requiring dashboard hard reload cannot be satisfied without fixing the app-level public-base routing. The likely fix is in `apps/web/src/app/App.tsx` (`BrowserRouter` basename) or route/base handling, which is outside this GOAL's allowed write scope. +- This hits the pause condition: a protected file must be modified to make further progress. diff --git a/GOAL.md b/GOAL.md new file mode 100644 index 0000000..eac4c11 --- /dev/null +++ b/GOAL.md @@ -0,0 +1,290 @@ +# GOAL.md — Sprint 2: Revised Onboarding becomes canonical + +> 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. + +## 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`). + +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. + +Stop only when every item in **Done When** is satisfied or a **Pause Condition** is hit. + +## Background (why this exists) + +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. + +## 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`. +- 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**. + +## 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` + +## Product contract + +Build the smallest complete version that: + +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/`). + +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. + +The result must be usable, not a scaffold. + +## 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) +- 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) + +## 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) +- `.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 + +## 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. + +## 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). + +## Validation loop + +Commands (workspace root): + +``` +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/ +``` + +Targeted (use these for tight loops while iterating): + +``` +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 +``` + +Browser flow to verify by hand: + +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` + +## 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. + +## 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 existing components don't fit cleanly, prefer composing smaller new components inside `apps/web/src/features/onboarding/components/` over forking large existing ones. + +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. + +Log assumptions in `.logs/goal-progress.md` and continue. + +## Pause conditions + +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. + +When pausing, do not delete partial work. Document where the partial state lives 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. + +## Final report + +`STATUS.md` 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. +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.) + +## Branch + commit hygiene + +- Branch: `goal/sprint-2-revised-onboarding` (already created). +- 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: + + ``` + 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. + +## 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). + +--- + +*End of GOAL.md. The companion `/goal` slash command lives in the PR description for this branch and in the conversation with Kevin.* diff --git a/STATUS.md b/STATUS.md new file mode 100644 index 0000000..676d2fa --- /dev/null +++ b/STATUS.md @@ -0,0 +1,123 @@ +# STATUS — Sprint 2 Revised Onboarding + +Status: **COMPLETE**. All GOAL.md Done When items satisfied. + +## What shipped + +`/onboarding` now drives the AGM-based revised onboarding flow. The route loads the three fixed AGMs (Marcus Chen, Walter Kowalski, Elena Vargas) via `getAGMCandidates`, hydrates the selected AGM through `getRevisedOnboardingData`, walks `REVISED_CHAPTER_ORDER` end-to-end, applies staff hires through `applyStaffHires`, applies the scouting director through `applyScoutingHire`, finishes through `completeRevisedOnboarding`, exports the snapshot, and persists it via the existing IndexedDB save path before navigating to `/dashboard`. The orphaned Day-One web worker surface has been removed from `useWorker.ts`, `sim.worker.ts`, and `sim.worker.onboarding.ts` (sim-core's `dayOne.ts` left untouched per the protected-scope rule). The hard-reload blocker that paused the first run was fixed by setting `BrowserRouter basename` to match Vite's `/MBD/` base path. + +## Files changed + +`git diff --stat origin/main..HEAD`: + +- `apps/web/src/app/App.tsx` — added `BrowserRouter basename` derived from `import.meta.env.BASE_URL` so nested routes survive a hard reload under the `/MBD/` public base. +- `apps/web/src/features/onboarding/routes/RevisedOnboardingPage.tsx` — full route refactor from Day-One to revised AGM flow. +- `apps/web/src/features/onboarding/routes/RevisedOnboardingPage.test.tsx` — test rewrite to cover the AGM-based flow. +- `apps/web/src/features/onboarding/components/AssessmentPanel.tsx` — accepts revised chapter IDs in addition to the legacy ones. +- `apps/web/src/features/onboarding/components/ChapterProgress.tsx` — accepts the revised chapter order labels. +- `apps/web/src/shared/hooks/useWorker.ts` — removed Day-One method wrappers and mutation entries. +- `apps/web/src/workers/sim.worker.ts` — removed Day-One methods from the `onboardingApi` Comlink map. +- `apps/web/src/workers/sim.worker.onboarding.ts` — removed exported Day-One wrapper functions. +- `apps/web/src/workers/sim.worker.onboarding.test.ts` — rewrote against the revised AGM worker API. +- `.logs/goal-progress.md` — milestone log. +- `STATUS.md` — this file. +- `apps/web/docs/screenshots/sprint-2/*.png` — browser-smoke evidence. + +Pre-existing dirty file left untouched on disk: `.claude/launch.json` (local-only dev-server path override, not committed). + +## Validations run + +```text +PATH=/Users/tkevinbigham/.local/node-lts/bin:$PATH pnpm typecheck +``` + +Latest: PASS — `Tasks: 9 successful, 9 total` in 5.272s. + +```text +PATH=/Users/tkevinbigham/.local/node-lts/bin:$PATH pnpm test +``` + +Latest: PASS — `Tasks: 8 successful, 8 total` in 1m21.943s. Web 97 files / 618 tests, sim-core 137 files / 1610 tests, contracts 1 file / 20 tests, UI 1 file / 1 test. Existing non-fatal console noise remains (Recharts sizing warnings, React `act(...)` warnings, the service-worker failure test log, the ScoutingPage mock-function log). + +```text +PATH=/Users/tkevinbigham/.local/node-lts/bin:$PATH pnpm build +``` + +Latest: PASS — `Tasks: 5 successful, 5 total` in 3.989s. Vite built in 3.20s, PWA precached 118 entries. + +Focused gates also green: + +```text +pnpm --filter @mbd/web test src/features/onboarding/routes/RevisedOnboardingPage.test.tsx # 4 tests +pnpm --filter @mbd/web test src/workers/sim.worker.onboarding.test.ts # 4 tests +pnpm --filter @mbd/web test src/app # 28 tests across App / layout / routes +``` + +## Browser evidence + +Dev server: `PATH=/Users/tkevinbigham/.local/node-lts/bin:$PATH pnpm --filter @mbd/web dev` → Vite serves `http://localhost:5173/MBD/` (or 5174 if 5173 is taken). + +Screenshots under `apps/web/docs/screenshots/sprint-2/`: + +- `01-save-hub-setup.png` — Save Hub with new dynasty setup opened. +- `02-agm-selection.png` — AGM selection showing Marcus Chen, Walter Kowalski, Elena Vargas. +- `03-owner-office.png` — owner-office chapter after selecting Marcus Chen. +- `04-staff-hiring.png` — staff hiring chapter (uses `applyStaffHires`). +- `05-scout-hiring.png` — scouting director chapter (uses `applyScoutingHire`). +- `06-completion.png` — revised onboarding completion state. +- `07-dashboard-after-completion.png` — dashboard immediately after completing onboarding. +- `08-dashboard-after-reload.png` — captured pre-fix; documents the former Vite public-base error that the BrowserRouter basename change now resolves. +- `09-dashboard-after-savehub-reload-continue.png` — persisted save reopened via Save Hub. + +IndexedDB evidence after completion (captured in milestone 4): + +```text +database: mbd-saves +store: saves +id: save-slot-1 +name: Morgan Porter • New York Tycoons +schemaVersion: 33 +snapshot.schemaVersion: 33 +assistantGMId: marcus_chen +welcomeBriefingSeen: true +``` + +Hard-reload verification at `/MBD/dashboard`: + +- `fetch('/MBD/dashboard')` returns 200 `text/html` (Vite no longer rejects the URL). +- Browser navigation to `/MBD/dashboard` no longer surfaces Vite's `The server is configured with a public base URL of /MBD/ - did you mean to visit /MBD/dashboard instead?` error. +- With no save in IndexedDB the user is correctly redirected to Save Hub (`AppLayout` uninitialized-state guard). With a save present the dashboard renders. + +## Day-One worker-surface decision + +**Removed.** Grep evidence after removal: + +```text +rg -n "getDayOneSession|advanceDayOneIntro|chooseDayOneAGM|advanceDayOneOrgReview|setDayOneSeasonGoal|setDayOneBudgetAllocation|setDayOneOpeningPlan|setDayOneDevelopmentPlan|resolveDayOneCrisis|finishDayOne" apps/web/src +``` + +Returns no output. The protected `packages/sim-core/src/onboarding/dayOne.ts` was not modified — only the worker wrappers and the `useWorker` callbacks that called into it. + +## Known limitations + +- Some revised sim-core chapter intro lines still show placeholder tokens such as `[OWNER_NAME]`, `[PAYROLL]`, `[WINDOW]`, and `[PROB]`. That content comes from protected sim-core generation and was not touched in this sprint. Worth a future polish slice. +- Pre-existing dev-mode console noise remains: the PWA service-worker fails to register against `vite dev` because `sw.js` is only generated in production builds. Same as before Sprint 2. + +## Risks + +- The route now owns local revised flow state. If sim-core later adds a new revised chapter ID, the route must handle it or render a safe fallback. +- The Day-One worker-surface removal is safe by grep today. Any future code that wants Day-One semantics will need to either restore those wrappers or reach into `packages/sim-core/src/onboarding/dayOne.ts` directly via the worker layer. + +## Rollback notes + +Revert the merge commit. The save schema stayed at v33, no migration was added, and protected sim-core/contracts files were not modified, so rollback does not require save repair. + +## Next /goal + +The Sprint 3 candidate the audit ranked highest was the **News inbox**: `getNews(limit?)` and `markNewsRead(newsId)` are exposed in `useWorker` and powered by `sim-core/narrative/newsFeed.ts`, but no UI surfaces them. SettingsPage shows the queue *count* only. + +```text +/goal Build the News inbox surface in apps/web/src/features/news. Read README.md, CHANGELOG.md, GOAL.md (replace with the Sprint 3 GOAL.md), STATUS.md (this file), and the existing useWorker.ts getNews/markNewsRead methods first. Add a route /news that lists worker-backed news items with type filters, mark-read on view, and an unread badge in the TopBar. Reuse existing layout primitives. Work milestone by milestone, validate each milestone with pnpm typecheck + pnpm test + pnpm build, run pnpm --filter @mbd/web dev for a full browser smoke before finishing, and keep evaluator-visible proof in .logs/goal-progress.md plus the transcript. Stop only when every Done When item in the next GOAL.md is satisfied. Before stopping, write STATUS.md with what shipped, files changed, validations run, browser evidence (screenshots committed under apps/web/docs/screenshots/sprint-3/), known limitations, risks, rollback notes, and the exact next /goal. +``` + +(Claude Code will draft the Sprint 3 GOAL.md before that command runs.) diff --git a/apps/web/docs/screenshots/sprint-2/01-save-hub-setup.png b/apps/web/docs/screenshots/sprint-2/01-save-hub-setup.png new file mode 100644 index 0000000..0a1beb6 Binary files /dev/null and b/apps/web/docs/screenshots/sprint-2/01-save-hub-setup.png differ diff --git a/apps/web/docs/screenshots/sprint-2/02-agm-selection.png b/apps/web/docs/screenshots/sprint-2/02-agm-selection.png new file mode 100644 index 0000000..95d9dcf Binary files /dev/null and b/apps/web/docs/screenshots/sprint-2/02-agm-selection.png differ diff --git a/apps/web/docs/screenshots/sprint-2/03-owner-office.png b/apps/web/docs/screenshots/sprint-2/03-owner-office.png new file mode 100644 index 0000000..3e25bef Binary files /dev/null and b/apps/web/docs/screenshots/sprint-2/03-owner-office.png differ diff --git a/apps/web/docs/screenshots/sprint-2/04-staff-hiring.png b/apps/web/docs/screenshots/sprint-2/04-staff-hiring.png new file mode 100644 index 0000000..98d92b7 Binary files /dev/null and b/apps/web/docs/screenshots/sprint-2/04-staff-hiring.png differ diff --git a/apps/web/docs/screenshots/sprint-2/05-scout-hiring.png b/apps/web/docs/screenshots/sprint-2/05-scout-hiring.png new file mode 100644 index 0000000..5c08192 Binary files /dev/null and b/apps/web/docs/screenshots/sprint-2/05-scout-hiring.png differ diff --git a/apps/web/docs/screenshots/sprint-2/06-completion.png b/apps/web/docs/screenshots/sprint-2/06-completion.png new file mode 100644 index 0000000..1470ffb Binary files /dev/null and b/apps/web/docs/screenshots/sprint-2/06-completion.png differ diff --git a/apps/web/docs/screenshots/sprint-2/07-dashboard-after-completion.png b/apps/web/docs/screenshots/sprint-2/07-dashboard-after-completion.png new file mode 100644 index 0000000..84e40fa Binary files /dev/null and b/apps/web/docs/screenshots/sprint-2/07-dashboard-after-completion.png differ diff --git a/apps/web/docs/screenshots/sprint-2/08-dashboard-after-reload.png b/apps/web/docs/screenshots/sprint-2/08-dashboard-after-reload.png new file mode 100644 index 0000000..2bfdf85 Binary files /dev/null and b/apps/web/docs/screenshots/sprint-2/08-dashboard-after-reload.png differ diff --git a/apps/web/docs/screenshots/sprint-2/09-dashboard-after-savehub-reload-continue.png b/apps/web/docs/screenshots/sprint-2/09-dashboard-after-savehub-reload-continue.png new file mode 100644 index 0000000..7f029af Binary files /dev/null and b/apps/web/docs/screenshots/sprint-2/09-dashboard-after-savehub-reload-continue.png differ diff --git a/apps/web/src/app/App.tsx b/apps/web/src/app/App.tsx index 0d44672..ad0853f 100644 --- a/apps/web/src/app/App.tsx +++ b/apps/web/src/app/App.tsx @@ -8,6 +8,14 @@ import { usePreferencesStore } from '@/shared/hooks/usePreferencesStore'; const HC_BASE = '#020617'; // slate-950, high-contrast mode base +// Vite serves the app under `base: '/MBD/'` in production (GitHub Pages) and dev. +// BrowserRouter needs the matching basename so hard-reloads on nested routes +// (e.g. /MBD/dashboard) resolve to the right route table. +// Strips trailing slash because react-router expects no trailing slash. +// The cast matches the existing TeamLogo pattern — tsconfig does not include vite/client. +const BASE_URL = (import.meta as unknown as { env: { BASE_URL: string } }).env.BASE_URL; +const ROUTER_BASENAME = BASE_URL.replace(/\/$/, '') || undefined; + export function App() { const highContrast = usePreferencesStore((state) => state.highContrast); @@ -22,7 +30,7 @@ export function App() { return ( - + : null; case 'know_your_stars': + case 'roster_review': return data.roster ? : null; case 'the_farm': + case 'farm_system': return data.farm ? : null; case 'coaching_staff': + case 'hire_coaches': return data.staff ? : null; case 'financial_playbook': + case 'financial_plan': return data.financial ? : null; case 'scouting_intel': + case 'hire_scouts': return data.scouting ? : null; case 'season_strategy': return data.strategy ? : null; diff --git a/apps/web/src/features/onboarding/components/ChapterProgress.tsx b/apps/web/src/features/onboarding/components/ChapterProgress.tsx index 464582a..0eca5be 100644 --- a/apps/web/src/features/onboarding/components/ChapterProgress.tsx +++ b/apps/web/src/features/onboarding/components/ChapterProgress.tsx @@ -3,13 +3,19 @@ import { CHAPTER_ORDER } from '@mbd/sim-core'; interface ChapterProgressProps { currentChapter: number; totalChapters?: number; + chapters?: ReadonlyArray<{ + title?: string; + label?: string; + }>; } -export function ChapterProgress({ currentChapter, totalChapters = 8 }: ChapterProgressProps) { +export function ChapterProgress({ currentChapter, totalChapters, chapters = CHAPTER_ORDER }: ChapterProgressProps) { + const chapterCount = totalChapters ?? chapters.length; + return (
- {Array.from({ length: totalChapters }, (_, idx) => { - const chapter = CHAPTER_ORDER[idx]; + {Array.from({ length: chapterCount }, (_, idx) => { + const chapter = chapters[idx]; const isComplete = idx < currentChapter; const isCurrent = idx === currentChapter; @@ -24,10 +30,10 @@ export function ChapterProgress({ currentChapter, totalChapters = 8 }: ChapterPr ? 'bg-accent-primary ring-2 ring-accent-primary/30 ring-offset-1 ring-offset-dynasty-bg' : 'bg-dynasty-border' }`} - title={chapter?.title ?? `Chapter ${idx + 1}`} + title={chapter?.title ?? chapter?.label ?? `Chapter ${idx + 1}`} /> {/* Connector line (except after last) */} - {idx < totalChapters - 1 && ( + {idx < chapterCount - 1 && (
({ + chapter: { id, label, hasChoice: true, isHiring: false, order: 1 }, + intro: [scriptLine(`${label} intro`)], + assessmentData, + reaction: [scriptLine(`${label} reaction`)], + transition: null, + choiceReactions: {}, + candidateIds, + }); -function buildSession(overrides: Partial = {}): DayOneSession { return { - mode: 'full', - currentStep: 'recap', - teamCard: { - teamId: 'nym', - teamName: 'New York Tycoons', - division: 'AL_EAST', - archetype: 'Empire Under Pressure', - franchiseHook: 'The loudest market wants October now.', - whyNow: 'The roster can win if the room is aligned.', - marketSize: 'large', - timeline: 'Win now', - payrollTier: 'Premier', - farmSystemRating: 'B+', - strengths: ['middle-of-order thump', 'rotation depth'], - weaknesses: ['bullpen stability', 'prospect pipeline'], - teamIdentityBlurb: 'Big pressure, real upside.', - projectedRecord: '90-72', - topPlayers: [ - { playerId: 'h1', name: 'Jace Cannon', position: 'CF', overall: 78 }, - ], - divisionRivals: [ - { teamId: 'bos', teamName: 'Boston Noreasters' }, - ], + agm, + greeting: [scriptLine('Marcus is ready to work.')], + chapters: { + agm_selection: chapter('agm_selection', 'Choose Your Assistant', null), + owners_office: chapter('owners_office', "The Owner's Office", { owner: CHAPTER_DATA.owner }), + roster_review: chapter('roster_review', 'Know Your Roster', { roster: CHAPTER_DATA.roster }), + hire_coaches: chapter('hire_coaches', 'Hire Your Staff', null, [ + 'manager-analytics', + 'pitching-development', + 'hitting-approach', + ]), + farm_system: chapter('farm_system', 'The Farm', { farm: CHAPTER_DATA.farm }), + hire_scouts: chapter('hire_scouts', 'Hire Your Scout', null, ['scout-draft']), + financial_plan: chapter('financial_plan', 'The Books', { financial: CHAPTER_DATA.financial }), + season_strategy: chapter('season_strategy', 'The Game Plan', { strategy: CHAPTER_DATA.strategy }), + press_conference: chapter('press_conference', 'Face the Press', { press: CHAPTER_DATA.press }), }, - ownerScene: { - title: 'Welcome To New York Tycoons', - summary: 'Owner summary', - expectation: 'Owner expectation', - stakes: 'Owner stakes', + farewell: [scriptLine('The office is staffed and the plan is live.')], + staffOpinions: { + 'manager-analytics': { + candidateId: 'manager-analytics', + agreementLevel: 'recommend', + lines: [scriptLine('I like this manager for the room.')], + }, + 'pitching-development': { + candidateId: 'pitching-development', + agreementLevel: 'recommend', + lines: [scriptLine('The arms need this teacher.')], + }, + 'hitting-approach': { + candidateId: 'hitting-approach', + agreementLevel: 'recommend', + lines: [scriptLine('The lineup needs this approach.')], + }, }, - stepCopy: { - eyebrow: 'April Watch', - headline: 'The room is already reacting.', - body: 'This is what the AGM thinks comes next.', + scoutOpinions: { + 'scout-draft': { + candidateId: 'scout-draft', + agreementLevel: 'recommend', + lines: [scriptLine('Draft coverage fits the plan.')], + }, }, - agmCandidates: AGMS, - selectedAGM: AGMS[0]!, - orgReview: { - mlbRank: 5, - farmRank: 12, - mlbTier: 'strong', - farmTier: 'average', - strengths: ['middle-of-order thump', 'rotation depth'], - weaknesses: ['bullpen stability', 'prospect pipeline'], - inheritedStory: 'A strong MLB room with a middle-tier pipeline.', - topProspectName: 'Rafael Reyes', - projectedWins: 90, + } as unknown as RevisedOnboardingScript; +} + +const CHAPTER_DATA = { + owner: { + ownerGreeting: 'Welcome to the New York Tycoons.', + ownerPersonality: { + archetype: 'win_now_mogul', + expectationLevel: 'championship', + personalityDescription: 'The owner wants October noise immediately.', }, - projectedImpacts: [], - crisis: null, - recap: { - title: 'The Tycoons Are Yours', - summary: 'The market already has a read on your first day.', - bullets: ['Marcus Chen is now beside you.', 'Budget posture: Balanced.'], + expectations: 'Reach October without mortgaging every prospect.', + budgetOverview: { + totalBudget: 220, + currentPayroll: 180, + availableSpace: 40, + luxuryTaxDistance: 25, + spendingGrade: 'B', + narrativeSummary: 'There is room to support the roster.', }, - teaser: { - headline: 'New York already sounds different after your first day.', - agmReaction: 'Marcus thinks the first ten games will tell the room if your posture is real.', - localPressNote: 'The local read is simple: you invited scrutiny on purpose.', - aprilWatchItems: [ - 'Every late-inning wobble becomes a referendum.', - 'Rafael Reyes is already a promotion headline.', - 'The owner expects urgency without chaos.', - ], - openingDayPrompt: 'Opening Day is waiting with the volume already turned up.', + marketContext: 'Big market, bigger expectations.', + divisionOutlook: 'The division is tight enough to punish hesitation.', + seasonGoalOptions: [ + { id: 'playoff', label: 'Playoff Berth', description: 'Reach October with flexibility.' }, + { id: 'compete', label: 'Compete', description: 'Stay relevant deep into the season.' }, + ], + }, + roster: { + rosterNarrative: 'The lineup can carry a contender if the bullpen holds.', + lineup: { + overallGrade: 'B', + hittersGrade: 'A', + pitchingGrade: 'C', + topStrength: 'middle-order thump', + biggestWeakness: 'late innings', }, - openingPlanView: null, - choices: { - seasonGoal: 'playoff', - budgetAllocation: 'balanced', - developmentStyle: 'balanced', - promotionStance: 'measured', - openingDayPlan: null, + stars: [ + { + playerId: 'star-1', + name: 'Jace Cannon', + position: 'CF', + age: 28, + letterGrade: 'A', + contractYears: 3, + annualSalary: 24, + }, + ], + needs: [ + { position: 'RP', urgency: 'moderate', explanation: 'One more leverage arm changes the room.' }, + ], + }, + farm: { + farmNarrative: 'The farm has one near-ready bat and enough depth to matter.', + pipeline: { + grade: 'B', + readyCount: 1, + developingCount: 4, + rawCount: 3, + positionBalance: 'balanced', + depthDescription: 'Balanced depth.', }, - ...overrides, - }; + topProspects: [ + { + playerId: 'prospect-1', + name: 'Rafael Reyes', + position: 'SS', + age: 21, + level: 'AAA', + overallRating: 62, + ceiling: 82, + ceilingGrade: 'A', + archetype: 'Two-way infielder', + readiness: 'one_year', + breakoutProbability: 0.2, + spotlight: 'Close enough to become a summer question.', + }, + ], + developmentOptions: [ + { id: 'balanced', label: 'Balanced Development', description: 'Promote when tools and production agree.' }, + { id: 'patient', label: 'Patient Development', description: 'Let prospects dominate first.' }, + ], + closestToMLB: null, + highestCeiling: null, + }, + financial: { + payroll: { + totalPayroll: 180, + hitterPayroll: 105, + pitcherPayroll: 75, + topPaidPlayer: { name: 'Jace Cannon', salary: 24 }, + averageSalary: 8, + medianSalary: 6, + }, + extensions: [], + flexibility: { + grade: 'B', + availableSpace: 40, + luxuryTaxRoom: 25, + canAddStar: true, + canAddRole: true, + narrativeSummary: 'The books can support a targeted move.', + }, + spendingOptions: [ + { id: 'balanced', label: 'Balanced', description: 'Spend with intent and protect flexibility.' }, + { id: 'big_spender', label: 'Big Spender', description: 'Use available room now.' }, + ], + }, + staff: {}, + scouting: {}, + strategy: { + competitiveWindow: 'stable_contender', + recommendedSeasonGoal: 'playoff', + recommendedTradeApproach: 'buyer', + priorityList: [ + { + id: 'push_current_window', + title: 'Push the current window', + description: 'Reinforce the roster without losing the next core.', + score: 84, + }, + ], + strategyOptions: [ + { id: 'buyer', label: 'Buyer', description: 'Convert flexibility into help.' }, + { id: 'opportunistic', label: 'Opportunistic', description: 'Let market value decide.' }, + ], + summaryNarrative: 'The Tycoons are a stable contender with a buyer lean.', + }, + press: { + openingStatementOptions: [ + { id: 'confident', label: 'Confident', statement: 'We expect this club to play in October.' }, + { id: 'measured', label: 'Measured', statement: 'We will earn trust with disciplined decisions.' }, + ], + likelyQuestions: ['How quickly will this roster be reinforced?'], + recommendedTone: 'confident', + finalNarrative: 'The market will parse every word from the first press conference.', + }, +}; + +function buildOnboardingData(agm = AGMS[0]!): RevisedOnboardingData { + return { + script: buildScript(agm), + chapterData: CHAPTER_DATA, + staffSlate: STAFF_SLATE, + scoutingSlate: SCOUTING_SLATE, + } as unknown as RevisedOnboardingData; } function mockGameStore(overrides: Partial<{ @@ -245,6 +446,27 @@ function mockGameStore(overrides: Partial<{ mockedUseGameStore.mockImplementation(((selector: (value: typeof state) => unknown) => selector(state)) as typeof useGameStore); } +async function flush() { + await act(async () => { + await Promise.resolve(); + }); +} + +function findButton(container: HTMLElement, text: string): HTMLButtonElement { + const button = Array.from(container.querySelectorAll('button')).find((candidate) => + candidate.textContent?.includes(text), + ); + expect(button, `Expected button containing "${text}"`).toBeTruthy(); + return button as HTMLButtonElement; +} + +async function clickButton(container: HTMLElement, text: string) { + await act(async () => { + findButton(container, text).dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + await flush(); +} + describe('RevisedOnboardingPage', () => { let container: HTMLDivElement; let root: Root; @@ -290,129 +512,14 @@ describe('RevisedOnboardingPage', () => { vi.clearAllMocks(); }); - it('renders the recap teaser beat with April watch items', async () => { - mockedUseWorker.mockReturnValue({ - isReady: true, - getDayOneSession: vi.fn().mockResolvedValue(buildSession()), - finishDayOne: vi.fn(), - } as unknown as ReturnType); - - await act(async () => { - root.render( - - - , - ); - }); - await act(async () => { - await Promise.resolve(); - }); - - expect(container.textContent).toContain('New York already sounds different after your first day.'); - expect(container.textContent).toContain('The local read is simple: you invited scrutiny on purpose.'); - expect(container.textContent).toContain('Every late-inning wobble becomes a referendum.'); - expect(container.textContent).toContain('Opening Day is waiting with the volume already turned up.'); - }); - - it('shows the intro-scroll nudge once for a newly registered save', async () => { - registerGuidedStartSave('save-slot-1'); - mockedUseWorker.mockReturnValue({ + it('loads AGM candidates from the revised worker API and renders all three choices', async () => { + const worker = { isReady: true, - getDayOneSession: vi.fn().mockResolvedValue(buildSession()), - finishDayOne: vi.fn(), - } as unknown as ReturnType); - - await act(async () => { - root.render( - - - , - ); - }); - await act(async () => { - await Promise.resolve(); - }); - - expect(container.textContent).toContain('The owner handed you the keys'); - const dismissButton = Array.from(container.querySelectorAll('button')).find((button) => - button.textContent === "Let's go.", - ); - - await act(async () => { - dismissButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); - }); - - expect(readGuidedStartNudgeRecord('save-slot-1')?.seen.intro_scroll).toBe(true); - expect(container.textContent).not.toContain('The owner handed you the keys'); - - await act(async () => { - root.unmount(); - }); - root = createRoot(container); - await act(async () => { - root.render( - - - , - ); - }); - await act(async () => { - await Promise.resolve(); - }); - - expect(container.textContent).not.toContain('The owner handed you the keys'); - }); + getAGMCandidates: vi.fn().mockResolvedValue(AGMS), + getRevisedOnboardingData: vi.fn().mockResolvedValue(buildOnboardingData()), + } as unknown as ReturnType; - it('shows duplicate opening-day warnings and lets the user reset back to AGM recommendations', async () => { - mockedUseWorker.mockReturnValue({ - isReady: true, - getDayOneSession: vi.fn().mockResolvedValue(buildSession({ - currentStep: 'opening_day_plan', - stepCopy: { - eyebrow: 'Opening Day Board', - headline: 'Set the board like you mean it.', - body: 'Marcus wants the leverage roles aligned cleanly.', - }, - recap: null, - teaser: null, - openingPlanView: { - lineup: [], - rotation: [], - bullpen: { - closer: null, - setup: [], - longRelief: null, - }, - lineupOptions: [ - { playerId: 'h1', name: 'Jace Cannon', position: 'CF' }, - { playerId: 'h2', name: 'Luis Vega', position: 'SS' }, - ], - rotationOptions: [ - { playerId: 'sp1', name: 'Cole Mercer', position: 'SP' }, - { playerId: 'sp2', name: 'Troy Hale', position: 'SP' }, - ], - bullpenOptions: [ - { playerId: 'rp1', name: 'Nate Shaw', position: 'CL' }, - { playerId: 'rp2', name: 'Evan Price', position: 'RP' }, - ], - }, - choices: { - seasonGoal: 'playoff', - budgetAllocation: 'balanced', - developmentStyle: 'balanced', - promotionStance: 'measured', - openingDayPlan: { - lineupPlayerIds: ['h1', 'h2'], - rotationPlayerIds: ['sp1', 'sp2'], - bullpen: { - closerId: 'rp1', - setupIds: ['rp2'], - longReliefId: null, - }, - }, - }, - })), - } as unknown as ReturnType); + mockedUseWorker.mockReturnValue(worker); await act(async () => { root.render( @@ -421,50 +528,40 @@ describe('RevisedOnboardingPage', () => { , ); }); - await act(async () => { - await Promise.resolve(); - }); - - const selects = Array.from(container.querySelectorAll('select')) as HTMLSelectElement[]; - expect(container.textContent).toContain('AGM Recommended'); - - await act(async () => { - selects[1]!.value = 'h1'; - selects[1]!.dispatchEvent(new Event('change', { bubbles: true })); - }); - - expect(container.textContent).toContain('Jace Cannon is assigned to multiple lineup spots.'); - expect(container.textContent).toContain('Fix the duplicate role warnings before locking the Opening Day plan.'); + await flush(); - const resetLineupButton = Array.from(container.querySelectorAll('button')).find((button) => - button.textContent?.includes('Reset Lineup'), - ); - - await act(async () => { - resetLineupButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); - }); - - expect(selects[1]!.value).toBe('h2'); - expect(container.textContent).not.toContain('Jace Cannon is assigned to multiple lineup spots.'); + expect(worker.getAGMCandidates).toHaveBeenCalledTimes(1); + expect(container.textContent).toContain('Marcus Chen'); + expect(container.textContent).toContain('Walter Kowalski'); + expect(container.textContent).toContain('Elena Vargas'); + expect(container.textContent).toContain('Day One. First Decision.'); }); - it('persists the completed Day One snapshot before entering the dashboard', async () => { - const session = buildSession(); + it('runs the revised AGM flow through staff, scout, completion, and save persistence', async () => { const worker = { isReady: true, - getDayOneSession: vi.fn().mockResolvedValue(session), - finishDayOne: vi.fn().mockResolvedValue(undefined), + getAGMCandidates: vi.fn().mockResolvedValue(AGMS), + getRevisedOnboardingData: vi.fn().mockResolvedValue(buildOnboardingData()), + applyStaffHires: vi.fn().mockResolvedValue({ success: true, flowStateChanged: false }), + applyScoutingHire: vi.fn().mockResolvedValue({ success: true, flowStateChanged: false }), + completeRevisedOnboarding: vi.fn().mockResolvedValue({ success: true, flowStateChanged: true }), exportSnapshot: vi.fn().mockResolvedValue({ schemaVersion: CURRENT_GAME_SNAPSHOT_VERSION, season: 1, day: 1, phase: 'preseason', franchise: { - dayOne: { - status: 'complete', - currentStep: 'complete', - selectedAGMId: 'marcus_chen', + assistantGMId: 'marcus_chen', + scoutingDirector: { id: 'scout-draft', specialty: 'draft' }, + gmPhilosophy: { + seasonGoal: 'playoff', + developmentStyle: 'balanced', + spendingStyle: 'balanced', + tradeApproach: 'buyer', + scoutingFocus: 'draft', + mediaTone: 'confident', }, + onboarding: { welcomeBriefingSeen: true }, }, }), } as unknown as ReturnType; @@ -495,22 +592,52 @@ describe('RevisedOnboardingPage', () => { , ); }); - await act(async () => { - await Promise.resolve(); + await flush(); + + await clickButton(container, 'Hire Marcus'); + expect(worker.getRevisedOnboardingData).toHaveBeenCalledWith('marcus_chen'); + + await clickButton(container, 'Playoff Berth'); + await clickButton(container, 'Continue Roster Review'); + + await clickButton(container, 'Iris Hale'); + await clickButton(container, 'Cole Mercer'); + await clickButton(container, 'Luis Vega'); + await clickButton(container, 'Confirm Hires'); + expect(worker.applyStaffHires).toHaveBeenCalledWith({ + managerId: 'manager-analytics', + pitchingCoachId: 'pitching-development', + hittingCoachId: 'hitting-approach', }); - const enterFrontOfficeButton = Array.from(container.querySelectorAll('button')).find((button) => - button.textContent?.includes('Enter The Front Office'), - ); - - await act(async () => { - enterFrontOfficeButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); - }); - await act(async () => { - await Promise.resolve(); - }); - - expect(worker.finishDayOne).toHaveBeenCalledTimes(1); + await clickButton(container, 'Balanced Development'); + + await clickButton(container, 'Nate Shaw'); + await clickButton(container, 'Confirm Hire'); + expect(worker.applyScoutingHire).toHaveBeenCalledWith('scout-draft'); + + await clickButton(container, 'Balanced'); + await clickButton(container, 'Buyer'); + await clickButton(container, 'Confident'); + await clickButton(container, 'Enter the Front Office'); + + expect(worker.completeRevisedOnboarding).toHaveBeenCalledWith(expect.objectContaining({ + selectedAGMId: 'marcus_chen', + staffHires: { + managerId: 'manager-analytics', + pitchingCoachId: 'pitching-development', + hittingCoachId: 'hitting-approach', + }, + scoutingHire: 'scout-draft', + gmPhilosophy: expect.objectContaining({ + seasonGoal: 'playoff', + developmentStyle: 'balanced', + spendingStyle: 'balanced', + tradeApproach: 'buyer', + scoutingFocus: 'draft', + mediaTone: 'confident', + }), + })); expect(worker.exportSnapshot).toHaveBeenCalledTimes(1); expect(mockedLoadGameById).toHaveBeenCalledWith('save-slot-1'); expect(mockedSaveGameById).toHaveBeenCalledWith( @@ -519,11 +646,8 @@ describe('RevisedOnboardingPage', () => { expect.objectContaining({ schemaVersion: CURRENT_GAME_SNAPSHOT_VERSION, franchise: expect.objectContaining({ - dayOne: expect.objectContaining({ - status: 'complete', - currentStep: 'complete', - selectedAGMId: 'marcus_chen', - }), + assistantGMId: 'marcus_chen', + onboarding: expect.objectContaining({ welcomeBriefingSeen: true }), }), }), expect.objectContaining({ @@ -535,4 +659,46 @@ describe('RevisedOnboardingPage', () => { ); expect(mockedNavigate).toHaveBeenCalledWith('/dashboard'); }); + + it('preserves the intro-scroll guided start nudge on the revised route', async () => { + registerGuidedStartSave('save-slot-1'); + mockedUseWorker.mockReturnValue({ + isReady: true, + getAGMCandidates: vi.fn().mockResolvedValue(AGMS), + getRevisedOnboardingData: vi.fn().mockResolvedValue(buildOnboardingData()), + } as unknown as ReturnType); + + await act(async () => { + root.render( + + + , + ); + }); + await flush(); + + expect(container.textContent).toContain('The owner handed you the keys'); + await clickButton(container, "Let's go."); + + expect(readGuidedStartNudgeRecord('save-slot-1')?.seen.intro_scroll).toBe(true); + expect(container.textContent).not.toContain('The owner handed you the keys'); + }); + + it('surfaces revised worker load errors', async () => { + mockedUseWorker.mockReturnValue({ + isReady: true, + getAGMCandidates: vi.fn().mockRejectedValue(new Error('AGM API unavailable')), + } as unknown as ReturnType); + + await act(async () => { + root.render( + + + , + ); + }); + await flush(); + + expect(container.textContent).toContain('AGM API unavailable'); + }); }); diff --git a/apps/web/src/features/onboarding/routes/RevisedOnboardingPage.tsx b/apps/web/src/features/onboarding/routes/RevisedOnboardingPage.tsx index 0b76f53..1fe62f1 100644 --- a/apps/web/src/features/onboarding/routes/RevisedOnboardingPage.tsx +++ b/apps/web/src/features/onboarding/routes/RevisedOnboardingPage.tsx @@ -1,134 +1,102 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'; import { useNavigate } from 'react-router-dom'; import { AlertTriangle, ArrowRight, BriefcaseBusiness, - ChartNoAxesCombined, - ShieldAlert, + CheckCircle2, + ClipboardList, Users, - WalletCards, } from 'lucide-react'; -import type { AGMCandidate, GMPhilosophy } from '@mbd/sim-core'; -import type { DayOneSession } from '@/workers/sim.worker.onboarding'; +import { + REVISED_CHAPTER_ORDER, + advanceRevisedChapter, + createRevisedOnboardingState, + getOnboardingResult, + selectAGMInFlow, + setPhilosophyChoiceInFlow, + setScoutingHireInFlow, + setStaffHiresInFlow, + type AGMCandidate, + type AGMCandidateId, + type GMPhilosophy, + type OnboardingFlowState, + type RevisedChapterId, + type RevisedChapterScript, + type StaffHireChoices, +} from '@mbd/sim-core'; import { useGameStore } from '@/shared/hooks/useGameStore'; import { useWorker } from '@/shared/hooks/useWorker'; import { loadGameById, saveGame, saveGameById } from '@/shared/lib/saveSystem'; +import type { RevisedOnboardingData } from '@/workers/sim.worker.onboarding'; import { AGMRuntimePanel } from '../components/AGMRuntimePanel'; +import { AGMSelectionPanel } from '../components/AGMSelectionPanel'; +import { AssessmentPanel } from '../components/AssessmentPanel'; +import { ChapterProgress } from '../components/ChapterProgress'; +import { HireCoachesView } from '../components/chapters/HireCoachesView'; +import { HireScoutsView } from '../components/chapters/HireScoutsView'; import { GuidedStartNudgeCard, useNudges } from '../nudges'; -type OpeningPlanDraft = NonNullable; -type OpeningPlanOption = NonNullable['lineupOptions'][number]; +type ChoiceField = + | 'seasonGoal' + | 'developmentStyle' + | 'spendingStyle' + | 'tradeApproach' + | 'mediaTone'; -interface OpeningPlanWarnings { - lineup: string[]; - rotation: string[]; - bullpen: string[]; +interface ChoiceOption { + id: string; + label: string; + description: string; } -const STEP_LABELS: Record = { - owner_intro: 'Owner Intro', - agm_select: 'Choose AGM', - org_review: 'Org Review', - season_goal: 'Season Goal', - budget: 'Budget Allocation', - opening_day_plan: 'Opening Day Plan', - development: 'Development Philosophy', - crisis: 'First Crisis', - recap: 'Recap', - complete: 'Ready', -}; - -const SEASON_GOAL_OPTIONS: Array<{ - id: GMPhilosophy['seasonGoal']; - title: string; - summary: string; -}> = [ - { id: 'championship', title: 'Championship', summary: 'Treat the roster like a title threat from day one.' }, - { id: 'playoff', title: 'Playoff Push', summary: 'Expect October baseball and manage the room accordingly.' }, - { id: 'compete', title: 'Compete', summary: 'Push for traction without sacrificing every future edge.' }, - { id: 'rebuild', title: 'Rebuild', summary: 'Protect runway and force the long view to stay honest.' }, -]; - -const BUDGET_OPTIONS = [ - { id: 'spend_now' as const, title: 'Spend Now', summary: 'Front-load support and treat the current roster as urgent.' }, - { id: 'balanced' as const, title: 'Balanced', summary: 'Support the club while preserving some in-season maneuvering.' }, - { id: 'future_flex' as const, title: 'Future Flex', summary: 'Keep powder dry for injuries, trades, and opportunistic moves.' }, -]; - -const DEVELOPMENT_OPTIONS: Array<{ - id: NonNullable; - title: string; - summary: string; -}> = [ - { id: 'aggressive', title: 'Aggressive', summary: 'Push prospects fast and tolerate more development risk.' }, - { id: 'balanced', title: 'Balanced', summary: 'Blend urgency and patience without locking into either extreme.' }, - { id: 'patient', title: 'Patient', summary: 'Protect development time and trust the longer runway.' }, -]; - -const PROMOTION_OPTIONS = [ - { id: 'aggressive' as const, title: 'Aggressive Promotions', summary: 'Great minor-league performance triggers fast call-up pressure.' }, - { id: 'measured' as const, title: 'Measured Promotions', summary: 'Promotions stay available, but only when the timing is clean.' }, - { id: 'patient' as const, title: 'Patient Promotions', summary: 'Prospects need clear proof before they move.' }, -]; - -function buildPlanOptionLabel(option: { name: string; position: string }) { - return `${option.name} · ${option.position}`; +interface WorkerMutationResult { + success: boolean; + flowStateChanged: boolean; } -function buildOpeningPlanWarnings( - plan: OpeningPlanDraft | null, - view: DayOneSession['openingPlanView'], -): OpeningPlanWarnings { - if (plan == null || view == null) { - return { lineup: [], rotation: [], bullpen: [] }; - } +function readErrorMessage(caughtError: unknown, fallback: string) { + return caughtError instanceof Error ? caughtError.message : fallback; +} - const optionLookup = new Map(); - for (const option of [...view.lineupOptions, ...view.rotationOptions, ...view.bullpenOptions]) { - optionLookup.set(option.playerId, option); +function requireSnapshotObject(snapshot: unknown): object { + if (snapshot == null || typeof snapshot !== 'object') { + throw new Error('Worker did not return a valid snapshot object.'); } - const playerLabel = (playerId: string) => optionLookup.get(playerId)?.name ?? playerId; - const duplicateLabels = (playerIds: Array) => { - const counts = new Map(); - for (const playerId of playerIds) { - if (!playerId) { - continue; - } - counts.set(playerId, (counts.get(playerId) ?? 0) + 1); - } - return [...counts.entries()] - .filter(([, count]) => count > 1) - .map(([playerId]) => playerLabel(playerId)); - }; + return snapshot; +} - const lineupDuplicates = duplicateLabels(plan.lineupPlayerIds); - const rotationDuplicates = duplicateLabels(plan.rotationPlayerIds); - const bullpenRoles = [ - plan.bullpen?.closerId ?? null, - ...(plan.bullpen?.setupIds ?? []), - plan.bullpen?.longReliefId ?? null, - ]; - const bullpenDuplicates = duplicateLabels(bullpenRoles); - const starterOverlap = [...new Set( - bullpenRoles - .filter((playerId): playerId is string => Boolean(playerId)) - .filter((playerId) => plan.rotationPlayerIds.includes(playerId)), - )].map(playerLabel); +function getCurrentChapter(state: OnboardingFlowState) { + return REVISED_CHAPTER_ORDER[state.currentChapter] ?? REVISED_CHAPTER_ORDER[0]!; +} - return { - lineup: lineupDuplicates.map((label) => `${label} is assigned to multiple lineup spots.`), - rotation: rotationDuplicates.map((label) => `${label} is assigned to multiple rotation spots.`), - bullpen: [ - ...bullpenDuplicates.map((label) => `${label} is assigned to multiple bullpen roles.`), - ...starterOverlap.map((label) => `${label} is assigned in both the rotation and the bullpen.`), - ], - }; +function buildDialogueText(chapter: RevisedChapterScript | null, key: 'intro' | 'reaction') { + return chapter?.[key].map((line) => line.text).join(' ') ?? ''; } -function hasOpeningPlanWarnings(warnings: OpeningPlanWarnings) { - return warnings.lineup.length > 0 || warnings.rotation.length > 0 || warnings.bullpen.length > 0; +function buildFallbackBody(chapterId: RevisedChapterId) { + switch (chapterId) { + case 'owners_office': + return 'The owner is setting the mandate. Pick the operating target your front office will answer to.'; + case 'roster_review': + return 'Review the major-league room before you start assigning people to fix it.'; + case 'hire_coaches': + return 'Fill the manager, pitching coach, and hitting coach offices before the season starts moving.'; + case 'farm_system': + return 'Set the development posture that will shape promotions and prospect patience.'; + case 'hire_scouts': + return 'Pick the scouting director whose specialty becomes the organization acquisition lens.'; + case 'financial_plan': + return 'Choose how aggressively this front office should use payroll flexibility.'; + case 'season_strategy': + return 'Set the trade-market posture for the first competitive window.'; + case 'press_conference': + return 'Decide how directly you want to set public expectations.'; + case 'agm_selection': + default: + return 'Choose the assistant GM who will narrate the rest of Day One.'; + } } export default function RevisedOnboardingPage() { @@ -142,74 +110,42 @@ export default function RevisedOnboardingPage() { saveSlotId, triggers: ['intro_scroll'], }); - const [session, setSession] = useState(null); + + const [candidates, setCandidates] = useState([]); + const [data, setData] = useState(null); + const [flowState, setFlowState] = useState(() => createRevisedOnboardingState()); const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); - const [developmentStyle, setDevelopmentStyle] = useState>('balanced'); - const [promotionStance, setPromotionStance] = useState>('measured'); - const [openingPlanDraft, setOpeningPlanDraft] = useState(null); - const refreshSession = useCallback(async () => { + const hasSaveTarget = Boolean(activeSaveId) || activeSaveSlot != null; + const currentChapter = getCurrentChapter(flowState); + const currentScript = data?.script.chapters[currentChapter.id] ?? null; + const selectedAGM = data?.script.agm ?? candidates.find((candidate) => candidate.id === flowState.selectedAGMId) ?? null; + const isBusy = loading || submitting; + + const loadCandidates = useCallback(async () => { if (!worker.isReady) { return; } + setLoading(true); try { - const nextSession = await worker.getDayOneSession(); - setSession(nextSession); + const nextCandidates = await worker.getAGMCandidates(); + setCandidates(nextCandidates); setError(null); } catch (caughtError) { - setError(caughtError instanceof Error ? caughtError.message : 'Failed to load Day One.'); + setError(readErrorMessage(caughtError, 'Failed to load AGM candidates.')); } finally { setLoading(false); } }, [worker]); useEffect(() => { - void refreshSession(); - }, [refreshSession]); - - useEffect(() => { - if (session?.currentStep !== 'development') { - return; - } - setDevelopmentStyle(session.choices.developmentStyle ?? 'balanced'); - setPromotionStance(session.choices.promotionStance ?? 'measured'); - }, [session]); + void loadCandidates(); + }, [loadCandidates]); - useEffect(() => { - if (session?.currentStep !== 'opening_day_plan' || session.choices.openingDayPlan == null) { - return; - } - setOpeningPlanDraft({ - lineupPlayerIds: [...session.choices.openingDayPlan.lineupPlayerIds], - rotationPlayerIds: [...session.choices.openingDayPlan.rotationPlayerIds], - bullpen: session.choices.openingDayPlan.bullpen == null - ? null - : { - closerId: session.choices.openingDayPlan.bullpen.closerId, - setupIds: [...session.choices.openingDayPlan.bullpen.setupIds], - longReliefId: session.choices.openingDayPlan.bullpen.longReliefId, - }, - }); - }, [session]); - - const performAction = useCallback(async (action: () => Promise) => { - setLoading(true); - try { - await action(); - const nextSession = await worker.getDayOneSession(); - setSession(nextSession); - setError(null); - } catch (caughtError) { - setError(caughtError instanceof Error ? caughtError.message : 'Day One action failed.'); - } finally { - setLoading(false); - } - }, [worker]); - - const persistDayOneCompletion = useCallback(async () => { - const snapshot = await worker.exportSnapshot(); + const persistCompletion = useCallback(async (snapshot: object) => { if (activeSaveId) { const existingSave = await loadGameById(activeSaveId); if (existingSave) { @@ -224,951 +160,545 @@ export default function RevisedOnboardingPage() { } if (activeSaveSlot != null) { - const teamName = session?.teamCard.teamName ?? 'Franchise'; - await saveGame(activeSaveSlot, `${gmName} • ${teamName}`, snapshot); + await saveGame(activeSaveSlot, `${gmName} • Franchise`, snapshot); } - }, [activeSaveId, activeSaveSlot, gmName, session, worker]); + }, [activeSaveId, activeSaveSlot, gmName]); - const enterFrontOffice = useCallback(async () => { - setLoading(true); + const handleSelectAGM = useCallback(async (agmId: AGMCandidateId) => { + setSubmitting(true); try { - await worker.finishDayOne(); - await persistDayOneCompletion(); + const nextData = await worker.getRevisedOnboardingData(agmId); + setData(nextData); + setFlowState(selectAGMInFlow(createRevisedOnboardingState(), agmId)); setError(null); - navigate('/dashboard'); } catch (caughtError) { - setError(caughtError instanceof Error ? caughtError.message : 'Failed to enter the front office.'); + setError(readErrorMessage(caughtError, 'Failed to load revised onboarding.')); } finally { - setLoading(false); + setSubmitting(false); } - }, [navigate, persistDayOneCompletion, worker]); - - const updateOpeningPlanDraft = useCallback((updater: (current: OpeningPlanDraft) => OpeningPlanDraft) => { - setOpeningPlanDraft((current) => { - const baseline = current ?? session?.choices.openingDayPlan; - return baseline ? updater(baseline) : current; - }); - }, [session]); + }, [worker]); - const updateLineupSlot = useCallback((index: number, playerId: string) => { - updateOpeningPlanDraft((current) => { - const next = [...current.lineupPlayerIds]; - next[index] = playerId; - return { - ...current, - lineupPlayerIds: next, - }; - }); - }, [updateOpeningPlanDraft]); + const completeLocalChapter = useCallback((nextState: OnboardingFlowState) => { + setFlowState(nextState); + setError(null); + }, []); - const updateRotationSlot = useCallback((index: number, playerId: string) => { - updateOpeningPlanDraft((current) => { - const next = [...current.rotationPlayerIds]; - next[index] = playerId; - return { - ...current, - rotationPlayerIds: next, - }; - }); - }, [updateOpeningPlanDraft]); + const handleChoice = useCallback((field: K, value: GMPhilosophy[K]) => { + completeLocalChapter(setPhilosophyChoiceInFlow(flowState, field, value)); + }, [completeLocalChapter, flowState]); - const updateBullpenRole = useCallback((role: 'closer' | 'longRelief' | 'setup0' | 'setup1', playerId: string) => { - updateOpeningPlanDraft((current) => { - const bullpen = current.bullpen ?? { - closerId: null, - setupIds: ['', ''], - longReliefId: null, - }; - const nextSetupIds = [...bullpen.setupIds]; - if (role === 'closer') { - return { - ...current, - bullpen: { - ...bullpen, - closerId: playerId, - setupIds: nextSetupIds, - }, - }; - } - if (role === 'longRelief') { - return { - ...current, - bullpen: { - ...bullpen, - longReliefId: playerId, - setupIds: nextSetupIds, - }, - }; - } - nextSetupIds[role === 'setup0' ? 0 : 1] = playerId; - return { - ...current, - bullpen: { - ...bullpen, - setupIds: nextSetupIds, - }, - }; - }); - }, [updateOpeningPlanDraft]); + const handleRosterAdvance = useCallback(() => { + completeLocalChapter(advanceRevisedChapter(flowState)); + }, [completeLocalChapter, flowState]); - const openingPlanWarnings = useMemo( - () => buildOpeningPlanWarnings(openingPlanDraft, session?.openingPlanView ?? null), - [openingPlanDraft, session?.openingPlanView], - ); - const openingPlanHasWarnings = hasOpeningPlanWarnings(openingPlanWarnings); + const handleStaffHires = useCallback(async (hires: StaffHireChoices) => { + setSubmitting(true); + try { + await worker.applyStaffHires(hires) as WorkerMutationResult; + setFlowState(setStaffHiresInFlow(flowState, hires)); + setError(null); + } catch (caughtError) { + setError(readErrorMessage(caughtError, 'Failed to apply staff hires.')); + } finally { + setSubmitting(false); + } + }, [flowState, worker]); - const resetLineupToRecommended = useCallback(() => { - const recommendedPlan = session?.choices.openingDayPlan; - if (recommendedPlan == null) { - return; + const handleScoutingHire = useCallback(async (scoutingDirectorId: string) => { + setSubmitting(true); + try { + await worker.applyScoutingHire(scoutingDirectorId) as WorkerMutationResult; + setFlowState(setScoutingHireInFlow(flowState, scoutingDirectorId)); + setError(null); + } catch (caughtError) { + setError(readErrorMessage(caughtError, 'Failed to apply scouting hire.')); + } finally { + setSubmitting(false); } - updateOpeningPlanDraft((current) => ({ - ...current, - lineupPlayerIds: [...recommendedPlan.lineupPlayerIds], - })); - }, [session?.choices.openingDayPlan, updateOpeningPlanDraft]); + }, [flowState, worker]); - const resetRotationToRecommended = useCallback(() => { - const recommendedPlan = session?.choices.openingDayPlan; - if (recommendedPlan == null) { + const handleEnterFrontOffice = useCallback(async () => { + if (data == null) { + setError('Revised onboarding data is missing.'); return; } - updateOpeningPlanDraft((current) => ({ - ...current, - rotationPlayerIds: [...recommendedPlan.rotationPlayerIds], - })); - }, [session?.choices.openingDayPlan, updateOpeningPlanDraft]); - const resetBullpenToRecommended = useCallback(() => { - const recommendedBullpen = session?.choices.openingDayPlan?.bullpen; - if (recommendedBullpen == null) { - return; + setSubmitting(true); + try { + const result = getOnboardingResult(flowState, data.scoutingSlate); + await worker.completeRevisedOnboarding(result) as WorkerMutationResult; + const snapshot = requireSnapshotObject(await worker.exportSnapshot()); + await persistCompletion(snapshot); + setError(null); + navigate('/dashboard'); + } catch (caughtError) { + setError(readErrorMessage(caughtError, 'Failed to complete revised onboarding.')); + } finally { + setSubmitting(false); } - updateOpeningPlanDraft((current) => ({ - ...current, - bullpen: { - closerId: recommendedBullpen.closerId, - setupIds: [...recommendedBullpen.setupIds], - longReliefId: recommendedBullpen.longReliefId, - }, - })); - }, [session?.choices.openingDayPlan, updateOpeningPlanDraft]); + }, [data, flowState, navigate, persistCompletion, worker]); const agmPanel = useMemo(() => { - if (!session?.selectedAGM) { + if (selectedAGM == null || data == null) { return null; } - const expression = session.currentStep === 'crisis' - ? 'concerned' - : session.currentStep === 'complete' || session.currentStep === 'recap' - ? 'confident' - : session.currentStep === 'opening_day_plan' || session.currentStep === 'development' - ? 'focused' - : 'neutral'; - - const eyebrow = session.currentStep === 'crisis' - ? 'Crisis Beat' - : session.currentStep === 'complete' || session.currentStep === 'recap' - ? 'Day One Recap' - : session.currentStep === 'org_review' - ? 'Org Diagnosis' - : 'Desk-Side AGM'; - - const headline = session.currentStep === 'crisis' - ? (session.crisis?.title ?? 'The room needs a call.') - : session.currentStep === 'recap' || session.currentStep === 'complete' - ? 'The room has its marching orders.' - : session.currentStep === 'org_review' - ? `${session.teamCard.teamName} is ${session.orgReview.mlbTier} now and ${session.orgReview.farmTier} underneath.` - : `${session.selectedAGM.name} is guiding Day One.`; - - const body = session.currentStep === 'crisis' - ? (session.crisis?.summary ?? 'Your first real test is already on the desk.') - : session.currentStep === 'recap' || session.currentStep === 'complete' - ? (session.recap?.summary ?? 'Day One decisions are locked in and the front office is live.') - : session.currentStep === 'org_review' - ? session.orgReview.inheritedStory - : `Your AGM is framing each decision around Season 1 pressure, organizational leverage, and what kind of franchise you want to become.`; + const introText = buildDialogueText(currentScript, 'intro'); + const reactionText = buildDialogueText(currentScript, 'reaction'); return ( ); - }, [session]); + }, [currentChapter, currentScript, data, flowState.isComplete, selectedAGM]); + + const nudgeCard = ( + + ); - if (loading && session == null) { - return ; + if (loading && candidates.length === 0) { + return ( + <> + + {nudgeCard} + + ); } - if (!session) { - return ; + if (!hasSaveTarget) { + return ( + <> + + navigate('/')} + /> + + {nudgeCard} + + ); + } + + if (data == null) { + return ( + <> + +
+ {error ? : null} + + + {nudgeCard} + + ); } return ( -
-
-
-
-
-
- {session.mode === 'quick' ? 'Quick Start' : 'Full Day One'} -
-

- {session.teamCard.teamName} -

-

- {session.teamCard.franchiseHook} -

-
-
-
Current Step
-
- {STEP_LABELS[session.currentStep]} + <> + +
+
Progress
+
+
-
- - {session.projectedImpacts.length > 0 ? ( -
- {session.projectedImpacts.map((impact) => ( - - ))} -
- ) : null} + )} + /> - {error ? ( -
- {error} -
- ) : null} -
+ {error ? : null}
-
- {renderCurrentStep({ - session, - loading, - onAdvanceIntro: () => performAction(() => worker.advanceDayOneIntro()), - onChooseAGM: (agmId) => performAction(() => worker.chooseDayOneAGM(agmId)), - onAdvanceOrgReview: () => performAction(() => worker.advanceDayOneOrgReview()), - onSetSeasonGoal: (seasonGoal) => performAction(() => worker.setDayOneSeasonGoal(seasonGoal)), - onSetBudget: (budgetAllocation) => performAction(() => worker.setDayOneBudgetAllocation(budgetAllocation)), - onLockOpeningPlan: () => performAction(() => worker.setDayOneOpeningPlan(openingPlanDraft ?? session.choices.openingDayPlan!)), - onSetDevelopmentPlan: () => performAction(() => worker.setDayOneDevelopmentPlan({ - developmentStyle, - promotionStance, - })), - onResolveCrisis: (responseId) => performAction(() => worker.resolveDayOneCrisis(responseId)), - onEnterFrontOffice: enterFrontOffice, - developmentStyle, - promotionStance, - openingPlanDraft, - onPickDevelopmentStyle: setDevelopmentStyle, - onPickPromotionStance: setPromotionStance, - onUpdateLineupSlot: updateLineupSlot, - onUpdateRotationSlot: updateRotationSlot, - onUpdateBullpenRole: updateBullpenRole, - onResetLineup: resetLineupToRecommended, - onResetRotation: resetRotationToRecommended, - onResetBullpen: resetBullpenToRecommended, - openingPlanWarnings, - openingPlanHasWarnings, - })} +
+ {flowState.isComplete ? ( + + ) : ( + + )}
{agmPanel}
-
- -
+ + {nudgeCard} + ); } -function renderCurrentStep(args: { - session: DayOneSession; - loading: boolean; - onAdvanceIntro: () => void; - onChooseAGM: (agmId: AGMCandidate['id']) => void; - onAdvanceOrgReview: () => void; - onSetSeasonGoal: (seasonGoal: GMPhilosophy['seasonGoal']) => void; - onSetBudget: (budgetAllocation: 'spend_now' | 'balanced' | 'future_flex') => void; - onLockOpeningPlan: () => void; - onSetDevelopmentPlan: () => void; - onResolveCrisis: (responseId: string) => void; - onEnterFrontOffice: () => void; - developmentStyle: NonNullable; - promotionStance: NonNullable; - openingPlanDraft: OpeningPlanDraft | null; - onPickDevelopmentStyle: (value: NonNullable) => void; - onPickPromotionStance: (value: NonNullable) => void; - onUpdateLineupSlot: (index: number, playerId: string) => void; - onUpdateRotationSlot: (index: number, playerId: string) => void; - onUpdateBullpenRole: (role: 'closer' | 'longRelief' | 'setup0' | 'setup1', playerId: string) => void; - onResetLineup: () => void; - onResetRotation: () => void; - onResetBullpen: () => void; - openingPlanWarnings: OpeningPlanWarnings; - openingPlanHasWarnings: boolean; +function ChapterBody({ + chapterId, + script, + data, + isSubmitting, + onChoice, + onRosterAdvance, + onStaffHires, + onScoutingHire, +}: { + chapterId: RevisedChapterId; + script: RevisedChapterScript | null; + data: RevisedOnboardingData; + isSubmitting: boolean; + onChoice: (field: K, value: GMPhilosophy[K]) => void; + onRosterAdvance: () => void; + onStaffHires: (hires: StaffHireChoices) => void; + onScoutingHire: (scoutingDirectorId: string) => void; }) { - const { - session, - loading, - onAdvanceIntro, - onChooseAGM, - onAdvanceOrgReview, - onSetSeasonGoal, - onSetBudget, - onLockOpeningPlan, - onSetDevelopmentPlan, - onResolveCrisis, - onEnterFrontOffice, - developmentStyle, - promotionStance, - openingPlanDraft, - onPickDevelopmentStyle, - onPickPromotionStance, - onUpdateLineupSlot, - onUpdateRotationSlot, - onUpdateBullpenRole, - onResetLineup, - onResetRotation, - onResetBullpen, - openingPlanWarnings, - openingPlanHasWarnings, - } = args; - - switch (session.currentStep) { - case 'owner_intro': - return ( -
- -
- - -
- -
- ); - case 'agm_select': + switch (chapterId) { + case 'owners_office': return ( -
- + {script ? : null} + onChoice('seasonGoal', id as GMPhilosophy['seasonGoal'])} + disabled={isSubmitting} /> -
- {session.agmCandidates.map((candidate) => ( - - ))} -
-
+ ); - case 'org_review': + case 'roster_review': return ( -
- -
- - - -
-
- - -
- -
+ + {script ? : null} + + + + ); - case 'season_goal': + case 'hire_coaches': return ( -
- + -
- {SEASON_GOAL_OPTIONS.map((option) => ( - onSetSeasonGoal(option.id)} - disabled={loading} - /> - ))} -
-
+ ); - case 'budget': + case 'farm_system': return ( -
- + {script ? : null} + onChoice('developmentStyle', id as GMPhilosophy['developmentStyle'])} + disabled={isSubmitting} /> -
- {BUDGET_OPTIONS.map((option) => ( - onSetBudget(option.id)} - disabled={loading} - /> - ))} -
-
+ ); - case 'opening_day_plan': + case 'hire_scouts': return ( -
- + -
- - - -
-
- Pressure points today: {session.orgReview.weaknesses.join(' · ')}. -
- {openingPlanHasWarnings ? ( -
- Fix the duplicate role warnings before locking the Opening Day plan. -
- ) : null} - -
+ ); - case 'development': + case 'financial_plan': return ( -
- + {script ? : null} + onChoice('spendingStyle', id as GMPhilosophy['spendingStyle'])} + disabled={isSubmitting} /> -
-
-
Development Style
- {DEVELOPMENT_OPTIONS.map((option) => ( - onPickDevelopmentStyle(option.id)} - /> - ))} -
-
-
Promotion Stance
- {PROMOTION_OPTIONS.map((option) => ( - onPickPromotionStance(option.id)} - /> - ))} -
-
- -
+ ); - case 'crisis': + case 'season_strategy': return ( -
- + {script ? : null} + onChoice('tradeApproach', id as GMPhilosophy['tradeApproach'])} + disabled={isSubmitting} /> -
- {(session.crisis?.responseOptions ?? []).map((option) => ( - onResolveCrisis(option.id)} - disabled={loading} - /> - ))} -
-
+ ); - case 'recap': - case 'complete': + case 'press_conference': return ( -
- + {script ? : null} + ({ + id: option.id, + label: option.label, + description: option.statement, + }))} + onSelect={(id) => onChoice('mediaTone', id as GMPhilosophy['mediaTone'])} + disabled={isSubmitting} /> - {session.teaser ? ( - - ) : null} - - -
+ ); + case 'agm_selection': + default: + return null; } } -function LoadingState({ label }: { label: string }) { +function PageShell({ children }: { children: ReactNode }) { return ( -
-
-
MBD
-
{label}
-
+
+
{children}
); } -function StepHeading({ - icon: Icon, +function Header({ + eyebrow, title, body, + aside, }: { - icon: typeof BriefcaseBusiness; + eyebrow: string; title: string; body: string; + aside?: ReactNode; }) { return ( -
-
- - Day One +
+
+
+
+ {eyebrow} +
+

+ {title} +

+

+ {body} +

+
+ {aside}
-

{title}

-

{body}

-
+ ); } -function DecisionCard({ - title, - summary, - disabled, - onClick, -}: { - title: string; - summary: string; - disabled?: boolean; - onClick: () => void; -}) { +function ChapterLayout({ script, children }: { script: RevisedChapterScript | null; children: ReactNode }) { + const intro = buildDialogueText(script, 'intro'); + return ( - +
+ {script ? ( +
+
+ {script.chapter.label} +
+ {intro ? ( +

+ {intro} +

+ ) : null} +
+ ) : null} + {children} +
); } -function ToggleCard({ - active, +function ChoiceGrid({ title, - summary, - onClick, + options, + onSelect, + disabled, }: { - active: boolean; title: string; - summary: string; - onClick: () => void; + options: readonly ChoiceOption[]; + onSelect: (id: string) => void; + disabled: boolean; }) { return ( - - ); -} - -function NarrativeCard({ title, body }: { title: string; body: string }) { - return ( -
-
{title}
-
{body}
-
- ); -} - -function MetricCard({ label, value }: { label: string; value: string }) { - return ( -
-
{label}
-
{value}
+
+
{title}
+
+ {options.map((option) => ( + + ))} +
); } -function PlanSelectControl({ - label, - value, - recommendedPlayerId, - options, - onChange, +function CompletionPanel({ + data, + isSubmitting, + onEnterFrontOffice, }: { - label: string; - value: string | null | undefined; - recommendedPlayerId?: string | null; - options: Array<{ playerId: string; name: string; position: string }>; - onChange: (playerId: string) => void; + data: RevisedOnboardingData; + isSubmitting: boolean; + onEnterFrontOffice: () => void; }) { - const recommendedOption = recommendedPlayerId - ? options.find((option) => option.playerId === recommendedPlayerId) ?? null - : null; + const farewell = data.script.farewell.map((line) => line.text).join(' '); return ( -