diff --git a/.logs/goal-progress.md b/.logs/goal-progress.md index 5dde084..83d804e 100644 --- a/.logs/goal-progress.md +++ b/.logs/goal-progress.md @@ -452,3 +452,111 @@ 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` + +--- + +# Sprint 4 Goal Progress + +Workspace: `/Users/tkevinbigham/MBD-main` +Branch: `goal/sprint-4-front-office` +Date: 2026-05-14 + +## Pause Before Milestone 1 - Worker Shape Mismatch + +- Commit: none; stopped before implementation. +- typecheck: not run; no milestone code was completed. +- test: not run; no milestone code was completed. +- Files touched: `STATUS.md`, `.logs/goal-progress.md`. +- Scope decisions: Did not add worker methods, did not touch protected worker/sim/contracts/save files, and did not reinterpret contract-negotiation fields from trade package data. +- Surprises: `getOpenNegotiations()` and `getNegotiation(id)` return `TradeNegotiationView` trade-package shapes (`offeringAssets`, `requestingAssets`, `counterOffer`, `phase`, `dialogue`, `expiresAtDay`), not contract salary asks/offered terms. Also, `getInteractivePressConference()` already has an `AppLayout` consumer through `PressConferenceModal`, so the audit's zero-consumer claim is stale. + +## Milestone 1 — Trade Negotiations Inbox scaffolding + +- Commit: c3eb524 `feat(trade-negotiations): add /trade-negotiations Inbox route` +- typecheck: PASS — `Tasks: 9 successful, 9 total` +- test: PASS — `Tasks: 8 successful, 8 total` +- Files touched: `apps/web/src/app/routes/index.tsx`, `apps/web/src/features/trade-negotiations/routes/TradeNegotiationsInboxPage.tsx`, `apps/web/src/features/trade-negotiations/routes/TradeNegotiationsInboxPage.test.tsx` +- Scope decisions: Built the Inbox as a read-only route against `TradeNegotiationView`, sorting open negotiations first and then by earliest `expiresAtDay`. Error handling uses the existing `logger` plus `sonner` toast and an inline retry state. +- Surprises: Focused red failed exactly because the page did not exist yet. Full test output includes existing Recharts/React act/service-worker/scouting warning noise and the new test's intentional mocked worker failure log. + +## Milestone 2 — Trade Negotiation detail + +- Commit: 3b1536d `feat(trade-negotiations): add /trade-negotiations/:id detail view` +- typecheck: PASS — `Tasks: 9 successful, 9 total` +- test: PASS — `Tasks: 8 successful, 8 total` +- Files touched: `.logs/goal-progress.md`, `apps/web/src/app/routes/index.tsx`, `apps/web/src/features/trade-negotiations/routes/TradeNegotiationDetailPage.tsx`, `apps/web/src/features/trade-negotiations/routes/TradeNegotiationDetailPage.test.tsx` +- Scope decisions: Kept the detail surface read-only and used `getPlayer` only to resolve existing `TradeAsset` player IDs into profile links. The only action CTA deep-links to `/trade?negotiationId=...` for the existing builder to handle. +- Surprises: `TradeCounterPackage` stores raw `TradeAsset[]`, not `TradeAssetView[]`, so the detail page formats draft picks and IFA pool space locally while resolving player names through the worker. + +## Milestone 3 — Sidebar Trade Negotiations entry + +- Commit: 2b99559 `feat(layout): add Trade Negotiations entry to Sidebar` +- typecheck: PASS — `Tasks: 9 successful, 9 total` +- test: PASS — `Tasks: 8 successful, 8 total` +- Files touched: `.logs/goal-progress.md`, `apps/web/src/app/layout/Sidebar.tsx`, `apps/web/src/app/layout/Sidebar.test.tsx` +- Scope decisions: Placed Trade Negotiations directly after Trades and used the lucide `Handshake` icon, avoiding the News `Inbox` icon. +- Surprises: None; the red check failed only on the missing sidebar label as intended. + +## Milestone 4 — Trade page cross-linking + deep-link from Inbox + +- Commit: 14043ed `feat(trade): cross-link player names + accept ?negotiationId deep link` +- typecheck: PASS — `Tasks: 9 successful, 9 total` +- test: PASS — `Tasks: 8 successful, 8 total` +- Files touched: `.logs/goal-progress.md`, `apps/web/src/features/trade/components/DeadlineDramaPanel.tsx`, `apps/web/src/features/trade/routes/TradePage.tsx`, `apps/web/src/features/trade/routes/TradePage.test.tsx` +- Scope decisions: Linked structured player assets in Trade rows, offer cards, active negotiation packages, package summaries, multi-team summaries, and the deadline bidding-war target using the Roster `/players/:id` pattern. Added `/trade?negotiationId=...` loading through existing `getNegotiation` and `applyNegotiationToBuilder`, with toast + param clearing for stale or closed negotiations. +- Surprises: Trade recap/ticker prose still contains narrative player names without a safe token map; no regex parsing was added. + +## Milestone 5 — Draft page cross-linking + +- Commit: 0ad438f `feat(draft): cross-link prospects to /players/:id` +- typecheck: PASS — `Tasks: 9 successful, 9 total` +- test: PASS — `Tasks: 8 successful, 8 total` +- Files touched: `.logs/goal-progress.md`, `apps/web/src/features/draft/routes/DraftPage.tsx`, `apps/web/src/features/draft/routes/DraftPage.test.tsx` +- Scope decisions: Used `/players/:playerId?tab=development` for draft prospects, matching the existing Minors prospect precedent. Linked available prospects, selected prospect card, draft ticker, board cells, post-draft best picks, and the user's draft class without parsing prose summaries. +- Surprises: Draft commentary and buzz entries have `playerId` fields but not display-safe player names in every entry, so the milestone kept links to places where a structured player ID and visible name were already paired. + +## Milestone 6 — Scouting page cross-linking + +- Commit: 0736d0c `feat(scouting): cross-link player names to /players/:id` +- typecheck: PASS — `Tasks: 9 successful, 9 total` +- test: PASS — `Tasks: 8 successful, 8 total`; web suite `103 passed`, `639 passed` +- Files touched: `.logs/goal-progress.md`, `apps/web/src/features/scouting/components/ScoutConflictsTab.tsx`, `apps/web/src/features/scouting/routes/ScoutingPage.tsx`, `apps/web/src/features/scouting/routes/ScoutingPage.test.tsx` +- Scope decisions: Linked only structured scouting player/prospect identifiers: pro search results, generated pro reports, recent reports, selected IFA reports, IFA board prospects, and scout-conflict headlines. Kept action/status prose such as signing result messages as text because those strings are composed state, not structured link records. +- Surprises: The route test mock was stale against older worker names, so the milestone updated it to the current `getScoutingStaff` / `getIFAPool` / `searchPlayers` surface before asserting the links. + +## Milestone 7 — Stats / leaderboards cross-linking + +- Commit: 5614f99 `feat(stats): cross-link leaderboard entries to /players/:id` +- typecheck: PASS — `Tasks: 9 successful, 9 total` +- test: PASS — `Tasks: 8 successful, 8 total`; web suite `103 passed`, `639 passed` +- Files touched: `.logs/goal-progress.md`, `apps/web/src/features/league/routes/LeadersPage.test.tsx` +- Scope decisions: Production leaderboard rows already used `/players/:id` links, so this milestone added explicit regression coverage for both WAR and FIP leaderboard states instead of changing working route code. +- Surprises: The only actual leaderboard surface lives under `apps/web/src/features/league/routes/LeadersPage.tsx`; `apps/web/src/features/stats/**` contains the stat encyclopedia and shared quality-scale test, not player leaderboard rows. + +## Milestone 8 — News page player-reference chips + +- Commit: c088244 `feat(news): cross-link player references in news items` +- typecheck: PASS — `Tasks: 9 successful, 9 total` +- test: PASS — `Tasks: 8 successful, 8 total`; web suite `103 passed`, `640 passed` +- Files touched: `.logs/goal-progress.md`, `apps/web/src/features/news/routes/NewsPage.tsx`, `apps/web/src/features/news/routes/NewsPage.test.tsx` +- Scope decisions: Used the existing `relatedPlayerIds` field from `NewsItem` and resolved display labels through existing `worker.getPlayer`; unresolved or failed lookups fall back to the raw player ID. Moved related chips outside the clickable news-card button so player links are valid interactive elements. +- Surprises: News already displayed related player IDs as plain text spans, so the milestone was a clean conversion to linked chips rather than a skip. + +## Milestone 9 — Trade Value on Player Profile + +- Commit: 0832a9b `feat(players): surface trade value on player profile` +- typecheck: PASS — `Tasks: 9 successful, 9 total` +- test: PASS — `Tasks: 8 successful, 8 total`; web suite `103 passed`, `641 passed` +- Files touched: `.logs/goal-progress.md`, `apps/web/src/features/players/routes/PlayerProfilePage.tsx`, `apps/web/src/features/players/routes/PlayerProfilePage.test.tsx`, `apps/web/src/shared/hooks/useWorker.ts` +- Scope decisions: Rendered a compact sidebar card rather than adding another tab. The hook now forwards the existing sim-worker `getPlayerTradeValue` query; no new worker query/action was added. +- Surprises: The sim worker already exposed `getPlayerTradeValue`, but `useWorker` did not forward it, so wiring the profile required a hook wrapper. + +## Milestone 10 — Browser smoke + screenshots + STATUS.md + +- Commit: a531964d04d9 `docs(sprint-4): browser smoke, STATUS report, and handoff` +- typecheck: PASS — `Tasks: 9 successful, 9 total` +- test: PASS — `Tasks: 8 successful, 8 total`; web suite `103 passed`, `641 passed` +- build: PASS — `Tasks: 5 successful, 5 total`; PWA precache `122 entries (3286.88 KiB)` +- Files touched: `.logs/goal-progress.md`, `STATUS.md`, `apps/web/docs/screenshots/sprint-4/*.png` +- Scope decisions: Captured the full Milestone 10 screenshot set under `apps/web/docs/screenshots/sprint-4/` and rewrote STATUS with validation tails, bundle notes, route invariants, cross-link coverage, and rollback/next-goal notes. Left the pre-existing `.claude/launch.json` change untouched. +- Surprises: The Codex in-app browser screenshot command timed out on `Page.captureScreenshot`, so the committed evidence was captured with a Playwright Chromium fallback against the same local dev server; no repo dependencies or manifests changed. diff --git a/GOAL.md b/GOAL.md index 07a2fbb..9ef4e3e 100644 --- a/GOAL.md +++ b/GOAL.md @@ -1,286 +1,433 @@ -# GOAL.md — Sprint 3.5: Hard-reload state survival +# GOAL.md — Sprint 4 (REVISED): Trade Negotiations Inbox + Cross-Linking + Trade Value > Single-mission contract for Codex (or any one-shot coding agent). > Format: Goal Packet v2.0 — Kevin's one-shot ritual. -> Built on top of Sprint 3 ([PR #76](https://github.com/KevinBigham/MBD/pull/76)). Will rebase onto `main` once Sprint 3 merges. +> Branched from `main` at `93b3f5b` (post Sprint 3.5 [PR #77](https://github.com/KevinBigham/MBD/pull/77)). +> **Revised after Codex's first-pass pause.** See "What Changed In This Revision" below. -## Mission +--- -When a user hard-reloads any in-game route (`/dashboard`, `/roster`, `/trade`, `/draft`, `/news`, etc.), they are currently redirected to the Save Hub because `apps/web/src/app/layout/AppLayout.tsx:446` checks `useGameStore().isInitialized` and `useGameStore` is a plain Zustand store with no persistence. The user's read state, unread count, and current view are preserved in IndexedDB but **invisible behind the redirect**. +## What Changed In This Revision -Fix it. Persist enough of `useGameStore` to `localStorage` (via Zustand `persist` middleware) so the active save id/slot survive a reload. On app boot, if the persisted save id resolves to a real IndexedDB record, auto-load it through the worker and call `initializeGame()` **before** `AppLayout`'s guard fires. While auto-loading, show a loading state. On error, clear the persisted state and fall through to Save Hub as today. +The original Sprint 4 contract was wrong about two of the four "orphans": -Stop only when every item in **Done When** is satisfied or a **Pause Condition** is hit. +1. **`getOpenNegotiations()` returns `TradeNegotiationView[]`** (trade packages between teams), **not contract negotiations** (salary asks / years / agent terms). Codex correctly paused under Pause Condition 1. +2. **`getInteractivePressConference()` is already consumed** by `apps/web/src/app/layout/AppLayout.tsx:167`, which feeds `PressConferenceModal`. It is not orphaned. The Press Conference milestone is dropped. -## Background +This revision: -Sprint 3's STATUS.md identified this as pre-existing app-wide behavior surfaced during news-inbox testing: +- Renames the orphan surface from "Negotiations Center" to **"Trade Negotiations Inbox"** and rewrites it against the actual `TradeNegotiationView` shape. +- Drops the Press Conference milestone entirely. +- Keeps Trade Value on Player Profile (genuinely orphaned). +- Keeps the five cross-linking milestones (Trade / Draft / News / Scouting / Stats). +- Tightens to **10 milestones** (was 12). -> `AppLayout` returns `` whenever `useGameStore().isInitialized` is false after a browser reload. `useGameStore` is a plain Zustand store with no persistence middleware, so `isInitialized` resets to `false` on every hard reload. This redirect fires on **every** in-game route — `/dashboard`, `/roster`, `/trade`, `/news` — not just news. +--- -Sprint 2's `BrowserRouter basename` fix solved URL parsing (`/MBD/news` now resolves to the `/news` route). Sprint 3.5 solves the next layer up: **state hydration**, so the resolved route actually renders. +## Mission -IndexedDB save state is already correct — the bug is purely in the boot sequence. +Wire the genuinely orphaned worker surfaces into a coherent Trade & Player experience, and finish the player-profile cross-linking that Roster / Free Agency / Minors already have but Trade / Draft / News / Scouting / Stats lack. -## Baseline +The two orphaned worker surfaces: -- This branch is built on top of Sprint 3 (`goal/sprint-3-news-inbox`, PR #76). When Sprint 3 merges to `main`, **rebase this branch onto the new main** before continuing: `git fetch origin && git rebase origin/main`. -- Save schema: `CURRENT_GAME_SNAPSHOT_VERSION = 33`. Do not bump. -- Test counts post-Sprint-3: web 99 files / 624 tests; sim-core 137/1610; contracts 1/20; UI 1/1. +1. **`getOpenNegotiations()` → `TradeNegotiationView[]`** — list of the user's open trade negotiations. Currently the only consumer is `TradePage` for the **active** negotiation in the trade builder. There is no inbox view of all open negotiations. +2. **`getNegotiation(negotiationId)` → `TradeNegotiationView | null`** — detail/lookup for a single trade negotiation. +3. **`getPlayerTradeValue(playerId)` → `PlayerTradeValue | null`** — zero consumers anywhere. -## Read first +The five cross-linking gaps (Roster / Free Agency / Minors already pass, these still ship plain text): -Inspect these before editing. Do not skip. +4. **Trade page** — player names in trade-block lists are not clickable. +5. **Draft page** — draft prospects are not clickable. +6. **News page** — news items referencing players are not clickable (only if `NewsItem` has a machine-readable player ref). +7. **Scouting page** — player names are not clickable. +8. **Stats / leaderboards** — leaderboard entries are not clickable. -**Repo orientation:** -- `README.md`, `CHANGELOG.md`, `MASTER_CONTEXT.md` -- `GOAL.md` (this file) -- Sprint 3's `STATUS.md` — confirms the architectural diagnosis +By the time you finish, every place a player is named in the app should link to `/players/:playerId`, the user should have a **Trade Negotiations Inbox** at `/trade-negotiations` with a detail view at `/trade-negotiations/:id`, and **Trade Value** should appear on the Player Profile. -**Core state + boot path:** -- `apps/web/src/shared/hooks/useGameStore.ts` — the Zustand store you'll add `persist` middleware to -- `apps/web/src/app/App.tsx` — the BrowserRouter shell where boot logic lands (Sprint 2 added the `basename`; build on top of that) -- `apps/web/src/app/layout/AppLayout.tsx` — confirms the `if (!isInitialized) return ` redirect at line 446 and the worker-readiness gate above it -- `apps/web/src/app/routes/index.tsx` — confirms `/` is `SetupPage` and every in-game route is nested under `AppLayout` -- `apps/web/src/shared/lib/saveSystem.ts` — `loadSaveSafely`, `inspectSaveById`, `listSaves`, the Dexie schema. This is the I/O layer you'll call during boot. -- `apps/web/src/shared/hooks/useWorker.ts` — the worker bridge. You'll use `importSnapshot` (or whatever the canonical "load this save into the worker" method is — confirm from current code) to hydrate the worker before flipping `isInitialized`. -- `apps/web/src/features/setup/routes/SetupPage.tsx` — the Save Hub path that today calls `loadGameById` → worker hydrate → `initializeGame`. Read it to understand the **exact** sequence the auto-resume must mimic. +--- -**Save recovery (DO NOT break):** -- `apps/web/src/features/save-recovery/SaveRecoveryProvider.tsx` -- `apps/web/src/features/save-recovery/SaveLoadErrorBoundary.tsx` -- `apps/web/src/features/save-recovery/SaveRecoveryDialog.tsx` -- `apps/web/src/features/save-recovery/__tests__/` (if present) +## Read-First (do this before writing anything) -The recovery dialog already handles corrupt-save cases. Auto-resume must use the same path (`loadSaveSafely → SaveRecoveryProvider.showFailure on `{ ok: false }`) so corrupt saves don't crash the auto-load. +1. `README.md` +2. `CHANGELOG.md` +3. `MASTER_CONTEXT.md` (architecture) +4. The previous `STATUS.md` (Sprint 4 first-pass pause — useful context for what NOT to assume) +5. This `GOAL.md` +6. `apps/web/src/workers/sim.worker.queries.ts` lines 2596–2601 (`getNegotiation`, `getOpenNegotiations`). +7. `apps/web/src/workers/sim.worker.trade.ts` lines 184–203 — the **`TradeNegotiationView` shape**. Read this carefully before scaffolding the inbox row UI. The fields you have to work with are: `id`, `teamId`, `teamName`, `teamAbbreviation`, `phase`, `roundsCompleted`, `expiresAtDay`, `dialogue`, `proposal`, `counterOffer`, `isComplete`, `canAccept`, `canCounter`, `canReject`. There are **no salary, years, or contract-terms fields** — this is a trade between teams. +8. `apps/web/src/features/trade/routes/TradePage.tsx` lines 795–830 (state) and lines 1241+ (`applyNegotiationToBuilder`) — see how the trade builder consumes a single `TradeNegotiationView`. The Inbox should link **into** the trade builder for an active negotiation, not duplicate the builder. +9. `apps/web/src/features/news/routes/NewsPage.tsx` — the **Sprint 3 pattern** for a worker-backed list route. Mimic this for `TradeNegotiationsInboxPage`. +10. `apps/web/src/features/roster/routes/RosterPage.tsx` — search for `to={\`/players/${player.id}\`}`. This is the **canonical cross-link pattern**. Replicate it; do not invent a new one. +11. `apps/web/src/features/players/routes/PlayerProfilePage.tsx` — 486 lines. Read it before adding the Trade Value panel. Trade Value should integrate, not displace existing tabs. -**Tests to study:** -- `apps/web/src/app/App.test.tsx` -- `apps/web/src/app/layout/AppLayout.test.tsx` -- `apps/web/src/features/setup/routes/SetupPage.test.tsx` — covers continue-existing-save; your auto-resume should produce the same end-state without a manual click -- `apps/web/src/shared/lib/saveSystem.test.ts` +--- -## Product contract +## Allowed Write Scope + +**New files (expected):** + +- `apps/web/src/features/trade-negotiations/routes/TradeNegotiationsInboxPage.tsx` +- `apps/web/src/features/trade-negotiations/routes/TradeNegotiationsInboxPage.test.tsx` +- `apps/web/src/features/trade-negotiations/routes/TradeNegotiationDetailPage.tsx` +- `apps/web/src/features/trade-negotiations/routes/TradeNegotiationDetailPage.test.tsx` +- `apps/web/src/features/trade-negotiations/components/*.tsx` (subcomponents as needed — e.g. `TradeNegotiationRow.tsx`, `TradePackageSummary.tsx`) +- `apps/web/src/features/players/components/TradeValuePanel.tsx` (or similar — fit into existing PlayerProfilePage) +- `apps/web/docs/screenshots/sprint-4/*.png` + +**Updates (expected):** + +- `apps/web/src/app/routes/index.tsx` (add routes for `/trade-negotiations` and `/trade-negotiations/:negotiationId`) +- `apps/web/src/app/layout/Sidebar.tsx` (add Trade Negotiations entry) +- `apps/web/src/app/layout/Sidebar.test.tsx` (existing tests likely assert label list) +- `apps/web/src/features/trade/routes/TradePage.tsx` (cross-link player names — and possibly accept a `?negotiationId=` query param to deep-link from the Inbox; read TradePage before deciding) +- `apps/web/src/features/draft/**` (cross-link prospects) +- `apps/web/src/features/news/routes/NewsPage.tsx` (cross-link players in news items, only if `NewsItem` has `playerId` or similar — see Milestone 8 details) +- `apps/web/src/features/scouting/**` (cross-link players) +- `apps/web/src/features/stats/**` (cross-link leaderboard entries) +- `apps/web/src/features/players/routes/PlayerProfilePage.tsx` (only to integrate Trade Value — do not refactor existing tabs) +- `STATUS.md` +- `.logs/goal-progress.md` +- `GOAL.md` (only to mark milestones complete in a working scratchpad — final commit should preserve the contract, not erase it) -Build the smallest complete fix that: +--- -1. **Persists**: `useGameStore` exposes `activeSaveId`, `activeSaveSlot`, `userTeamId`, `season`, `day`, `phase`, `teamName`, `gmName`, `difficulty` to `localStorage` via Zustand's `persist` middleware. **Do not persist** `isInitialized`, `isSimulating`, `playerCount`, `gamesPlayed`, or any function-typed fields — those derive from the worker after hydration. -2. **Auto-resumes on boot**: When the React tree mounts, if `useGameStore.activeSaveId` is non-null AND a save with that id exists in IndexedDB, kick off an auto-load. The auto-load: - - Calls `loadSaveSafely(activeSaveId)`. - - On `{ ok: true }`, calls `worker.importSnapshot(save.snapshot)` (or the equivalent canonical method), then `useGameStore.initializeGame(...)` with the post-import worker state. - - On `{ ok: false }`, hands off to `SaveRecoveryProvider.showFailure` (same path Save Hub uses). - - On any worker/IndexedDB error, clears the persisted state and falls through to Save Hub. Toast the user briefly. -3. **Loading state**: While the auto-load is in flight, show a route-level "Resuming…" skeleton (NOT the Save Hub). When done, the user lands on the route they hard-reloaded into. If they hard-reloaded `/news`, they end up on `/news`. If they hard-reloaded `/dashboard`, they end up on `/dashboard`. -4. **First-time and post-clear behavior unchanged**: If `useGameStore.activeSaveId` is null (no prior save, or persisted state cleared), the app boots to Save Hub as today. -5. **SaveHub still wins manual loads**: When the user navigates to `/` and clicks a save, the existing flow runs and updates the persisted store. No regression to the manual continue path. +## Protected (DO NOT TOUCH) + +- `packages/sim-core/**` — sim logic stays untouched. +- `packages/contracts/**` — no schema or version changes. Save schema stays at v33. +- `packages/ui/**` — use existing primitives only. No new shared UI components. +- `packages/design-tokens/**` — use existing tokens. +- `apps/web/src/workers/sim.worker.queries.ts` — consume what exists. Do NOT add new worker methods. +- `apps/web/src/workers/sim.worker.actions.ts` — same. Do NOT add new worker actions. +- `apps/web/src/workers/sim.worker.trade.ts` — read-only. +- `apps/web/src/shared/lib/saveSystem.ts` — Sprint 3.5 territory. +- `apps/web/src/app/boot/AppBootGate.tsx` — Sprint 3.5 territory. Do not change boot order. +- `apps/web/src/shared/hooks/useGameStore.ts` — Sprint 3.5 territory. Do not extend the persisted shell. +- `apps/web/src/features/save-recovery/**` — use existing API only. +- `apps/web/src/features/onboarding/**` — Sprint 2 territory. +- `apps/web/src/features/press-room/**` — Press Conference is already wired in `AppLayout.tsx:167`. No Press Conference work in Sprint 4. +- Any existing test that currently passes — do not modify to make new code work. If an existing test breaks, that is a regression — fix the new code, not the test. -Prefer: -- working over broad — get one hard-reload route (e.g. `/news`) surviving end-to-end before declaring victory across all routes; -- composition over invention — reuse `loadSaveSafely`, `SaveRecoveryProvider`, existing worker methods; -- no new dependencies; -- the smallest diff that satisfies the contract. +--- -## Allowed write scope +## Non-Negotiables -Write only inside: -- `apps/web/src/shared/hooks/useGameStore.ts` — add `persist` middleware, declare the persisted slice, write the persistence config -- `apps/web/src/app/App.tsx` — add the boot-time auto-resume hook + a route-level loading state. May extract a small `` wrapper component if the diff stays small -- `apps/web/src/app/layout/AppLayout.tsx` — minimal change only if necessary (e.g. to render `` once `isInitialized` flips). If you can avoid touching this file, prefer that. -- New files under `apps/web/src/app/boot/` (e.g. `useAutoResumeSave.ts`, `AppBootGate.tsx`) if needed -- Test files matching the above paths -- `.logs/goal-progress.md` -- `STATUS.md` (rewrite for Sprint 3.5) -- `GOAL.md` (this file — minor edits only) -- `apps/web/docs/screenshots/sprint-3-5/` — browser smoke evidence - -## Protected scope - -Do not modify: -- `packages/sim-core/**` -- `packages/contracts/**` -- `apps/web/src/workers/**` — worker methods stay as-is; you call them, you don't change them -- `apps/web/src/features/save-recovery/**` — the recovery dialog already does the right thing; auto-resume must integrate with it, not modify it -- `apps/web/src/features/setup/**` — Save Hub's manual continue path stays unchanged -- `apps/web/src/features/news/**` — Sprint 3 just shipped; preserve it -- `apps/web/src/features/onboarding/**` — Sprint 2 just shipped; preserve it -- `apps/web/src/shared/lib/saveSystem.ts` — read-only; persistence helpers already exist -- `apps/web/src/shared/lib/audio.ts`, `logger.ts`, `webVitals.ts`, etc. -- `.github/**`, `package.json` (root), `turbo.json`, `pnpm-workspace.yaml` -- `apps/web/src/build/bundleConfig.ts`, `apps/web/docs/BUDGETS.md` - -## Non-negotiables - -- **Schema v33.** No bump, no migration. -- **Determinism.** No `Math.random()`. No new RNG paths. -- **No new dependencies.** Zustand's `persist` middleware ships with the `zustand` package already in `apps/web/package.json` — use `import { persist, createJSONStorage } from 'zustand/middleware'`. -- **No emoji.** lucide-react icons only. Bloomberg Terminal aesthetic. -- **Do not delete or weaken tests** to make checks pass. New tests required for the auto-resume flow. -- **Save Recovery must still trigger** on corrupt saves during auto-resume. -- **Manual Save Hub flow must be unchanged.** Existing SetupPage.test.tsx assertions still pass. -- **localStorage namespace**: use a stable key (e.g. `mbd:game-store@v1`). If you ever need to invalidate the persisted shape, bump the suffix — do **not** silently change it. -- **Privacy**: persist only the minimal fields above. Do NOT persist the full snapshot, player data, or any large blob in localStorage — IndexedDB owns the heavy data. -- **No commits on `main`.** Work only on `goal/sprint-3-5-hard-reload-survival`. -- **No `git add -A`.** - -## Milestone loop - -For each milestone: inspect → state checkpoint → smallest change → smallest validation → fix → log to `.logs/goal-progress.md`. - -Suggested milestones: - -1. **Inventory.** Read every file in "Read first." Document the exact sequence Save Hub uses today to continue a save (which worker method hydrates the snapshot? what order does `initializeGame` get called in?). Map the auto-resume to the same sequence. -2. **Persist `useGameStore`.** Add `persist` middleware with `createJSONStorage(() => localStorage)`. `partialize` to only the persisted slice listed above. Bump the version key if needed. Confirm `useGameStore` still works in tests with a localStorage mock. -3. **Auto-resume hook.** New `useAutoResumeSave` (or in-file hook in `App.tsx`) that runs once at app mount: read `activeSaveId` from the store, if present call `loadSaveSafely`, then hydrate the worker, then call `initializeGame`. Handle the `SaveRecoveryProvider.showFailure` case. Handle generic errors by clearing `activeSaveId` from the store. -4. **Boot loading state.** While the auto-resume is in flight, render a small route-level "Resuming…" skeleton. Do NOT show the Save Hub. Do NOT show the dashboard half-loaded. When done, the route renders normally — react-router preserves the URL across the hydration because we never navigated. -5. **Tests.** Add: - - `App.test.tsx` (or a new `useAutoResumeSave.test.ts`): with a persisted `activeSaveId` and a mock save in IndexedDB, the app hydrates and renders the in-game route, not Save Hub. - - With a persisted `activeSaveId` but no IndexedDB record, the persisted state clears and the user lands on Save Hub. - - With a corrupt save, the SaveRecoveryDialog appears. - - Without a persisted `activeSaveId`, behavior is unchanged (Save Hub). -6. **Verify gate.** `pnpm typecheck`, `pnpm test`, `pnpm build`. -7. **Browser smoke.** - - Start dev server. - - Complete onboarding into Slot 1. - - Land on dashboard. - - Hard reload `/MBD/dashboard` → should land back on dashboard (not Save Hub). - - Navigate to `/news`, hard reload `/MBD/news` → should land back on news. - - Navigate to `/roster`, hard reload → roster. - - Navigate to `/trade`, hard reload → trade. - - Clear IndexedDB → hard reload `/MBD/dashboard` → should land on Save Hub (graceful recovery). - - Capture screenshots for each scenario. -8. **STATUS.md.** Rewrite for Sprint 3.5. - -Each `.logs/goal-progress.md` entry: timestamp, milestone, files changed, checks run, result, blocker or next step. - -## Validation loop - -Workspace root commands: +1. **No new worker methods.** If you find yourself wanting one, STOP — that is Pause Condition 1. +2. **No save schema changes.** Stays v33. No new fields, no version bump. +3. **No `Math.random()` anywhere in new code.** The app is deterministic. +4. **No new top-level packages or npm dependencies.** +5. **All new test files use existing harness** — `@testing-library/react`, `vitest`, and the worker-mocking patterns from `NewsPage.test.tsx` and `RosterPage.test.tsx`. +6. **Hard reload must continue to work** at every new route. Sprint 3.5 invariant. +7. **Bundle budgets must not regress.** Check `apps/web/docs/BUDGETS.md` after the build. +8. **Mobile 375×667** — every new route must render without horizontal overflow. +9. **No `console.log`, `console.warn`, or `console.error` left in production code.** Use `logger` from `apps/web/src/shared/lib/logger.ts` if you need diagnostic output. +10. **Cross-links must reuse the Roster pattern.** Same `` import, same hover/focus styling. Do not invent a "clickable name" component. +11. **The Trade Negotiations Inbox is read-only in Sprint 4.** Action buttons (`canAccept`, `canCounter`, `canReject`) deep-link into the existing TradePage builder — they do NOT call worker actions directly from the Inbox. That keeps the Inbox surface a pure list/detail and respects "no new worker methods." -``` +--- + +## Milestone Loop + +Work milestones **in order**. After each milestone: + +1. Run `pnpm typecheck`. +2. Run `pnpm test` (focused first if it's faster, then the full suite before commit). +3. `git add` the milestone's files (specific files — no `git add -A`). +4. Commit with the milestone's prescribed message. +5. Append a milestone block to `.logs/goal-progress.md` (commit SHA, validation tail, scope decisions, surprises). +6. Move to the next milestone. + +If a milestone's validation fails: + +- Fix forward. Do not commit a broken milestone. +- If you cannot figure it out in three attempts, STOP and document under Pause Conditions. + +### Milestone 1 — Trade Negotiations Inbox scaffolding (`/trade-negotiations`) + +- Add a lazy import for `TradeNegotiationsInboxPage` in `apps/web/src/app/routes/index.tsx`. +- Register `)} />` alongside the other top-level in-game routes. +- Create `apps/web/src/features/trade-negotiations/routes/TradeNegotiationsInboxPage.tsx`: + - Calls `worker.getOpenNegotiations()` on mount. + - Renders the list. Each row shows: counterpart team (name + abbreviation, optionally team logo if a `TeamLogo` component exists), `phase` badge, `Round N` label from `roundsCompleted`, days-until-expiry derived from `expiresAtDay` minus current day (read current day from `useGameStore`), a one-line dialogue preview from the last `dialogue` entry if any, and a status indicator (`isComplete` → "Closed" badge; otherwise show which actions are available based on `canAccept`/`canCounter`/`canReject`). + - Row click navigates to `/trade-negotiations/:id`. + - Empty state: "No open trade negotiations" with a small "Visit the Trade Hub to start one" hint that links to `/trade`. + - Loading skeleton mimicking `NewsPage` skeleton style. + - Error path: toast + render an inline error card if the worker call throws. +- Create `apps/web/src/features/trade-negotiations/routes/TradeNegotiationsInboxPage.test.tsx`: + - Happy path (mock worker, render rows). + - Empty state. + - Counterpart team name renders, phase badge renders, expires-in count renders. + - Row click navigates to `/trade-negotiations/:id` (use the same `MemoryRouter`/`useNavigate` mock pattern as `NewsPage.test.tsx`). +- Sort order: open negotiations first (`isComplete === false`), then by smallest `expiresAtDay` first (most urgent on top). Closed last. +- **DONE WHEN**: typecheck + test pass; `/MBD/trade-negotiations` (when the dev server is up) renders the list or the empty state. +- **COMMIT**: `feat(trade-negotiations): add /trade-negotiations Inbox route` + +### Milestone 2 — Trade Negotiation detail (`/trade-negotiations/:negotiationId`) + +- Lazy import `TradeNegotiationDetailPage` in routes. +- Register `)} />`. +- Create `apps/web/src/features/trade-negotiations/routes/TradeNegotiationDetailPage.tsx`: + - Reads `negotiationId` from `useParams`. + - Calls `worker.getNegotiation(negotiationId)`. + - Renders the negotiation in detail: + - Header: counterpart team, phase, rounds completed, expires day. + - Two side-by-side panels: **Proposal** (the user's `proposal: TradeCounterPackage`) and **Counter-Offer** (the counterpart's `counterOffer`, or "Awaiting counter" if `null`). Render the `TradeCounterPackage` shape — read its type. If it includes player IDs, render the player names as `` to `/players/:playerId`. + - **Dialogue thread**: render `dialogue` entries chronologically as a chat-style log (sender name + message). Reuse existing chat/conversation styling if any exists in the codebase; otherwise compose with existing primitives. + - **Action area**: if `isComplete === false`, show a single CTA — "Open in Trade Builder" — that navigates to `/trade?negotiationId={id}` (deep link). Buttons for `canAccept`/`canCounter`/`canReject` are NOT wired in this sprint — they are visually disabled with a tooltip "Use the Trade Builder to act on this negotiation." If `isComplete === true`, show "This negotiation is closed." + - 404-ish state if `getNegotiation` returns `null`: "Trade negotiation not found" with a back link to `/trade-negotiations`. +- Create `TradeNegotiationDetailPage.test.tsx`: happy path with proposal + counter-offer + dialogue, awaiting-counter path (`counterOffer === null`), not-found path. +- **DONE WHEN**: typecheck + test pass; clicking a row in `/trade-negotiations` opens the detail page; bad ID renders not-found. +- **COMMIT**: `feat(trade-negotiations): add /trade-negotiations/:id detail view` + +### Milestone 3 — Sidebar Trade Negotiations entry + +- Add a Trade Negotiations entry to `apps/web/src/app/layout/Sidebar.tsx`. Use a lucide icon — `Handshake`, `MessagesSquare`, or `Repeat2` are all reasonable. Pick one. (Note: Sprint 3 already used `Inbox` for News — pick something different.) +- Place it adjacent to or under Trade. Your call. +- Update `Sidebar.test.tsx` to include the new entry. +- **DONE WHEN**: typecheck + test pass; sidebar shows Trade Negotiations and navigates to `/trade-negotiations`. +- **COMMIT**: `feat(layout): add Trade Negotiations entry to Sidebar` + +### Milestone 4 — Trade page cross-linking + deep-link from Inbox + +- Find every place in `apps/web/src/features/trade/**` where a player name renders as plain text. Wrap each with `` (or equivalent). Match the styling pattern from `RosterPage.tsx:473`. +- **Additionally**: support the `/trade?negotiationId={id}` deep link added in Milestone 2. On `TradePage` mount, read `searchParams.get('negotiationId')`, and if present, fetch that negotiation via `worker.getNegotiation(id)` and seed `activeNegotiation` / the trade builder via the existing `applyNegotiationToBuilder` flow. If the negotiation isn't found or has expired, show a toast and clear the param. +- Update or add tests verifying the link target AND the deep-link behavior. +- **DONE WHEN**: typecheck + test pass; clicking any player name in any Trade surface navigates to that player's profile; opening `/trade?negotiationId=` opens TradePage with that negotiation loaded. +- **COMMIT**: `feat(trade): cross-link player names + accept ?negotiationId deep link` + +### Milestone 5 — Draft page cross-linking + +- Same `` pattern in `apps/web/src/features/draft/**` for draft prospects. +- The `to={\`/players/${entry.playerId}?tab=development\`}` pattern from `apps/web/src/features/minors/components/ProspectBreakoutTracker.tsx:84` is a precedent for prospects specifically — consider whether `?tab=development` is the right default for the Big Board. +- **DONE WHEN**: typecheck + test pass; draft Big Board entries are clickable. +- **COMMIT**: `feat(draft): cross-link prospects to /players/:id` + +### Milestone 6 — Scouting page cross-linking + +- Same pattern in `apps/web/src/features/scouting/**`. +- **DONE WHEN**: typecheck + test pass; scouting reports' player names are clickable. +- **COMMIT**: `feat(scouting): cross-link player names to /players/:id` + +### Milestone 7 — Stats / leaderboards cross-linking + +- Same pattern in `apps/web/src/features/stats/**`. +- **DONE WHEN**: typecheck + test pass; leaderboard entries are clickable. +- **COMMIT**: `feat(stats): cross-link leaderboard entries to /players/:id` + +### Milestone 8 — News page player references (skip if NewsItem has no machine-readable player ref) + +- Inspect `NewsItem` from `@mbd/contracts`. Does it carry a machine-readable player reference field (`playerId`, `relatedPlayerIds`, `entities`, etc.)? +- If YES: render those references as `` chips below the news body. Match existing news item styling. +- If NO: SKIP this milestone. Do **not** introduce text-parsing or regex to extract player names. Document the skip in `.logs/goal-progress.md` and the final STATUS.md. +- Update `NewsPage.test.tsx` if you wired links. +- **DONE WHEN**: either links work, or skip is documented. +- **COMMIT** (if shipped): `feat(news): cross-link player references in news items` + +### Milestone 9 — Trade Value on Player Profile + +- Wire `worker.getPlayerTradeValue(playerId)` into `PlayerProfilePage.tsx`. +- Decide placement: a small panel/widget alongside the existing tabs, or a new tab. **Lean toward a small panel** rather than a new tab to keep tab count stable. +- Render the `PlayerTradeValue` shape — read the type definition before designing. +- Loading + null-handling. +- Add a focused test in `PlayerProfilePage.test.tsx` for the trade-value render. +- **DONE WHEN**: typecheck + test pass; the player profile shows Trade Value for any player. +- **COMMIT**: `feat(players): surface trade value on player profile` + +### Milestone 10 — Browser smoke + screenshots + STATUS.md + +Run `pnpm --filter @mbd/web dev` and capture screenshots to `apps/web/docs/screenshots/sprint-4/`: + +1. `01-trade-negotiations-inbox.png` — `/MBD/trade-negotiations` with rows. +2. `02-trade-negotiations-empty.png` — empty state. +3. `03-trade-negotiation-detail.png` — `/MBD/trade-negotiations/` detail. +4. `04-trade-negotiation-detail-awaiting-counter.png` — detail with `counterOffer === null`. +5. `05-trade-deep-link-loaded.png` — `/MBD/trade?negotiationId=` showing the trade builder seeded with that negotiation. +6. `06-trade-clickable-name.png` — hover/focus state on a clickable player name in Trade. +7. `07-draft-clickable-prospect.png` — same in Draft. +8. `08-scouting-clickable-name.png` — same in Scouting. +9. `09-stats-clickable-leader.png` — same in Stats. +10. `10-news-player-chip.png` — only if Milestone 8 shipped. +11. `11-player-profile-trade-value.png` — Trade Value panel on profile. +12. `12-trade-negotiations-mobile-375.png` — 375×667. +13. `13-trade-negotiations-hard-reload.png` — Sprint 3.5 invariant check. + +Verify on each route: + +- Hard reload (Cmd+Shift+R) lands on the same route, not Save Hub. Sprint 3.5 invariant. +- 375×667 viewport — no horizontal overflow. +- No `console.error` in the browser console. + +Also run before final commit: + +```bash PATH=/Users/tkevinbigham/.local/node-lts/bin:$PATH pnpm typecheck PATH=/Users/tkevinbigham/.local/node-lts/bin:$PATH pnpm test PATH=/Users/tkevinbigham/.local/node-lts/bin:$PATH pnpm build -PATH=/Users/tkevinbigham/.local/node-lts/bin:$PATH pnpm --filter @mbd/web dev ``` -Targeted: +Then rewrite `STATUS.md` at repo root with: -``` -pnpm --filter @mbd/web test src/app/App.test.tsx -pnpm --filter @mbd/web test src/app/layout/AppLayout.test.tsx -pnpm --filter @mbd/web test src/features/setup/routes/SetupPage.test.tsx -pnpm --filter @mbd/web test src/shared +- What shipped (one-paragraph summary). +- Files changed (`git diff --stat origin/main..HEAD`). +- Validations run (typecheck + test + build — paste the final tail of each). +- Browser evidence (screenshot list with one-line captions). +- Bundle impact (chunk sizes; any movement against `BUDGETS.md`). +- Worker methods newly consumed (`getOpenNegotiations`, `getNegotiation`, `getPlayerTradeValue`) — confirm zero new worker methods added. +- Sprint 3.5 invariant confirmation. +- Cross-linking coverage table (Roster ✅ / Free Agency ✅ / Minors ✅ / Trade ✅ / Draft ✅ / News ✅ or skipped / Scouting ✅ / Stats ✅). +- Known limitations. +- Risks. +- Rollback notes (revert the merge commit; no schema bump). +- Next `/goal` recommendation. + +- **DONE WHEN**: all required screenshots committed; STATUS.md and `.logs/goal-progress.md` complete. +- **COMMIT**: `docs(sprint-4): browser smoke, STATUS report, and handoff` + +--- + +## Validation Gates + +After **every** milestone: + +```bash +PATH=/Users/tkevinbigham/.local/node-lts/bin:$PATH pnpm typecheck +PATH=/Users/tkevinbigham/.local/node-lts/bin:$PATH pnpm test ``` -Browser flow (full smoke): +Before the final Milestone 10 commit, also run `pnpm build`. -1. `pnpm --filter @mbd/web dev` -2. Open `http://localhost:5173/MBD/` -3. Complete a new dynasty through onboarding → land on `/dashboard` -4. Hard-reload `/MBD/dashboard` → expect dashboard renders, not Save Hub -5. Navigate to `/news` → confirm the unread count and items match pre-reload state -6. Hard-reload `/MBD/news` → expect news renders, not Save Hub -7. Repeat for `/MBD/roster`, `/MBD/trade`, `/MBD/draft` -8. In DevTools, clear IndexedDB for `mbd-saves` → hard-reload → expect graceful fallback to Save Hub -9. In DevTools, set `localStorage` for `mbd:game-store@v1` to point at a non-existent save id → hard-reload → expect persisted state clears + Save Hub -10. At 375×667 viewport, hard-reload `/MBD/dashboard` → no layout shift larger than a brief skeleton +Browser smoke (Milestone 10 only): `PATH=/Users/tkevinbigham/.local/node-lts/bin:$PATH pnpm --filter @mbd/web dev` then drive through the screenshots list. -## Evaluator-visible proof +--- -Before declaring done, the transcript and `STATUS.md` must contain: +## Pause Conditions -- Exact commands run and pass/fail -- Output summaries (test counts, build duration, bundle sizes — bundle should be unchanged or tiny) -- Browser steps walked with screenshots under `apps/web/docs/screenshots/sprint-3-5/` -- A `git diff --stat origin/main..HEAD` showing changes stayed inside allowed scope -- localStorage snapshot before/after a reload (key + minimal value, no PII) -- Confirmation that SaveHub manual continue still works (existing SetupPage.test.tsx assertions still pass) +**STOP and surface to Kevin** (do not push, do not improvise) if any of these happen: -## Autonomy rules +1. A worker method you expected does not exist or returns an unexpected shape. Do not add new worker methods. Document the actual shape so the contract can be revised. +2. The save schema would need to bump. Sprint 4 is **consumer-only**. +3. A bundle budget in `apps/web/docs/BUDGETS.md` would be exceeded. +4. Hard reload breaks at any new route. Sprint 3.5 invariant. +5. An existing test in an unrelated area starts failing because of your changes. Investigate the coupling, don't paper over it. +6. The `NewsItem` shape has no machine-readable player references AND you can't ship Milestone 8 cleanly. Skip; document; continue. +7. You discover the audit was wrong about a "no consumers" claim (as happened with Press Conference last run). Document the surprise, skip the redundant work, continue. +8. You cannot decide between two reasonable approaches and the Autonomy Rules below don't resolve it. -When picking between: -- (a) a `useAutoResumeSave` hook called inside `App.tsx` -- (b) a wrapping `` component +A pause is not a failure. A bad commit shipped through quietly is a failure. Codex's first-pass pause on this sprint was the right call — it caught a contract bug. + +--- -Pick whichever has the smaller diff in tests too. Both are valid. +## Autonomy Rules -When designing the "Resuming…" loading state, match the existing Suspense fallback (`LoadingFallback` in `apps/web/src/app/routes/index.tsx` — the MBD pulse + "Loading route..." pattern). Don't invent new spinners. +Once the mission is clear, **do not ask for permission** on: -When a persisted save id doesn't resolve in IndexedDB, **clear** `useGameStore` (call a new `clearActiveSave` action or `setActiveSave(null, null)`). Do NOT keep stale ids around. +- Component composition (table vs. card list vs. row layout for the Inbox). +- Lucide icon choice for Sidebar entry. +- How to format the days-until-expiry chip (`expires in 3 days` vs. `3d`). +- How to format dollar amounts in `TradeCounterPackage` (match what TradePage does). +- Dialogue chat styling — invent something tasteful using existing tokens if no chat primitive exists. +- Whether Trade Value on the Player Profile is a panel or a new tab. Lean panel. +- Whether News player chips live above or below the body copy. +- Sidebar grouping order. +- Sort order tie-breakers within "open negotiations" (e.g. when two negotiations expire on the same day). -When a worker error happens during hydration, route through `SaveRecoveryProvider.showFailure` with a synthetic `{ ok: false, reason: 'storage_failed', detail: ... }` so the user sees the same dialog they'd see from a manual load. +Use judgment. Match the existing codebase's tone. Pause only on the Pause Conditions above. -Log assumptions in `.logs/goal-progress.md` and continue. +--- -## Pause conditions +## Evaluator-Visible Proof -Pause and write the blocker into `STATUS.md` only when: +Maintain `.logs/goal-progress.md` continuously. After each milestone, append: -- The auto-resume requires a sim-core change. -- The auto-resume requires a worker-side method that doesn't exist. -- A persistence-shape change requires bumping save schema (it shouldn't — localStorage is independent of `GameSnapshot`). -- The same validation fails 3 times after serious repair attempts. -- A protected file must be modified beyond `AppLayout.tsx`'s minimal touch (and even that touch should be avoided if possible). -- Save Hub's manual continue path breaks and you can't restore parity in 2 fix attempts. +```markdown +## Milestone N — -## Done when +- Commit: <SHA> "<message>" +- typecheck: <PASS/FAIL — last line> +- test: <PASS/FAIL — Tasks: X successful, Y total> +- Files touched: <list> +- Scope decisions: <one or two sentences if any> +- Surprises: <one or two sentences if any> +``` -All of the following are true: +Treat `.logs/goal-progress.md` like a journal, not an afterthought. Kevin and future agents read it to understand what happened. -- `useGameStore` persists to `localStorage` via Zustand `persist` with a versioned key. -- On app boot, if a persisted `activeSaveId` resolves in IndexedDB, the app auto-loads it and renders the user's previous route — **NOT** Save Hub. -- A "Resuming…" loading state shows while auto-load is in flight. -- Corrupt saves trigger `SaveRecoveryDialog` (unchanged from today's manual-load behavior). -- Missing saves clear the persisted state and fall through to Save Hub. -- The Save Hub manual continue path is unchanged (existing tests pass). -- Hard reload at `/MBD/dashboard`, `/MBD/news`, `/MBD/roster`, `/MBD/trade` all render the route, not Save Hub. -- `pnpm typecheck` clean (all tasks). -- `pnpm test` clean (no test deleted or weakened; new tests added for the auto-resume flow). -- `pnpm build` clean (bundleBudget.test.ts passes; budget journal untouched). -- Browser smoke walked end-to-end with screenshots under `apps/web/docs/screenshots/sprint-3-5/`. -- `.logs/goal-progress.md` contains the milestone log. -- `STATUS.md` exists with the final report. -- Branch is on `goal/sprint-3-5-hard-reload-survival`. +--- -## Final report +## Done Criteria + +ALL of these must be true before Milestone 10's STATUS.md commit: + +1. ✅ `/trade-negotiations` renders open trade negotiations (or empty state). +2. ✅ `/trade-negotiations/:id` renders detail or graceful not-found. +3. ✅ Sidebar has a Trade Negotiations entry. +4. ✅ Trade page player names link to `/players/:id`. +5. ✅ TradePage accepts `?negotiationId=<id>` and seeds the trade builder. +6. ✅ Draft page prospects link to `/players/:id`. +7. ✅ Scouting page player names link to `/players/:id`. +8. ✅ Stats / leaderboards link to `/players/:id`. +9. ✅ News page player references link to `/players/:id` OR Milestone 8 is documented as skipped with reason. +10. ✅ Player Profile shows Trade Value. +11. ✅ Each new route hard-reloads successfully (Sprint 3.5 invariant). +12. ✅ Each new route renders cleanly at 375×667. +13. ✅ All required screenshots committed under `apps/web/docs/screenshots/sprint-4/`. +14. ✅ `pnpm typecheck` passes. +15. ✅ `pnpm test` passes (with new tests added for Inbox, Detail, and Trade Value). +16. ✅ `pnpm build` passes with no new budget violations. +17. ✅ `STATUS.md` rewritten with the full report. +18. ✅ `.logs/goal-progress.md` has 10 milestone blocks. +19. ✅ Final commit pushed to `goal/sprint-4-front-office`. +20. ✅ Zero new worker methods, zero schema changes, zero new dependencies. -`STATUS.md` (rewrite from scratch) must include, in order: +--- -1. **What shipped** — one paragraph summary. -2. **Files changed** — `git diff --stat origin/main..HEAD` output. -3. **Validations run** — exact commands and results. -4. **Browser evidence** — screenshots under `apps/web/docs/screenshots/sprint-3-5/` with captions; localStorage key/value before & after; a list of routes you hard-reloaded successfully. -5. **Save Recovery integration** — confirmation that corrupt saves still hit the dialog. -6. **Known limitations** — anything out of scope you noticed. -7. **Risks** — what could break in production and what to watch (e.g. quota, localStorage disabled in private mode). -8. **Rollback notes** — revert the merge commit; localStorage entries will be ignored on the old code path. -9. **Next /goal** — exact paste-ready prompt for **Sprint 4** (the audit's original next pick: wire orphaned player-profile + open-negotiations endpoints). Claude Code will draft that GOAL.md after Sprint 3.5 merges. +## Final Report -## Branch + commit hygiene +When all Done Criteria are met: -- Branch: `goal/sprint-3-5-hard-reload-survival` (built on top of `goal/sprint-3-news-inbox`). When Sprint 3 merges to `main`, rebase this branch. -- Stage specific files; never `git add -A`. -- Commit prefixes: `feat(app):` for the boot logic, `feat(store):` for `useGameStore` persistence, `test(app):` for tests, `docs(app):` for docs. -- Co-author trailer on each commit: +1. `git push origin goal/sprint-4-front-office`. +2. Report back to Kevin (via the transcript / handoff doc you control) with: + - Final commit SHA. + - Branch name. + - Path to STATUS.md. + - Link to the draft PR (Claude Code will flip it to ready and merge once Kevin approves). + - The exact next `/goal` for the next sprint. - ``` - Co-Authored-By: Codex GPT-5 <noreply@openai.com> - ``` +--- -- When done, push and open PR titled `Sprint 3.5 — Hard-reload state survival`. Body summarizes against this GOAL.md and links Sprint 3 PR #76 for lineage. +## Bonus Round (only after all 20 Done Criteria are satisfied) -## Out of scope (do not attempt this sprint) +If Sprint 4 closes cleanly with daylight remaining, you may pick up **one** of the following clearly-scoped follow-ons. Do NOT start Bonus Round work until Milestone 10 is committed and pushed. -- Granular player-profile endpoints (Sprint 4) -- Open-negotiations resume pane (Sprint 4) -- Press conference unification (Sprint 5) -- Invariant runtime checks in DEV (Sprint 6) -- Moving narrative generation off main thread (Sprint 6) -- Team logo SVGs (Sprint 7) -- Anything that touches `packages/sim-core/` or `packages/contracts/` -- Service worker changes -- Adding new dependencies +### Bonus A — Active negotiation chip in TopBar + +If a user has open trade negotiations, show a small chip in `apps/web/src/app/layout/TopBar.tsx` (mirror Sprint 3's News chip pattern) that displays the count and links to `/trade-negotiations`. + +### Bonus B — Active negotiation panel on Player Profile + +Add a panel to `PlayerProfilePage` that calls `worker.getOpenNegotiations()` and surfaces any negotiation whose `proposal` or `counterOffer` includes this player. Links to `/trade-negotiations/:id`. Skip if the cross-reference is non-trivial to derive from the shape (read first, decide). + +### Bonus C — League activity on Inbox + +Below the user's open negotiations, show a small "League activity" summary count: "12 trade negotiations are happening league-wide" (if `getOpenNegotiations` returns league-wide, which you'll verify when you read it). + +**Bonus Round non-negotiables:** + +- Separate commits per task. +- Same validation gates. +- Update `.logs/goal-progress.md` with a "Bonus Round" section. +- Append to `STATUS.md` under a "Bonus Round" heading. +- If a Bonus Round task hits a Pause Condition, STOP and leave the bonus uncommitted — do not block Sprint 4's merge. --- -*End of GOAL.md. Companion `/goal` slash command lives in Sprint 3.5's PR description and in Kevin's conversation with Claude Code.* +## Operating Notes for Codex + +- This is Kevin's overnight sprint. He's asleep. He wants Codex working steady through the night. Pace yourself: 10 milestones over the night is roughly one milestone every ~50 minutes if you go steady. Quality > speed. +- The previous run **paused correctly** under Pause Condition 1 because the original GOAL.md asked for fields that didn't exist on `TradeNegotiationView`. This revision fixes that. If you spot another contract bug like that, pause again — the precedent is established. +- The `pnpm` binary lives at `/Users/tkevinbigham/.local/node-lts/bin/pnpm`. Every shell call needs `PATH=/Users/tkevinbigham/.local/node-lts/bin:$PATH` prepended. +- The working tree is `/Users/tkevinbigham/MBD-main`. The branch is `goal/sprint-4-front-office`. The base is `main` at `93b3f5b`. +- The draft PR ([#78](https://github.com/KevinBigham/MBD/pull/78)) already exists. Claude Code (reviewer) will flip it to ready and merge once Kevin approves in the morning. +- If something feels off — a milestone is over-specified, an assumption is wrong, the worker shape doesn't match — STOP and surface it. The worst outcome is silent over-improvising. The second worst is asking permission for everything. + +Go. diff --git a/STATUS.md b/STATUS.md index 6e457b5..24c906d 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,165 +1,222 @@ -# STATUS - Sprint 3.5 Hard-Reload State Survival +# STATUS - Sprint 4 Front Office Marathon -Status: **COMPLETE** for the hard-reload state survival mission. The active save shell state now persists to localStorage, app boot auto-loads the persisted save through the existing safe save/worker path, and hard-reloaded in-game routes render their route instead of falling back to Save Hub. +Status: **SHIPPED** on `goal/sprint-4-front-office`. -## What shipped +## What Shipped -`useGameStore` now persists only the allowed shell fields under `mbd:game-store@v1`: active save id/slot, user team id, season, day, phase, team name, GM name, and difficulty. `AppBootGate` wraps the router, shows a `Resuming save...` route-level skeleton while loading, calls `loadSaveSafely(activeSaveId)`, imports the snapshot through `worker.importSnapshot`, then calls `initializeGame(...)` before `AppLayout` can redirect. Missing save ids clear the stale persisted active-save fields and fall through to Save Hub; corrupt saves route through the existing Save Recovery dialog. +Sprint 4 wires the revised front-office scope end to end: a read-only Trade Negotiations Inbox at `/trade-negotiations`, a detail route at `/trade-negotiations/:negotiationId`, Sidebar navigation, Trade Builder deep-link loading from `?negotiationId=`, player-name cross-links across Trade/Draft/Scouting/Stats/News, and a Trade Value panel on Player Profile. The implementation stayed consumer-only: no save schema bump, no new worker query/action methods, no sim-core changes, and no new dependencies. -## Files changed +## Files Changed -Current implementation diff is intentionally inside the Sprint 3.5 allowed scope plus proof artifacts: +`git diff --stat origin/main..HEAD` before the Milestone 10 docs commit: ```text -.logs/goal-progress.md -STATUS.md -apps/web/docs/screenshots/sprint-3-5/*.png -apps/web/src/app/App.test.tsx -apps/web/src/app/App.tsx -apps/web/src/app/boot/AppBootGate.test.tsx -apps/web/src/app/boot/AppBootGate.tsx -apps/web/src/shared/hooks/useGameStore.test.ts -apps/web/src/shared/hooks/useGameStore.ts + .logs/goal-progress.md | 89 ++++ + GOAL.md | 589 +++++++++++++-------- + STATUS.md | 178 +++---- + apps/web/src/app/layout/Sidebar.test.tsx | 2 + + apps/web/src/app/layout/Sidebar.tsx | 2 + + apps/web/src/app/routes/index.tsx | 8 + + .../src/features/draft/routes/DraftPage.test.tsx | 4 + + apps/web/src/features/draft/routes/DraftPage.tsx | 41 +- + .../features/league/routes/LeadersPage.test.tsx | 4 + + .../web/src/features/news/routes/NewsPage.test.tsx | 23 +- + apps/web/src/features/news/routes/NewsPage.tsx | 89 +++- + .../players/routes/PlayerProfilePage.test.tsx | 40 +- + .../features/players/routes/PlayerProfilePage.tsx | 92 +++- + .../scouting/components/ScoutConflictsTab.tsx | 13 +- + .../features/scouting/routes/ScoutingPage.test.tsx | 228 +++++++- + .../src/features/scouting/routes/ScoutingPage.tsx | 51 +- + .../routes/TradeNegotiationDetailPage.test.tsx | 173 ++++++ + .../routes/TradeNegotiationDetailPage.tsx | 376 +++++++++++++ + .../routes/TradeNegotiationsInboxPage.test.tsx | 180 +++++++ + .../routes/TradeNegotiationsInboxPage.tsx | 267 ++++++++++ + .../trade/components/DeadlineDramaPanel.tsx | 8 +- + .../src/features/trade/routes/TradePage.test.tsx | 100 ++++ + apps/web/src/features/trade/routes/TradePage.tsx | 168 +++++- + apps/web/src/shared/hooks/useWorker.ts | 6 +- + 24 files changed, 2299 insertions(+), 432 deletions(-) ``` -Pre-existing local dirt left untouched: +Milestone 10 also adds `apps/web/docs/screenshots/sprint-4/*.png`, rewrites this `STATUS.md`, and commits the accumulated `.logs/goal-progress.md` journal entry from Milestone 9. + +Pre-existing local change left untouched: ```text .claude/launch.json -.claude/scheduled_tasks.lock ``` -`git diff --stat origin/main..HEAD`: +## Validations Run -```text - .logs/goal-progress.md | 121 +++++++ - GOAL.md | 355 ++++++++++----------- - STATUS.md | 180 ++++++----- - .../sprint-3-5/01-dashboard-before-hard-reload.png | Bin 0 -> 156026 bytes - .../sprint-3-5/02-dashboard-after-hard-reload.png | Bin 0 -> 139473 bytes - .../sprint-3-5/03-news-before-hard-reload.png | Bin 0 -> 126471 bytes - .../sprint-3-5/04-news-after-hard-reload.png | Bin 0 -> 103543 bytes - .../sprint-3-5/05-roster-after-hard-reload.png | Bin 0 -> 135451 bytes - .../sprint-3-5/06-trade-after-hard-reload.png | Bin 0 -> 150587 bytes - .../sprint-3-5/07-draft-after-hard-reload.png | Bin 0 -> 93716 bytes - .../sprint-3-5/08-save-hub-after-delete-slot.png | Bin 0 -> 135242 bytes - .../09-missing-save-fallback-save-hub.png | Bin 0 -> 138231 bytes - .../sprint-3-5/10-corrupt-save-recovery-dialog.png | Bin 0 -> 89768 bytes - .../11-dashboard-mobile-375-after-hard-reload.png | Bin 0 -> 32785 bytes - apps/web/src/app/App.test.tsx | 4 + - apps/web/src/app/App.tsx | 33 +- - apps/web/src/app/boot/AppBootGate.test.tsx | 283 ++++++++++++++++ - apps/web/src/app/boot/AppBootGate.tsx | 171 ++++++++++ - apps/web/src/shared/hooks/useGameStore.test.ts | 72 +++++ - apps/web/src/shared/hooks/useGameStore.ts | 118 ++++--- - 20 files changed, 1008 insertions(+), 329 deletions(-) +```bash +PATH=/Users/tkevinbigham/.local/node-lts/bin:$PATH pnpm typecheck ``` -## Validations run +Final tail: ```text -PATH=/Users/tkevinbigham/.local/node-lts/bin:$PATH pnpm --filter @mbd/web test src/shared/hooks/useGameStore.test.ts src/app/boot/AppBootGate.test.tsx +Tasks: 9 successful, 9 total +Cached: 8 cached, 9 total +Time: 5.386s ``` -Red proof: FAIL as expected before implementation. The store persistence test saw `persisted.version` as `undefined`, and `AppBootGate.tsx` did not exist. - -```text -PATH=/Users/tkevinbigham/.local/node-lts/bin:$PATH pnpm --filter @mbd/web test src/shared/hooks/useGameStore.test.ts src/app/boot/AppBootGate.test.tsx src/app/App.test.tsx +```bash +PATH=/Users/tkevinbigham/.local/node-lts/bin:$PATH pnpm test ``` -Latest focused result: PASS, 3 files / 8 tests. +Final tail: ```text -PATH=/Users/tkevinbigham/.local/node-lts/bin:$PATH pnpm typecheck +@mbd/web:test: Test Files 103 passed (103) +@mbd/web:test: Tests 641 passed (641) +@mbd/web:test: Duration 79.87s + +Tasks: 8 successful, 8 total +Cached: 7 cached, 8 total +Time: 1m20.508s ``` -PASS. Turbo reported `Tasks: 9 successful, 9 total` in `5.418s`. +Known test noise remained the existing Recharts zero-size warnings, React `act(...)` warnings, intentional mocked worker failure log in the Inbox error test, and the intentional service-worker registration failure test. -```text -PATH=/Users/tkevinbigham/.local/node-lts/bin:$PATH pnpm test +```bash +PATH=/Users/tkevinbigham/.local/node-lts/bin:$PATH pnpm build ``` -PASS. Turbo reported `Tasks: 8 successful, 8 total` in `1m23.712s`. Web passed 101 files / 629 tests, sim-core passed 137 files / 1610 tests, contracts passed 1 file / 20 tests, and UI passed 1 file / 1 test. Existing non-fatal console noise remained: Recharts zero-size warnings, React `act(...)` warnings, service worker failure-test log, and the existing ScoutingPage mock-function log. +Final tail: ```text -PATH=/Users/tkevinbigham/.local/node-lts/bin:$PATH pnpm build -``` +@mbd/web:build: ✓ built in 3.23s +@mbd/web:build: PWA v1.2.0 +@mbd/web:build: precache 122 entries (3286.88 KiB) -PASS. Turbo reported `Tasks: 5 successful, 5 total` in `4.134s`; Vite built in `3.28s`; PWA precached 120 entries. Bundle budget stayed green. Main app chunk: `index-jENJPu8m.js` 205.74 KB raw / 58.38 KB gzip. Worker chunks remained `game-engine-core` 450.75 KB raw and `game-engine-story` 452.10 KB raw. +Tasks: 5 successful, 5 total +Cached: 4 cached, 5 total +Time: 4.03s +``` -## Browser evidence +## Browser Evidence Dev server: -```text +```bash PATH=/Users/tkevinbigham/.local/node-lts/bin:$PATH pnpm --filter @mbd/web dev ``` -PASS at `http://localhost:5173/MBD/`. +Captured screenshots: + +| File | Evidence | +| --- | --- | +| `01-trade-negotiations-inbox.png` | `/MBD/trade-negotiations` with open negotiation rows. | +| `02-trade-negotiations-empty.png` | Empty Inbox state. | +| `03-trade-negotiation-detail.png` | Detail route with proposal, counter-offer, and dialogue. | +| `04-trade-negotiation-detail-awaiting-counter.png` | Detail route where `counterOffer === null`. | +| `05-trade-deep-link-loaded.png` | `/MBD/trade?negotiationId=<id>` with builder seeded from the negotiation. | +| `06-trade-clickable-name.png` | Trade player name focused/hovered as a `/players/:id` link. | +| `07-draft-clickable-prospect.png` | Draft prospect focused/hovered as a player-profile link. | +| `08-scouting-clickable-name.png` | Scouting result/report player link. | +| `09-stats-clickable-leader.png` | Leaderboard player link. | +| `10-news-player-chip.png` | News related-player chip linking to `/players/:id`. | +| `11-player-profile-trade-value.png` | Player Profile Trade Value panel. | +| `12-trade-negotiations-mobile-375.png` | Inbox at 375x667 with no horizontal overflow. | +| `13-trade-negotiations-hard-reload.png` | Inbox after hard reload, still on route. | + +Browser smoke checks: + +```text +First pass: 01-06 captured; consoleErrors: []; pageErrors: []. +Second pass: 07-13 captured; consoleErrors: []; pageErrors: []. +Mobile no-overflow: /trade-negotiations true; /trade-negotiations/:id true. +Hard reload: /trade-negotiations true; /trade-negotiations/:id true. +``` -Screenshots under `apps/web/docs/screenshots/sprint-3-5/`: +The Codex in-app browser screenshot command timed out on `Page.captureScreenshot`, so the committed screenshots were captured with a local Playwright Chromium fallback using the same running dev server. Playwright was installed into the user cache only; repo package manifests were not changed. -- `01-dashboard-before-hard-reload.png` - dashboard loaded from Save Hub continue. -- `02-dashboard-after-hard-reload.png` - `/MBD/dashboard` after hard reload, dashboard rendered. -- `03-news-before-hard-reload.png` - News Inbox before hard reload. -- `04-news-after-hard-reload.png` - `/MBD/news` after hard reload, News Inbox rendered. -- `05-roster-after-hard-reload.png` - `/MBD/roster` after hard reload, Roster rendered. -- `06-trade-after-hard-reload.png` - `/MBD/trade` after hard reload, Trade Center rendered. -- `07-draft-after-hard-reload.png` - `/MBD/draft` after hard reload, Draft Room rendered. -- `08-save-hub-after-delete-slot.png` - active Slot 1 deleted for missing-save fallback. -- `09-missing-save-fallback-save-hub.png` - stale persisted id fell through to Save Hub. -- `10-corrupt-save-recovery-dialog.png` - corrupt persisted save hit Save Recovery actions. -- `11-dashboard-mobile-375-after-hard-reload.png` - 375x667 dashboard hard reload rendered dashboard with `scrollWidth=375`. +## Cross-Linking Coverage -Routes hard-reloaded successfully without Save Hub redirect: +| Surface | Coverage | +| --- | --- | +| Roster | Existing baseline already links player names. | +| Free Agency | Existing baseline already links player names. | +| Minors | Existing baseline already links prospects to player profiles/development. | +| Trade | Shipped links in trade assets, offer summaries, active negotiation packages, deadline panels, and deep-link builder flow. | +| Draft | Shipped links for structured prospects and draft-board contexts. | +| News | Shipped related-player chips from machine-readable `relatedPlayerIds`; no prose parsing. | +| Scouting | Shipped links for structured pro/IFA reports, search results, recent reports, board entries, and conflict headlines. | +| Stats | Existing production leaderboard links verified with regression coverage. | + +## Bundle Impact + +Build and bundle-budget test passed with no ceiling changes. New route chunks are lazy-loaded: ```text -/MBD/dashboard -/MBD/news -/MBD/roster -/MBD/trade -/MBD/draft +TradeNegotiationsInboxPage-D2RxZ2hf.js 7.27 kB | gzip 2.26 kB +TradeNegotiationDetailPage-BeonUzVy.js 9.05 kB | gzip 2.73 kB +TradePage-BlCLVm2_.js 68.41 kB | gzip 13.46 kB +DraftPage-DjO87OaK.js 34.09 kB | gzip 7.07 kB +ScoutingPage-CGK5jiXs.js 34.79 kB | gzip 6.98 kB +LeadersPage-CT2DrRUP.js 7.84 kB | gzip 2.08 kB +NewsPage-CFthlCeJ.js 10.62 kB | gzip 3.64 kB +PlayerProfilePage-BACNN7vy.js 14.43 kB | gzip 4.57 kB +index-B5O8n8bI.js 206.42 kB | gzip 58.54 kB ``` -localStorage before dashboard reload: +`apps/web/src/build/bundleBudget.test.ts` passed as part of `pnpm test`. `apps/web/docs/BUDGETS.md` and `apps/web/src/build/bundleConfig.ts` were read; no budget lift was made. -```json -{"state":{"activeSaveId":"save-slot-1","activeSaveSlot":1,"userTeamId":"nym","season":1,"day":1,"phase":"preseason","teamName":"New York Tycoons","gmName":"Mobile Smoke","difficulty":"standard"},"version":1} -``` +## Worker Method Confirmation -localStorage after dashboard reload: +Newly consumed existing methods: -```json -{"state":{"activeSaveId":"save-slot-1","activeSaveSlot":1,"userTeamId":"nym","season":1,"day":1,"phase":"preseason","teamName":"New York Tycoons","gmName":"Mobile Smoke","difficulty":"standard"},"version":1} -``` +- `getOpenNegotiations()` in the Inbox route. +- `getNegotiation(negotiationId)` in the detail route and Trade Builder deep-link load. +- `getPlayerTradeValue(playerId)` on Player Profile through the existing worker API, newly forwarded by `useWorker`. -Missing-save fallback cleared the stale active save id and rendered Save Hub. Corrupt-save fallback cleared the stale active save id and showed the existing Save Recovery action surface; post-recovery storage was: +Confirmed: -```json -{"state":{"activeSaveId":null,"activeSaveSlot":null,"userTeamId":"nym","season":1,"day":1,"phase":"preseason","teamName":"New York Tycoons","gmName":"Smoke Tester","difficulty":"standard"},"version":1} -``` +- Zero new worker query/action methods. +- Zero edits to `apps/web/src/workers/sim.worker.queries.ts`. +- Zero edits to `apps/web/src/workers/sim.worker.actions.ts`. +- Zero edits to `apps/web/src/workers/sim.worker.trade.ts`. +- Zero save schema changes and no `packages/contracts/**` edits. +- No new dependencies. +- No new `Math.random()` or production `console.*` calls in the Sprint 4 diff. + +## Sprint 3.5 Invariant + +The new routes hard-reload successfully: + +| Route | Result | +| --- | --- | +| `/MBD/trade-negotiations` | Reload lands on Inbox, not Save Hub. | +| `/MBD/trade-negotiations/:id` | Reload lands on the detail route shell/graceful not-found for a non-persisted smoke negotiation, not Save Hub. | + +The new routes render at 375x667 without horizontal overflow: -## Save Recovery integration +| Route | Result | +| --- | --- | +| `/MBD/trade-negotiations` | `scrollWidth === clientWidth === 375`. | +| `/MBD/trade-negotiations/:id` | `scrollWidth === clientWidth === 375`. | -Auto-resume uses `loadSaveSafely(activeSaveId)`. Non-missing `{ ok: false }` results call `SaveRecoveryProvider.showFailure(...)` with a retry callback, matching the manual Save Hub load path. `AppBootGate.test.tsx` covers the corrupt-save branch, and browser evidence `10-corrupt-save-recovery-dialog.png` confirms the recovery action surface appears for a malformed persisted save. +Protected Sprint 3.5 files were not changed: `saveSystem.ts`, `AppBootGate.tsx`, `useGameStore.ts`, and `features/save-recovery/**`. -## Known limitations +## Known Limitations -- The in-app Browser read-only page scope did not expose `localStorage` or `indexedDB`, so exact storage snapshots and 375x667 viewport metrics were captured in a separate Playwright context against the same dev server. -- Auto-resume intentionally does not persist or duplicate snapshots in localStorage. IndexedDB remains the source of truth for heavy save data. -- If localStorage is disabled, the app behaves like the old flow and falls back to Save Hub on hard reload. +- News links use structured `relatedPlayerIds` only; narrative player names without a machine-readable player reference remain plain text. +- Trade narrative/ticker prose that lacks a structured player asset remains plain text; no regex/name parsing was introduced. +- The smoke-created negotiation was in-memory for the live browser profile. Hard reload of that exact detail ID confirms route survival and graceful unavailable-state handling, while the live detail screenshot confirms the full detail UI before reload. +- Stats leaderboard rows were already linked in production code; Sprint 4 added regression coverage rather than route-code churn. ## Risks -- Browser storage edge cases remain the main risk: localStorage denied, IndexedDB blocked, quota pressure, or a stale persisted id after manual browser data cleanup. The implemented behavior clears stale active-save state and falls back to Save Hub. -- Worker import failures now surface through Save Recovery as `storage_failed`; watch production telemetry/manual reports for any confusing copy if a future worker compatibility error is not truly storage-related. +- The Inbox is intentionally read-only. Accept/counter/reject actions deep-link into the existing Trade Builder rather than calling worker actions from the Inbox. +- Player Profile Trade Value depends on the existing worker method returning a value for the current player; the UI handles `null` by showing an unavailable state. +- Browser screenshots used a copied local profile plus Playwright fallback because the in-app screenshot command timed out; this affects evidence collection only, not app code or dependencies. -## Rollback notes +## Rollback Notes -Revert the Sprint 3.5 merge commit. No save schema bump, no migration, no worker/sim-core/contracts changes, no new dependencies. Existing `mbd:game-store@v1` localStorage entries become inert on the old code path; users can also remove that key manually if needed. +Rollback is straightforward: revert the Sprint 4 feature/docs commits on `goal/sprint-4-front-office` or revert the eventual merge commit. No migration rollback is required because save schema remains v33, no contracts changed, and no new dependencies were added. -## Next /goal +## Exact Next /goal ```text -/goal Implement Sprint 4: wire orphaned player-profile + open-negotiations endpoints. Read README.md, CHANGELOG.md, MASTER_CONTEXT.md, STATUS.md, GOAL.md, the player profile route/tests, trade negotiation route/tests, useWorker, and worker query/action surfaces first. Keep save schema v33 and do not touch sim-core/contracts unless GOAL.md explicitly allows it. Wire the existing worker endpoints into the player profile and open-negotiations UI so hard-reloaded long-running saves can inspect a player and resume active trade talks without dead controls. Add focused tests for player-profile endpoint wiring and open-negotiations resume behavior, then validate with pnpm typecheck + pnpm test + pnpm build and a dev-server browser smoke. Keep proof in .logs/goal-progress.md and finish with STATUS.md. +/goal Review Sprint 4 PR #78 against GOAL.md and STATUS.md, inspect apps/web/docs/screenshots/sprint-4/, then have Claude Code flip the draft PR ready or request targeted fixes. ``` diff --git a/apps/web/docs/screenshots/sprint-4/01-trade-negotiations-inbox.png b/apps/web/docs/screenshots/sprint-4/01-trade-negotiations-inbox.png new file mode 100644 index 0000000..b8f42ef Binary files /dev/null and b/apps/web/docs/screenshots/sprint-4/01-trade-negotiations-inbox.png differ diff --git a/apps/web/docs/screenshots/sprint-4/02-trade-negotiations-empty.png b/apps/web/docs/screenshots/sprint-4/02-trade-negotiations-empty.png new file mode 100644 index 0000000..db945d2 Binary files /dev/null and b/apps/web/docs/screenshots/sprint-4/02-trade-negotiations-empty.png differ diff --git a/apps/web/docs/screenshots/sprint-4/03-trade-negotiation-detail.png b/apps/web/docs/screenshots/sprint-4/03-trade-negotiation-detail.png new file mode 100644 index 0000000..567c760 Binary files /dev/null and b/apps/web/docs/screenshots/sprint-4/03-trade-negotiation-detail.png differ diff --git a/apps/web/docs/screenshots/sprint-4/04-trade-negotiation-detail-awaiting-counter.png b/apps/web/docs/screenshots/sprint-4/04-trade-negotiation-detail-awaiting-counter.png new file mode 100644 index 0000000..064dcd9 Binary files /dev/null and b/apps/web/docs/screenshots/sprint-4/04-trade-negotiation-detail-awaiting-counter.png differ diff --git a/apps/web/docs/screenshots/sprint-4/05-trade-deep-link-loaded.png b/apps/web/docs/screenshots/sprint-4/05-trade-deep-link-loaded.png new file mode 100644 index 0000000..c9355a1 Binary files /dev/null and b/apps/web/docs/screenshots/sprint-4/05-trade-deep-link-loaded.png differ diff --git a/apps/web/docs/screenshots/sprint-4/06-trade-clickable-name.png b/apps/web/docs/screenshots/sprint-4/06-trade-clickable-name.png new file mode 100644 index 0000000..2468329 Binary files /dev/null and b/apps/web/docs/screenshots/sprint-4/06-trade-clickable-name.png differ diff --git a/apps/web/docs/screenshots/sprint-4/07-draft-clickable-prospect.png b/apps/web/docs/screenshots/sprint-4/07-draft-clickable-prospect.png new file mode 100644 index 0000000..1ad3807 Binary files /dev/null and b/apps/web/docs/screenshots/sprint-4/07-draft-clickable-prospect.png differ diff --git a/apps/web/docs/screenshots/sprint-4/08-scouting-clickable-name.png b/apps/web/docs/screenshots/sprint-4/08-scouting-clickable-name.png new file mode 100644 index 0000000..641c978 Binary files /dev/null and b/apps/web/docs/screenshots/sprint-4/08-scouting-clickable-name.png differ diff --git a/apps/web/docs/screenshots/sprint-4/09-stats-clickable-leader.png b/apps/web/docs/screenshots/sprint-4/09-stats-clickable-leader.png new file mode 100644 index 0000000..4683036 Binary files /dev/null and b/apps/web/docs/screenshots/sprint-4/09-stats-clickable-leader.png differ diff --git a/apps/web/docs/screenshots/sprint-4/10-news-player-chip.png b/apps/web/docs/screenshots/sprint-4/10-news-player-chip.png new file mode 100644 index 0000000..d58a5b0 Binary files /dev/null and b/apps/web/docs/screenshots/sprint-4/10-news-player-chip.png differ diff --git a/apps/web/docs/screenshots/sprint-4/11-player-profile-trade-value.png b/apps/web/docs/screenshots/sprint-4/11-player-profile-trade-value.png new file mode 100644 index 0000000..f79123e Binary files /dev/null and b/apps/web/docs/screenshots/sprint-4/11-player-profile-trade-value.png differ diff --git a/apps/web/docs/screenshots/sprint-4/12-trade-negotiations-mobile-375.png b/apps/web/docs/screenshots/sprint-4/12-trade-negotiations-mobile-375.png new file mode 100644 index 0000000..e57db66 Binary files /dev/null and b/apps/web/docs/screenshots/sprint-4/12-trade-negotiations-mobile-375.png differ diff --git a/apps/web/docs/screenshots/sprint-4/13-trade-negotiations-hard-reload.png b/apps/web/docs/screenshots/sprint-4/13-trade-negotiations-hard-reload.png new file mode 100644 index 0000000..54ef4fd Binary files /dev/null and b/apps/web/docs/screenshots/sprint-4/13-trade-negotiations-hard-reload.png differ diff --git a/apps/web/src/app/layout/Sidebar.test.tsx b/apps/web/src/app/layout/Sidebar.test.tsx index a68e1be..71c26c0 100644 --- a/apps/web/src/app/layout/Sidebar.test.tsx +++ b/apps/web/src/app/layout/Sidebar.test.tsx @@ -94,6 +94,8 @@ describe('Sidebar', () => { expect(container.textContent).toContain('Offseason'); expect(container.textContent).toContain('Compare'); expect(container.textContent).toContain('News'); + expect(container.textContent).toContain('Trade Negotiations'); + expect(container.querySelector('a[href="/trade-negotiations"]')?.textContent).toContain('Trade Negotiations'); expect(getDashboardSummary).toHaveBeenCalledTimes(1); }); diff --git a/apps/web/src/app/layout/Sidebar.tsx b/apps/web/src/app/layout/Sidebar.tsx index e5d572b..aa434f6 100644 --- a/apps/web/src/app/layout/Sidebar.tsx +++ b/apps/web/src/app/layout/Sidebar.tsx @@ -12,6 +12,7 @@ import { Search, FileText, ArrowLeftRight, + Handshake, HandCoins, Flame, MoreHorizontal, @@ -56,6 +57,7 @@ const baseMainNavItems: NavItem[] = [ { to: '/offseason', label: 'Offseason', icon: <Snowflake className="h-5 w-5" /> }, { to: '/players/compare', label: 'Compare', icon: <Scale className="h-5 w-5" /> }, { to: '/trade', label: 'Trades', icon: <ArrowLeftRight className="h-5 w-5" /> }, + { to: '/trade-negotiations', label: 'Trade Negotiations', icon: <Handshake className="h-5 w-5" /> }, { to: '/league/standings', label: 'League', icon: <Trophy className="h-5 w-5" /> }, { to: '/stats', label: 'Stats', icon: <BarChart3 className="h-5 w-5" /> }, { to: '/records', label: 'Records', icon: <TrendingUp className="h-5 w-5" /> }, diff --git a/apps/web/src/app/routes/index.tsx b/apps/web/src/app/routes/index.tsx index f0ece81..b1493c2 100644 --- a/apps/web/src/app/routes/index.tsx +++ b/apps/web/src/app/routes/index.tsx @@ -37,6 +37,12 @@ const DraftPage = lazy( const TradePage = lazy( () => import('@/features/trade/routes/TradePage') ); +const TradeNegotiationsInboxPage = lazy( + () => import('@/features/trade-negotiations/routes/TradeNegotiationsInboxPage') +); +const TradeNegotiationDetailPage = lazy( + () => import('@/features/trade-negotiations/routes/TradeNegotiationDetailPage') +); const StandingsPage = lazy( () => import('@/features/league/routes/StandingsPage') ); @@ -142,6 +148,8 @@ export function AppRoutes() { <Route path="staff" element={withRouteBoundary('Staff', <StaffPage />)} /> <Route path="draft" element={withRouteBoundary('Draft', <DraftPage />)} /> <Route path="trade" element={withRouteBoundary('Trade', <TradePage />)} /> + <Route path="trade-negotiations" element={withRouteBoundary('Trade Negotiations', <TradeNegotiationsInboxPage />)} /> + <Route path="trade-negotiations/:negotiationId" element={withRouteBoundary('Trade Negotiation', <TradeNegotiationDetailPage />)} /> <Route path="standings" element={withRouteBoundary('Standings', <StandingsPage />)} /> <Route path="leaders" element={withRouteBoundary('Leaders', <LeadersPage />)} /> <Route path="league"> diff --git a/apps/web/src/features/draft/routes/DraftPage.test.tsx b/apps/web/src/features/draft/routes/DraftPage.test.tsx index 6fad107..4944e50 100644 --- a/apps/web/src/features/draft/routes/DraftPage.test.tsx +++ b/apps/web/src/features/draft/routes/DraftPage.test.tsx @@ -487,6 +487,10 @@ describe('DraftPage', () => { expect(container.textContent).toContain('War Room'); expect(container.textContent).toContain('Buzz Tracker'); expect(container.textContent).toContain('Value Slide'); + const availableProspectLink = container.querySelector('a[href="/players/prospect-1?tab=development"]'); + const completedPickLink = container.querySelector('a[href="/players/bos-1?tab=development"]'); + expect(availableProspectLink?.textContent ?? '').toContain('Eli Prospect'); + expect(completedPickLink?.textContent ?? '').toContain('Marcus Early'); const watchButton = Array.from(container.querySelectorAll('button')).find( (button) => button.textContent?.includes('Watch Draft'), diff --git a/apps/web/src/features/draft/routes/DraftPage.tsx b/apps/web/src/features/draft/routes/DraftPage.tsx index 2d29d94..1ee5cf2 100644 --- a/apps/web/src/features/draft/routes/DraftPage.tsx +++ b/apps/web/src/features/draft/routes/DraftPage.tsx @@ -8,6 +8,7 @@ import { Users, Zap, } from 'lucide-react'; +import { Link } from 'react-router-dom'; import { useWorker } from '@/shared/hooks/useWorker'; import { useGameStore } from '@/shared/hooks/useGameStore'; import { getAudioEngine } from '@/shared/lib/audio'; @@ -56,6 +57,12 @@ function formatBonus(value: number | null | undefined): string { return `$${(value ?? 0).toFixed(2)}M`; } +const DRAFT_PLAYER_LINK_CLASS = 'font-heading font-medium text-dynasty-text hover:text-accent-primary'; + +function draftPlayerPath(playerId: string): string { + return `/players/${playerId}?tab=development`; +} + function compensationContextLabel( compensation: DraftRoomPick['compensation'] | DraftBoardCell['compensation'] | null | undefined, ): string | null { @@ -207,7 +214,15 @@ function ProspectsPanel({ <td className="px-4 py-2 font-data text-dynasty-muted"> {prospect.bigBoardRank ?? index + 1} </td> - <td className="px-2 py-2 font-heading font-medium text-dynasty-textBright">{prospect.name}</td> + <td className="px-2 py-2"> + <Link + to={draftPlayerPath(prospect.playerId)} + onClick={(event) => event.stopPropagation()} + className={DRAFT_PLAYER_LINK_CLASS} + > + {prospect.name} + </Link> + </td> <td className="px-2 py-2 font-data text-dynasty-muted">{prospect.position}</td> <td className="px-2 py-2 text-right font-data text-dynasty-muted">{prospect.age}</td> <td className="px-2 py-2 font-data text-dynasty-muted">{prospect.looks ?? 0}</td> @@ -289,7 +304,11 @@ function CurrentPickPanel({ <> <div className="flex items-start justify-between gap-4"> <div> - <p className="font-heading text-lg font-semibold text-dynasty-textBright">{selectedProspect.name}</p> + <p className="font-heading text-lg font-semibold text-dynasty-textBright"> + <Link to={draftPlayerPath(selectedProspect.playerId)} className={DRAFT_PLAYER_LINK_CLASS}> + {selectedProspect.name} + </Link> + </p> <p className="mt-1 font-data text-sm text-dynasty-muted"> {selectedProspect.position} · {selectedProspect.origin} · Age {selectedProspect.age} </p> @@ -431,7 +450,10 @@ function DraftTicker({ Round {pick.round} · Pick {pick.pickNumber} </p> <p className="mt-1 font-heading text-sm font-semibold"> - {pick.teamAbbreviation} selected {pick.playerName} + {pick.teamAbbreviation} selected{' '} + <Link to={draftPlayerPath(pick.playerId)} className={DRAFT_PLAYER_LINK_CLASS}> + {pick.playerName} + </Link> </p> <p className="mt-1 font-data text-xs text-dynasty-muted"> {pick.position} · {pick.origin} @@ -510,7 +532,9 @@ function DraftBoard({ draft, visibleCount }: { draft: DraftRoomView; visibleCoun )} </div> <div className="mt-1 font-heading text-xs font-semibold leading-tight"> - {visiblePick.playerName} + <Link to={draftPlayerPath(visiblePick.playerId)} className={DRAFT_PLAYER_LINK_CLASS}> + {visiblePick.playerName} + </Link> </div> <div className="mt-1 font-data text-[10px] text-dynasty-muted"> {visiblePick.position} · {visiblePick.scoutingGrade} @@ -718,6 +742,9 @@ function PostDraftGrades({ gradesView }: { gradesView: DraftPostDraftGradesView <span className="font-heading text-sm font-semibold text-dynasty-textBright">{grade.teamName}</span> </div> <p className="mt-1 font-heading text-xs text-dynasty-muted">{grade.summary}</p> + <Link to={draftPlayerPath(grade.bestPickPlayerId)} className={DRAFT_PLAYER_LINK_CLASS}> + Best pick: {grade.bestPickPlayerName} + </Link> </div> <div className="text-right"> <p className="font-heading text-2xl font-semibold text-dynasty-textBright">{grade.grade}</p> @@ -771,7 +798,11 @@ function DraftSummary({ <div key={pick.playerId} className="rounded border border-dynasty-border bg-dynasty-surface px-4 py-3"> <div className="flex items-start justify-between gap-4"> <div> - <p className="font-heading text-sm font-semibold text-dynasty-textBright">{pick.playerName}</p> + <p className="font-heading text-sm font-semibold text-dynasty-textBright"> + <Link to={draftPlayerPath(pick.playerId)} className={DRAFT_PLAYER_LINK_CLASS}> + {pick.playerName} + </Link> + </p> <p className="mt-1 font-data text-xs text-dynasty-muted"> {pick.position} · {pick.origin} </p> diff --git a/apps/web/src/features/league/routes/LeadersPage.test.tsx b/apps/web/src/features/league/routes/LeadersPage.test.tsx index 6ed77c4..8d7e372 100644 --- a/apps/web/src/features/league/routes/LeadersPage.test.tsx +++ b/apps/web/src/features/league/routes/LeadersPage.test.tsx @@ -144,6 +144,8 @@ describe('LeadersPage', () => { expect(container.textContent).toContain('wOBA'); expect(container.textContent).toContain('wRC+'); expect(container.textContent).toContain('5.6'); + const judgeLink = container.querySelector<HTMLAnchorElement>('a[href="/players/p1"]'); + expect(judgeLink?.textContent).toContain('Aaron Judge'); const fipButton = Array.from(container.querySelectorAll('button')).find((button) => button.textContent?.includes('FIP'), @@ -160,5 +162,7 @@ describe('LeadersPage', () => { expect(container.textContent).toContain('Gerrit Cole'); expect(container.textContent).toContain('xFIP'); expect(container.textContent).toContain('2.83'); + const coleLink = container.querySelector<HTMLAnchorElement>('a[href="/players/p2"]'); + expect(coleLink?.textContent).toContain('Gerrit Cole'); }); }); diff --git a/apps/web/src/features/news/routes/NewsPage.test.tsx b/apps/web/src/features/news/routes/NewsPage.test.tsx index 75357c0..9f8ccb7 100644 --- a/apps/web/src/features/news/routes/NewsPage.test.tsx +++ b/apps/web/src/features/news/routes/NewsPage.test.tsx @@ -72,6 +72,7 @@ describe('NewsPage', () => { let container: HTMLDivElement; let root: Root; let getNews: ReturnType<typeof vi.fn>; + let getPlayer: ReturnType<typeof vi.fn>; let markNewsRead: ReturnType<typeof vi.fn>; let exportSnapshot: ReturnType<typeof vi.fn>; @@ -81,6 +82,11 @@ describe('NewsPage', () => { root = createRoot(container); getNews = vi.fn().mockResolvedValue(sampleNews); + getPlayer = vi.fn().mockImplementation(async (playerId: string) => { + if (playerId === 'player-ace') return { id: playerId, firstName: 'Casey', lastName: 'Ace' }; + if (playerId === 'prospect-1') return { id: playerId, firstName: 'Eli', lastName: 'Prospect' }; + return null; + }); markNewsRead = vi.fn().mockResolvedValue(undefined); exportSnapshot = vi.fn().mockResolvedValue({ schemaVersion: 33, @@ -113,6 +119,7 @@ describe('NewsPage', () => { mockedUseWorker.mockReturnValue({ isReady: true, exportSnapshot, + getPlayer, getNews, markNewsRead, } as unknown as ReturnType<typeof useWorker>); @@ -151,10 +158,24 @@ describe('NewsPage', () => { expect(content).toContain('Priority 5'); expect(content).toContain('WATCH'); expect(content).toContain('NYT'); - expect(content).toContain('player-ace'); + expect(content).toContain('Casey Ace'); expect(getNews).toHaveBeenCalledWith(100); }); + it('links machine-readable related players as profile chips', async () => { + await renderPage(); + + const aceLink = container.querySelector<HTMLAnchorElement>('a[href="/players/player-ace"]'); + expect(aceLink).not.toBeNull(); + expect(aceLink!.textContent).toContain('Casey Ace'); + + const prospectLink = container.querySelector<HTMLAnchorElement>('a[href="/players/prospect-1"]'); + expect(prospectLink).not.toBeNull(); + expect(prospectLink!.textContent).toContain('Eli Prospect'); + expect(getPlayer).toHaveBeenCalledWith('player-ace'); + expect(getPlayer).toHaveBeenCalledWith('prospect-1'); + }); + it('marks an unread item read when opened', async () => { await renderPage(); diff --git a/apps/web/src/features/news/routes/NewsPage.tsx b/apps/web/src/features/news/routes/NewsPage.tsx index 982196a..4339bf4 100644 --- a/apps/web/src/features/news/routes/NewsPage.tsx +++ b/apps/web/src/features/news/routes/NewsPage.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Link } from 'react-router-dom'; import { AlertTriangle, ChevronDown, ChevronRight, Inbox, Search } from 'lucide-react'; import { Badge, Skeleton } from '@mbd/ui'; import { getTeamById } from '@mbd/sim-core'; @@ -16,6 +17,7 @@ type ReadFilter = 'all' | 'unread'; const NEWS_LIMIT = 100; const BODY_EXCERPT_LENGTH = 180; const ALL_CATEGORY = 'all'; +const PLAYER_CHIP_CLASS = 'max-w-full truncate rounded border border-dynasty-border px-2 py-1 font-data text-[10px] text-dynasty-muted hover:border-accent-primary hover:text-accent-primary'; function parseTimestampRank(timestamp: string): number { if (timestamp === 'NOW') return Number.MAX_SAFE_INTEGER; @@ -83,6 +85,10 @@ function teamLabel(teamId: string): string { return team?.abbreviation ?? teamId.toUpperCase(); } +function playerProfilePath(playerId: string): string { + return `/players/${playerId}`; +} + function NewsSkeleton() { return ( <div className="space-y-5" data-testid="news-page-skeleton"> @@ -105,14 +111,17 @@ function NewsItemCard({ item, expanded, marking, + playerLabels, onOpen, }: { item: NewsItem; expanded: boolean; marking: boolean; + playerLabels: Record<string, string>; onOpen: (item: NewsItem) => void; }) { const isUnread = !item.read; + const hasRelatedEntities = item.relatedTeamIds.length > 0 || item.relatedPlayerIds.length > 0; return ( <article @@ -162,33 +171,35 @@ function NewsItemCard({ </span> </div> - {(item.relatedTeamIds.length > 0 || item.relatedPlayerIds.length > 0) ? ( - <div className="flex flex-wrap gap-2"> - {item.relatedTeamIds.map((teamId) => ( - <span - key={`team-${item.id}-${teamId}`} - className="rounded border border-accent-primary/30 bg-accent-primary/10 px-2 py-1 font-data text-[10px] uppercase tracking-[0.16em] text-accent-primary" - > - {teamLabel(teamId)} - </span> - ))} - {item.relatedPlayerIds.map((playerId) => ( - <span - key={`player-${item.id}-${playerId}`} - className="max-w-full truncate rounded border border-dynasty-border px-2 py-1 font-data text-[10px] text-dynasty-muted" - > - {playerId} - </span> - ))} - </div> - ) : null} - - {marking ? ( - <div className="font-data text-[11px] uppercase tracking-[0.16em] text-dynasty-muted"> - Saving read state... - </div> - ) : null} </button> + + {hasRelatedEntities ? ( + <div className="flex flex-wrap gap-2 px-4 pb-4 sm:px-5"> + {item.relatedTeamIds.map((teamId) => ( + <span + key={`team-${item.id}-${teamId}`} + className="rounded border border-accent-primary/30 bg-accent-primary/10 px-2 py-1 font-data text-[10px] uppercase tracking-[0.16em] text-accent-primary" + > + {teamLabel(teamId)} + </span> + ))} + {item.relatedPlayerIds.map((playerId) => ( + <Link + key={`player-${item.id}-${playerId}`} + to={playerProfilePath(playerId)} + className={PLAYER_CHIP_CLASS} + > + {playerLabels[playerId] ?? playerId} + </Link> + ))} + </div> + ) : null} + + {marking ? ( + <div className="px-4 pb-4 font-data text-[11px] uppercase tracking-[0.16em] text-dynasty-muted sm:px-5"> + Saving read state... + </div> + ) : null} </article> ); } @@ -206,6 +217,7 @@ export default function NewsPage() { teamName, } = useGameStore(); const [items, setItems] = useState<NewsItem[]>([]); + const [playerLabels, setPlayerLabels] = useState<Record<string, string>>({}); const [loading, setLoading] = useState(true); const [error, setError] = useState<string | null>(null); const [readFilter, setReadFilter] = useState<ReadFilter>('all'); @@ -216,6 +228,7 @@ export default function NewsPage() { const fetchNews = useCallback(async () => { if (!isInitialized || !worker.isReady) { setItems([]); + setPlayerLabels({}); setLoading(false); return; } @@ -224,10 +237,31 @@ export default function NewsPage() { setError(null); try { const nextNews = await worker.getNews(NEWS_LIMIT); - setItems([...(nextNews ?? [])].sort(compareNewsForInbox)); + const sortedNews = [...(nextNews ?? [])].sort(compareNewsForInbox); + setItems(sortedNews); + + const playerIds = Array.from(new Set(sortedNews.flatMap((item) => item.relatedPlayerIds))); + if (playerIds.length === 0) { + setPlayerLabels({}); + } else { + const labels = await Promise.all(playerIds.map(async (playerId) => { + try { + const player = await worker.getPlayer(playerId); + return [ + playerId, + player ? `${player.firstName} ${player.lastName}` : playerId, + ] as const; + } catch (err) { + logger.error('Failed to resolve news player label:', err); + return [playerId, playerId] as const; + } + })); + setPlayerLabels(Object.fromEntries(labels)); + } } catch (err) { logger.error('Failed to fetch news inbox:', err); setError('News feed unavailable.'); + setPlayerLabels({}); } finally { setLoading(false); } @@ -419,6 +453,7 @@ export default function NewsPage() { item={item} expanded={expandedIds.has(item.id)} marking={markingIds.has(item.id)} + playerLabels={playerLabels} onOpen={(selected) => void markItemRead(selected)} /> ))} diff --git a/apps/web/src/features/players/routes/PlayerProfilePage.test.tsx b/apps/web/src/features/players/routes/PlayerProfilePage.test.tsx index 15776b1..af15a43 100644 --- a/apps/web/src/features/players/routes/PlayerProfilePage.test.tsx +++ b/apps/web/src/features/players/routes/PlayerProfilePage.test.tsx @@ -367,9 +367,20 @@ describe('PlayerProfilePage', () => { }); async function renderPage(initialEntry: string, profileView: PlayerProfileView) { - mockedUseWorker.mockReturnValue({ + const worker = { isReady: true, getPlayerProfileView: vi.fn().mockResolvedValue(profileView), + getPlayerTradeValue: vi.fn().mockResolvedValue({ + playerId: profileView.player?.id ?? 'player-1', + overall: 74, + dimensions: { + currentAbility: 68, + futureValue: 82, + contractValue: 76, + positionalScarcity: 75, + durability: 69, + }, + }), getExtensionOffer: vi.fn().mockResolvedValue({ years: 5, annualSalary: 18.2, @@ -392,7 +403,9 @@ describe('PlayerProfilePage', () => { designateForAssignment: vi.fn().mockResolvedValue({ success: true }), getSeasonProjections: vi.fn().mockResolvedValue(null), getPlayerSimilarity: vi.fn().mockResolvedValue(null), - } as unknown as ReturnType<typeof useWorker>); + }; + + mockedUseWorker.mockReturnValue(worker as unknown as ReturnType<typeof useWorker>); await act(async () => { root.render( @@ -406,6 +419,7 @@ describe('PlayerProfilePage', () => { await Promise.resolve(); }); await settleUi(); + return worker; } async function settleUi() { @@ -465,6 +479,17 @@ describe('PlayerProfilePage', () => { expect(container.textContent).toContain('Fan Favorite'); }); + it('renders the player trade value sidebar panel', async () => { + const worker = await renderPage('/players/player-1', createProfileView()); + + expect(worker.getPlayerTradeValue).toHaveBeenCalledWith('player-1'); + expect(container.textContent).toContain('Trade Value'); + expect(container.textContent).toContain('Overall Trade Value'); + expect(container.textContent).toContain('74'); + expect(container.textContent).toContain('Future Value'); + expect(container.textContent).toContain('Contract Value'); + }); + it('renders a read-only historical fallback and scouting availability note for retired players', async () => { const historicalView = createProfileView({ player: { @@ -514,6 +539,17 @@ describe('PlayerProfilePage', () => { minorLeagueLevel: null, }, })), + getPlayerTradeValue: vi.fn().mockResolvedValue({ + playerId: 'player-1', + overall: 74, + dimensions: { + currentAbility: 68, + futureValue: 82, + contractValue: 76, + positionalScarcity: 75, + durability: 69, + }, + }), getExtensionOffer: vi.fn().mockResolvedValue({ years: 5, annualSalary: 18.2, diff --git a/apps/web/src/features/players/routes/PlayerProfilePage.tsx b/apps/web/src/features/players/routes/PlayerProfilePage.tsx index 5bc6bae..36bc32f 100644 --- a/apps/web/src/features/players/routes/PlayerProfilePage.tsx +++ b/apps/web/src/features/players/routes/PlayerProfilePage.tsx @@ -1,6 +1,7 @@ import { Suspense, lazy, useCallback, useEffect, useMemo, useState } from 'react'; import { Link, useParams, useSearchParams } from 'react-router-dom'; import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Skeleton, StatLine, Tabs, TabsContent, TabsList, TabsTrigger } from '@mbd/ui'; +import type { PlayerTradeValue } from '@mbd/sim-core'; import { ArrowDownCircle, ArrowLeft, @@ -97,6 +98,14 @@ const TAB_COMPONENTS = { personality: PersonalityTab, } satisfies Record<PlayerProfileTab, typeof StatsTab>; +const TRADE_VALUE_DIMENSION_LABELS: Array<[keyof PlayerTradeValue['dimensions'], string]> = [ + ['currentAbility', 'Current Ability'], + ['futureValue', 'Future Value'], + ['contractValue', 'Contract Value'], + ['positionalScarcity', 'Positional Scarcity'], + ['durability', 'Durability'], +]; + function PlayerProfileSkeleton() { return ( <div className="space-y-6" data-testid="player-profile-loading"> @@ -143,6 +152,77 @@ function actionToneClasses(tone: ActionState['tone']): string { } } +function tradeValueBarClass(score: number): string { + if (score >= 75) return 'bg-accent-success'; + if (score >= 55) return 'bg-accent-primary'; + if (score >= 35) return 'bg-accent-warning'; + return 'bg-accent-danger'; +} + +function TradeValuePanel({ + value, + loading, +}: { + value: PlayerTradeValue | null; + loading: boolean; +}) { + return ( + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2 font-heading text-dynasty-text"> + <ArrowLeftRight className="h-4 w-4 text-accent-primary" /> + Trade Value + </CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + {loading ? ( + <div className="space-y-3"> + <Skeleton className="h-16 rounded-lg" /> + <Skeleton className="h-3 rounded-full" /> + <Skeleton className="h-3 rounded-full" /> + <Skeleton className="h-3 rounded-full" /> + </div> + ) : value ? ( + <> + <div className="rounded-lg border border-accent-primary/30 bg-accent-primary/10 px-4 py-3"> + <div className="font-heading text-[11px] uppercase tracking-[0.18em] text-dynasty-muted"> + Overall Trade Value + </div> + <div className="mt-2 flex items-end gap-2"> + <span className="font-data text-4xl text-dynasty-textBright">{Math.round(value.overall)}</span> + <span className="pb-1 font-data text-xs uppercase tracking-[0.16em] text-dynasty-muted">/ 100</span> + </div> + </div> + <div className="space-y-3"> + {TRADE_VALUE_DIMENSION_LABELS.map(([dimension, label]) => { + const score = Math.round(value.dimensions[dimension]); + return ( + <div key={dimension}> + <div className="mb-1 flex items-center justify-between gap-3"> + <span className="font-heading text-xs text-dynasty-muted">{label}</span> + <span className="font-data text-xs text-dynasty-text">{score}</span> + </div> + <div className="h-2 rounded-full bg-dynasty-elevated"> + <div + className={`h-full rounded-full ${tradeValueBarClass(score)}`} + style={{ width: `${Math.max(0, Math.min(100, score))}%` }} + /> + </div> + </div> + ); + })} + </div> + </> + ) : ( + <div className="rounded-lg border border-dynasty-border bg-dynasty-elevated px-4 py-5 font-heading text-sm text-dynasty-muted"> + Trade value is unavailable for this profile. + </div> + )} + </CardContent> + </Card> + ); +} + export default function PlayerProfilePage() { const { playerId } = useParams<{ playerId: string }>(); const [searchParams, setSearchParams] = useSearchParams(); @@ -150,7 +230,9 @@ export default function PlayerProfilePage() { const workerReady = worker.isReady; const { isInitialized, day, season, userTeamId } = useGameStore(); const [view, setView] = useState<PlayerProfileView | null>(null); + const [tradeValue, setTradeValue] = useState<PlayerTradeValue | null>(null); const [loading, setLoading] = useState(true); + const [tradeValueLoading, setTradeValueLoading] = useState(false); const [actionState, setActionState] = useState<ActionState | null>(null); const [busyAction, setBusyAction] = useState<'extend' | 'promote' | 'demote' | 'dfa' | null>(null); @@ -163,11 +245,17 @@ export default function PlayerProfilePage() { } setLoading(true); + setTradeValueLoading(true); try { - const data = await worker.getPlayerProfileView(playerId); + const [data, value] = await Promise.all([ + worker.getPlayerProfileView(playerId), + worker.getPlayerTradeValue(playerId), + ]); setView((data as PlayerProfileView | null) ?? null); + setTradeValue((value as PlayerTradeValue | null) ?? null); } finally { setLoading(false); + setTradeValueLoading(false); } }, [isInitialized, playerId, worker, workerReady]); @@ -437,6 +525,8 @@ export default function PlayerProfilePage() { </CardContent> </Card> + <TradeValuePanel value={tradeValue} loading={tradeValueLoading} /> + <Card> <CardHeader> <CardTitle className="font-heading text-dynasty-text">Contract Snapshot</CardTitle> diff --git a/apps/web/src/features/scouting/components/ScoutConflictsTab.tsx b/apps/web/src/features/scouting/components/ScoutConflictsTab.tsx index 7e121a3..2b2e419 100644 --- a/apps/web/src/features/scouting/components/ScoutConflictsTab.tsx +++ b/apps/web/src/features/scouting/components/ScoutConflictsTab.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; import { Badge, GradeBar } from '@mbd/ui'; import { AlertTriangle, CheckCircle, Scale } from 'lucide-react'; import { useWorker } from '@/shared/hooks/useWorker'; @@ -29,6 +30,12 @@ interface ScoutConflict { outcomeSummary: string | null; } +const PLAYER_PROFILE_LINK_CLASS = 'font-heading text-sm text-dynasty-textBright hover:text-accent-primary'; + +function playerProfilePath(playerId: string): string { + return `/players/${playerId}`; +} + function sourceLabel(source: string): string { switch (source) { case 'scout_director': return 'Scout Director'; @@ -119,7 +126,11 @@ function ConflictCard({ conflict }: { conflict: ScoutConflict }) { {/* Header */} <div className="flex items-center gap-2"> <Scale className="h-4 w-4 text-dynasty-muted" /> - <h3 className="font-heading text-sm text-dynasty-textBright">{conflict.headline}</h3> + <h3> + <Link to={playerProfilePath(conflict.prospectId)} className={PLAYER_PROFILE_LINK_CLASS}> + {conflict.headline} + </Link> + </h3> <div className="ml-auto flex items-center gap-2"> {isDivided && !conflict.resolved && ( <Badge className="border-accent-danger/40 bg-accent-danger/10 text-accent-danger"> diff --git a/apps/web/src/features/scouting/routes/ScoutingPage.test.tsx b/apps/web/src/features/scouting/routes/ScoutingPage.test.tsx index 343ad38..990a44b 100644 --- a/apps/web/src/features/scouting/routes/ScoutingPage.test.tsx +++ b/apps/web/src/features/scouting/routes/ScoutingPage.test.tsx @@ -21,6 +21,163 @@ const mockedUseGameStore = vi.mocked(useGameStore); globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; +const MOCK_IFA_POOL = { + season: 1, + currentPhase: 'opening_window', + signingWindowOpen: true, + budget: { + baseAllocation: 5, + tradedIn: 0, + tradedOut: 0, + committed: 0, + remaining: 5, + }, + staffAccuracy: 0.72, + prospects: [ + { + id: 'ifa-1', + playerName: 'Luis Mercado', + age: 17, + position: 'SS', + region: 'dominican_republic', + country: 'Dominican Republic', + expectedBonus: 2.35, + status: 'available', + signedTeamId: null, + signedBonus: null, + looks: 1, + overall: 55, + confidence: 4, + ceiling: 66, + floor: 43, + notes: 'Projectable bat with twitchy defense.', + scoutConflict: null, + }, + ], +}; + +const MOCK_PRO_PLAYER = { + id: 'pro-1', + firstName: 'Anthony', + lastName: 'Volpe', + age: 24, + position: 'SS', + overallRating: 78, + displayRating: 78, + letterGrade: 'B+', + rosterStatus: 'active', + teamId: 'nyy', + serviceTimeDays: 400, + optionYearsUsed: 1, + isOutOfOptions: false, + minorLeagueLevel: null, + contract: { + years: 3, + annualSalary: 7.5, + totalValue: 22.5, + noTradeClause: false, + noTradeClauseType: 'none', + playerOption: false, + teamOption: false, + optOutYears: [], + signingBonus: 0, + buyoutAmount: 0, + deferredMoney: [], + }, + ceiling: 82, + floor: 68, + developmentProgram: null, + developmentTrajectory: 'steady', + personalityTraits: [], + extensionHistory: [], + stats: null, + advanced: null, +}; + +const MOCK_PRO_REPORT = { + playerId: 'pro-1', + playerName: 'Anthony Volpe', + position: 'SS', + age: 24, + teamName: 'New York Empires', + isPitcher: false, + grades: { + contact: 57, + power: 50, + eye: 54, + speed: 62, + defense: 65, + durability: 60, + }, + confidence: 4, + overall: 60, + ceiling: 68, + floor: 48, + notes: 'Reliable glove with enough contact to profile as a regular.', + scoutName: 'Pat Evaluator', + date: 'Season 1 Day 1', + reliability: 4, +}; + +function createWorkerMock() { + return { + isReady: true, + getScoutingStaff: vi.fn().mockResolvedValue([ + { id: 'scout-1', name: 'Pat Evaluator', quality: 72, specialty: 'Infield', bias: 'tools' }, + ]), + getTeamChemistry: vi.fn().mockResolvedValue({ + score: 74, + tier: 'connected', + summary: 'The room trusts the front office.', + }), + getOwnerState: vi.fn().mockResolvedValue({ + hotSeat: false, + patience: 70, + confidence: 65, + summary: 'Ownership is steady.', + }), + getIFAPool: vi.fn().mockResolvedValue(MOCK_IFA_POOL), + searchPlayers: vi.fn().mockResolvedValue([MOCK_PRO_PLAYER]), + scoutPlayerReport: vi.fn().mockResolvedValue(MOCK_PRO_REPORT), + scoutIFAPlayer: vi.fn().mockResolvedValue({ + success: true, + report: { + playerId: 'ifa-1', + playerName: 'Luis Mercado', + position: 'SS', + age: 17, + region: 'dominican_republic', + country: 'Dominican Republic', + expectedBonus: 2.35, + looks: 2, + grades: { + contact: 56, + power: 48, + eye: 50, + speed: 61, + defense: 60, + durability: 58, + }, + overall: 55, + confidence: 4, + ceiling: 66, + floor: 43, + notes: 'Projectable bat with twitchy defense.', + reliability: 4, + scoutConflict: null, + }, + }), + signIFAPlayer: vi.fn().mockResolvedValue({ success: true, remainingBudget: 2.65 }), + tradeIFAPoolSpace: vi.fn().mockResolvedValue({ success: true, remainingBudget: 4.5 }), + getScoutConflicts: vi.fn().mockResolvedValue([]), + } as unknown as ReturnType<typeof useWorker>; +} + +async function flushPromises() { + await Promise.resolve(); + await Promise.resolve(); +} + describe('ScoutingPage', () => { let container: HTMLDivElement; let root: Root; @@ -60,18 +217,7 @@ describe('ScoutingPage', () => { }); it('renders scouting heading', async () => { - mockedUseWorker.mockReturnValue({ - isReady: true, - getScoutingOverview: vi.fn().mockResolvedValue({ - scouts: [], - assignments: [], - recentReports: [], - }), - getIfaPool: vi.fn().mockResolvedValue({ - pool: [], - budget: { total: 5, spent: 0, remaining: 5 }, - }), - } as unknown as ReturnType<typeof useWorker>); + mockedUseWorker.mockReturnValue(createWorkerMock()); await act(async () => { root.render( @@ -79,10 +225,64 @@ describe('ScoutingPage', () => { <ScoutingPage /> </MemoryRouter>, ); - await Promise.resolve(); - await Promise.resolve(); + await flushPromises(); }); expect(container.textContent).toContain('Scout'); }); + + it('links scouting player names to player profiles', async () => { + mockedUseWorker.mockReturnValue(createWorkerMock()); + + await act(async () => { + root.render( + <MemoryRouter> + <ScoutingPage /> + </MemoryRouter>, + ); + await flushPromises(); + }); + + const ifaLink = container.querySelector<HTMLAnchorElement>('a[href="/players/ifa-1"]'); + expect(ifaLink).not.toBeNull(); + expect(ifaLink!.textContent).toContain('Luis Mercado'); + + const proReportsTab = Array.from(container.querySelectorAll('button')).find((button) => + button.textContent?.includes('Pro Reports'), + ); + + await act(async () => { + proReportsTab?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + const searchInput = container.querySelector<HTMLInputElement>('input[placeholder="Search player by name..."]'); + expect(searchInput).not.toBeNull(); + + await act(async () => { + const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; + setValue?.call(searchInput, 'Volpe'); + searchInput!.dispatchEvent(new Event('input', { bubbles: true })); + }); + + const searchButton = Array.from(container.querySelectorAll('button')).find((button) => + button.textContent?.trim() === 'Search', + ); + + await act(async () => { + searchButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await flushPromises(); + }); + + const scoutButton = Array.from(container.querySelectorAll('button')).find((button) => + button.textContent?.trim() === 'Scout', + ); + + await act(async () => { + scoutButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await flushPromises(); + }); + + const proLink = container.querySelector<HTMLAnchorElement>('a[href="/players/pro-1"]'); + expect(proLink?.textContent).toContain('Anthony Volpe'); + }); }); diff --git a/apps/web/src/features/scouting/routes/ScoutingPage.tsx b/apps/web/src/features/scouting/routes/ScoutingPage.tsx index bdf5a25..9a791bd 100644 --- a/apps/web/src/features/scouting/routes/ScoutingPage.tsx +++ b/apps/web/src/features/scouting/routes/ScoutingPage.tsx @@ -1,4 +1,5 @@ import { useEffect, useState, useCallback } from 'react'; +import { Link } from 'react-router-dom'; import { Search, Eye, @@ -164,6 +165,11 @@ function chemistryTone(tier: string | undefined): string { const hitterAttrs = ['Contact', 'Power', 'Eye', 'Speed', 'Defense', 'Durability']; const pitcherAttrs = ['Stuff', 'Control', 'Stamina', 'Velocity', 'Movement']; +const PLAYER_PROFILE_LINK_CLASS = 'font-heading font-semibold text-dynasty-textBright hover:text-accent-primary'; + +function playerProfilePath(playerId: string): string { + return `/players/${playerId}`; +} function formatMoney(value: number): string { return `$${value.toFixed(2)}M`; @@ -479,17 +485,24 @@ export default function ScoutingPage() { {searchResults.length > 0 && ( <div className="mt-2 max-h-48 overflow-y-auto rounded border border-dynasty-border bg-dynasty-elevated"> {searchResults.map((player) => ( - <button + <div key={player.id} - type="button" - onClick={() => { void handleScout(player); setSearchResults([]); }} - className="flex w-full items-center justify-between px-3 py-2 text-left hover:bg-dynasty-surface" + className="flex w-full items-center justify-between gap-3 px-3 py-2 text-left hover:bg-dynasty-surface" > - <span className="font-heading text-sm text-dynasty-text"> - {player.firstName} {player.lastName} - </span> - <span className="font-data text-xs text-dynasty-muted">{player.position} / Age {player.age}</span> - </button> + <div> + <Link to={playerProfilePath(player.id)} className={PLAYER_PROFILE_LINK_CLASS}> + {player.firstName} {player.lastName} + </Link> + <div className="font-data text-xs text-dynasty-muted">{player.position} / Age {player.age}</div> + </div> + <button + type="button" + onClick={() => { void handleScout(player); setSearchResults([]); }} + className="rounded border border-dynasty-border bg-dynasty-surface px-3 py-1.5 font-heading text-[11px] font-semibold text-dynasty-text hover:border-accent-primary" + > + Scout + </button> + </div> ))} </div> )} @@ -504,7 +517,11 @@ export default function ScoutingPage() { <div className="mt-4 space-y-4 rounded border border-dynasty-border bg-dynasty-elevated p-4"> <div className="flex items-start justify-between"> <div> - <h3 className="font-heading text-base font-bold text-dynasty-textBright">{scoutReport.playerName}</h3> + <h3 className="text-base"> + <Link to={playerProfilePath(scoutReport.playerId)} className={PLAYER_PROFILE_LINK_CLASS}> + {scoutReport.playerName} + </Link> + </h3> <p className="font-data text-xs text-dynasty-muted"> {scoutReport.position} | Age {scoutReport.age} | {scoutReport.teamName} </p> @@ -610,7 +627,11 @@ export default function ScoutingPage() { <tbody> {recentReports.map((report, index) => ( <tr key={`${report.playerId}-${index}`} className="border-b border-dynasty-border/50 hover:bg-dynasty-elevated/50"> - <td className="py-2 pr-4 font-heading text-sm text-dynasty-text">{report.playerName}</td> + <td className="py-2 pr-4 font-heading text-sm"> + <Link to={playerProfilePath(report.playerId)} className={PLAYER_PROFILE_LINK_CLASS}> + {report.playerName} + </Link> + </td> <td className="py-2 pr-4 font-data text-xs text-dynasty-muted">{report.position}</td> <td className="py-2 pr-4 text-right font-data text-sm text-dynasty-textBright">{report.overall}</td> <td className="py-2 pr-4 text-right font-data text-xs text-dynasty-muted">±{report.confidence}</td> @@ -717,7 +738,9 @@ export default function ScoutingPage() { {ifaReport ? ( <div className="space-y-3"> <div> - <div className="font-heading text-base font-semibold text-dynasty-textBright">{ifaReport.playerName}</div> + <Link to={playerProfilePath(ifaReport.playerId)} className={PLAYER_PROFILE_LINK_CLASS}> + {ifaReport.playerName} + </Link> <div className="font-data text-xs text-dynasty-muted"> {ifaReport.position} | Age {ifaReport.age} | {regionLabel(ifaReport.region)} / {ifaReport.country} </div> @@ -893,7 +916,9 @@ export default function ScoutingPage() { {ifaPool.prospects.map((prospect) => ( <tr key={prospect.id} className="border-b border-dynasty-border/50 hover:bg-dynasty-elevated/50"> <td className="py-2 pr-4"> - <div className="font-heading text-sm text-dynasty-text">{prospect.playerName}</div> + <Link to={playerProfilePath(prospect.id)} className={PLAYER_PROFILE_LINK_CLASS}> + {prospect.playerName} + </Link> <div className="font-data text-xs text-dynasty-muted">{prospect.position} / Age {prospect.age}</div> {prospect.scoutConflict ? ( <div className="mt-1 font-data text-[10px] uppercase tracking-[0.16em] text-accent-warning"> diff --git a/apps/web/src/features/trade-negotiations/routes/TradeNegotiationDetailPage.test.tsx b/apps/web/src/features/trade-negotiations/routes/TradeNegotiationDetailPage.test.tsx new file mode 100644 index 0000000..90cecff --- /dev/null +++ b/apps/web/src/features/trade-negotiations/routes/TradeNegotiationDetailPage.test.tsx @@ -0,0 +1,173 @@ +import { act } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createRoot, type Root } from 'react-dom/client'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import TradeNegotiationDetailPage from './TradeNegotiationDetailPage'; +import { useWorker } from '@/shared/hooks/useWorker'; +import { useGameStore } from '@/shared/hooks/useGameStore'; + +vi.mock('@/shared/hooks/useWorker', () => ({ + useWorker: vi.fn(), +})); + +vi.mock('@/shared/hooks/useGameStore', () => ({ + useGameStore: vi.fn(), +})); + +const mockedUseWorker = vi.mocked(useWorker); +const mockedUseGameStore = vi.mocked(useGameStore); + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const sampleNegotiation = { + id: 'neg-1', + teamId: 'bos', + teamName: 'Boston Noreasters', + teamAbbreviation: 'BOS', + phase: 'counter_2', + roundsCompleted: 3, + expiresAtDay: 112, + dialogue: [ + { speaker: 'rival_gm', text: 'Boston kicked back a firmer counter.', tone: 'firm' }, + { speaker: 'agm_advisor', text: 'The value gap is narrow enough to keep working.', tone: 'measured' }, + ], + proposal: { + offeringAssets: [ + { type: 'player', playerId: 'nym-1' }, + { type: 'draft_pick', season: 5, round: 2, originalTeamId: 'nym' }, + ], + requestingAssets: [ + { type: 'player', playerId: 'bos-1' }, + { type: 'ifa_pool_space', amount: 1.25 }, + ], + }, + counterOffer: { + offeringAssets: [{ type: 'player', playerId: 'nym-1' }], + requestingAssets: [ + { type: 'player', playerId: 'bos-1' }, + { type: 'player', playerId: 'bos-2' }, + ], + }, + isComplete: false, + canAccept: true, + canCounter: true, + canReject: true, +}; + +const playersById = new Map([ + ['nym-1', { id: 'nym-1', firstName: 'Carlos', lastName: 'Core', position: 'SS' }], + ['bos-1', { id: 'bos-1', firstName: 'Roman', lastName: 'Anthony', position: 'CF' }], + ['bos-2', { id: 'bos-2', firstName: 'Ben', lastName: 'Arm', position: 'SP' }], +]); + +describe('TradeNegotiationDetailPage', () => { + let container: HTMLDivElement; + let root: Root; + let getNegotiation: ReturnType<typeof vi.fn>; + let getPlayer: ReturnType<typeof vi.fn>; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + getNegotiation = vi.fn().mockResolvedValue(sampleNegotiation); + getPlayer = vi.fn().mockImplementation(async (playerId: string) => playersById.get(playerId) ?? null); + + mockedUseWorker.mockReturnValue({ + isReady: true, + getNegotiation, + getPlayer, + } as unknown as ReturnType<typeof useWorker>); + + mockedUseGameStore.mockReturnValue({ + isInitialized: true, + day: 109, + season: 4, + phase: 'regular', + userTeamId: 'nym', + teamName: 'Tycoons', + playerCount: 780, + gamesPlayed: 108, + isSimulating: false, + setSeason: vi.fn(), + setDay: vi.fn(), + setPhase: vi.fn(), + setSimulating: vi.fn(), + setInitialized: vi.fn(), + setUserTeamId: vi.fn(), + updateFromSim: vi.fn(), + initializeGame: vi.fn(), + }); + }); + + afterEach(async () => { + await act(async () => { + root.unmount(); + }); + container.remove(); + vi.clearAllMocks(); + }); + + async function renderPage(path = '/trade-negotiations/neg-1') { + await act(async () => { + root.render( + <MemoryRouter initialEntries={[path]}> + <Routes> + <Route path="/trade-negotiations/:negotiationId" element={<TradeNegotiationDetailPage />} /> + </Routes> + </MemoryRouter>, + ); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + } + + it('renders proposal, counter-offer, dialogue, and trade-builder deep link', async () => { + await renderPage(); + + const content = container.textContent ?? ''; + expect(content).toContain('Boston Noreasters'); + expect(content).toContain('counter 2'); + expect(content).toContain('Round 3'); + expect(content).toContain('Expires Day 112'); + expect(content).toContain('Proposal'); + expect(content).toContain('Counter-Offer'); + expect(content).toContain('Carlos Core'); + expect(content).toContain('Roman Anthony'); + expect(content).toContain('Ben Arm'); + expect(content).toContain('R2 5'); + expect(content).toContain('IFA Pool $1.25M'); + expect(content).toContain('Boston kicked back a firmer counter.'); + expect(content).toContain('The value gap is narrow enough to keep working.'); + + expect(container.querySelector('a[href="/players/nym-1"]')?.textContent).toContain('Carlos Core'); + expect(container.querySelector('a[href="/players/bos-1"]')?.textContent).toContain('Roman Anthony'); + expect(container.querySelector('a[href="/trade?negotiationId=neg-1"]')?.textContent).toContain('Open in Trade Builder'); + expect(getNegotiation).toHaveBeenCalledWith('neg-1'); + expect(getPlayer).toHaveBeenCalledWith('nym-1'); + }); + + it('renders the awaiting-counter path', async () => { + getNegotiation.mockResolvedValueOnce({ + ...sampleNegotiation, + counterOffer: null, + }); + + await renderPage(); + + expect(container.textContent).toContain('Awaiting counter'); + }); + + it('renders not found when the worker returns null', async () => { + getNegotiation.mockResolvedValueOnce(null); + + await renderPage('/trade-negotiations/missing-id'); + + expect(container.textContent).toContain('Trade negotiation not found'); + expect(container.querySelector('a[href="/trade-negotiations"]')?.textContent).toContain('Back to Inbox'); + }); +}); diff --git a/apps/web/src/features/trade-negotiations/routes/TradeNegotiationDetailPage.tsx b/apps/web/src/features/trade-negotiations/routes/TradeNegotiationDetailPage.tsx new file mode 100644 index 0000000..9135074 --- /dev/null +++ b/apps/web/src/features/trade-negotiations/routes/TradeNegotiationDetailPage.tsx @@ -0,0 +1,376 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Link, useParams } from 'react-router-dom'; +import { ArrowLeft, ArrowRight, AlertTriangle, Handshake } from 'lucide-react'; +import type { TradeAsset } from '@mbd/contracts'; +import { Badge, Skeleton } from '@mbd/ui'; +import { EmptyStatePanel } from '@/shared/components/EmptyStatePanel'; +import { PageShell } from '@/shared/components/PageShell'; +import { useGameStore } from '@/shared/hooks/useGameStore'; +import { useWorker } from '@/shared/hooks/useWorker'; +import { logger } from '@/shared/lib/logger'; +import type { PlayerDTO } from '@/workers/sim.worker.helpers'; +import type { TradeCounterPackage, TradeNegotiationView } from '@/workers/sim.worker.trade'; + +type PlayerLookup = Map<string, Pick<PlayerDTO, 'id' | 'firstName' | 'lastName' | 'position'>>; + +function TradeNegotiationDetailSkeleton() { + return ( + <div className="space-y-6" data-testid="trade-negotiation-detail-skeleton"> + <Skeleton className="h-5 w-32" /> + <Skeleton className="h-44 rounded-2xl" /> + <div className="grid gap-4 lg:grid-cols-2"> + <Skeleton className="h-64 rounded-2xl" /> + <Skeleton className="h-64 rounded-2xl" /> + </div> + <Skeleton className="h-52 rounded-2xl" /> + </div> + ); +} + +function phaseLabel(phase: TradeNegotiationView['phase']): string { + return phase.replace(/_/g, ' '); +} + +function collectPlayerIds(...packages: Array<TradeCounterPackage | null | undefined>): string[] { + const ids = new Set<string>(); + for (const tradePackage of packages) { + if (!tradePackage) continue; + for (const asset of [...tradePackage.offeringAssets, ...tradePackage.requestingAssets]) { + if (asset.type === 'player') { + ids.add(asset.playerId); + } + } + } + return [...ids].sort(); +} + +function assetKey(asset: TradeAsset, index: number): string { + switch (asset.type) { + case 'player': + return `player-${asset.playerId}`; + case 'draft_pick': + return `pick-${asset.season}-${asset.round}-${asset.originalTeamId}`; + case 'ifa_pool_space': + return `ifa-${asset.amount}-${index}`; + } +} + +function PlayerAssetLink({ + playerId, + players, +}: { + playerId: string; + players: PlayerLookup; +}) { + const player = players.get(playerId); + const name = player ? `${player.firstName} ${player.lastName}` : playerId; + return ( + <Link + to={`/players/${playerId}`} + className="font-heading font-medium text-dynasty-text hover:text-accent-primary" + > + {name} + {player ? <span className="ml-2 font-data text-[11px] text-dynasty-muted">{player.position}</span> : null} + </Link> + ); +} + +function TradeAssetLine({ + asset, + players, +}: { + asset: TradeAsset; + players: PlayerLookup; +}) { + if (asset.type === 'player') { + return <PlayerAssetLink playerId={asset.playerId} players={players} />; + } + + if (asset.type === 'draft_pick') { + return ( + <span className="font-heading text-sm text-dynasty-text"> + R{asset.round} {asset.season} + <span className="ml-2 font-data text-[11px] text-dynasty-muted"> + {asset.originalTeamId.toUpperCase()} original + </span> + </span> + ); + } + + return ( + <span className="font-heading text-sm text-dynasty-text"> + IFA Pool ${asset.amount.toFixed(2)}M + </span> + ); +} + +function AssetColumn({ + title, + assets, + players, +}: { + title: string; + assets: TradeAsset[]; + players: PlayerLookup; +}) { + return ( + <div className="rounded-lg border border-dynasty-border bg-dynasty-elevated/50 p-3"> + <div className="font-data text-[11px] uppercase tracking-[0.18em] text-dynasty-muted">{title}</div> + <div className="mt-3 space-y-2"> + {assets.length === 0 ? ( + <div className="font-heading text-sm text-dynasty-muted">No assets listed</div> + ) : ( + assets.map((asset, index) => ( + <div + key={assetKey(asset, index)} + className="rounded border border-dynasty-border bg-dynasty-surface px-3 py-2" + > + <TradeAssetLine asset={asset} players={players} /> + </div> + )) + )} + </div> + </div> + ); +} + +function TradePackagePanel({ + title, + tradePackage, + players, + emptyLabel, +}: { + title: string; + tradePackage: TradeCounterPackage | null; + players: PlayerLookup; + emptyLabel?: string; +}) { + return ( + <section className="rounded-lg border border-dynasty-border bg-dynasty-surface p-4"> + <h2 className="font-heading text-sm font-semibold text-dynasty-textBright">{title}</h2> + {tradePackage ? ( + <div className="mt-4 grid gap-3 sm:grid-cols-2"> + <AssetColumn title="You Send" assets={tradePackage.offeringAssets} players={players} /> + <AssetColumn title="You Receive" assets={tradePackage.requestingAssets} players={players} /> + </div> + ) : ( + <div className="mt-4 rounded-lg border border-dynasty-border bg-dynasty-elevated px-4 py-6 font-heading text-sm text-dynasty-muted"> + {emptyLabel ?? 'No package is available.'} + </div> + )} + </section> + ); +} + +function DialogueThread({ + negotiation, +}: { + negotiation: TradeNegotiationView; +}) { + return ( + <section className="rounded-lg border border-dynasty-border bg-dynasty-surface p-4"> + <h2 className="font-heading text-sm font-semibold text-dynasty-textBright">Dialogue Thread</h2> + <div className="mt-4 space-y-3"> + {negotiation.dialogue.length === 0 ? ( + <div className="rounded border border-dynasty-border bg-dynasty-elevated px-3 py-3 font-heading text-sm text-dynasty-muted"> + No dialogue has been logged for this negotiation yet. + </div> + ) : ( + negotiation.dialogue.map((entry, index) => ( + <div + key={`${entry.speaker}-${index}`} + className="rounded-lg border border-dynasty-border bg-dynasty-elevated/60 px-4 py-3" + > + <div className="font-data text-[10px] uppercase tracking-[0.18em] text-dynasty-muted"> + {entry.speaker === 'rival_gm' ? negotiation.teamAbbreviation : 'AGM Advisor'} + </div> + <p className="mt-2 font-heading text-sm leading-6 text-dynasty-text">{entry.text}</p> + </div> + )) + )} + </div> + </section> + ); +} + +export default function TradeNegotiationDetailPage() { + const { negotiationId } = useParams<{ negotiationId: string }>(); + const worker = useWorker(); + const { isInitialized, day, season, phase } = useGameStore(); + const [negotiation, setNegotiation] = useState<TradeNegotiationView | null>(null); + const [players, setPlayers] = useState<PlayerLookup>(() => new Map()); + const [loading, setLoading] = useState(true); + const [error, setError] = useState<string | null>(null); + + const fetchNegotiation = useCallback(async () => { + if (!isInitialized || !worker.isReady || !negotiationId) { + setLoading(false); + return; + } + + setLoading(true); + setError(null); + try { + const nextNegotiation = (await worker.getNegotiation(negotiationId)) as TradeNegotiationView | null; + setNegotiation(nextNegotiation); + + const playerIds = collectPlayerIds(nextNegotiation?.proposal, nextNegotiation?.counterOffer); + const resolvedPlayers = await Promise.all(playerIds.map(async (playerId) => worker.getPlayer(playerId) as Promise<PlayerDTO | null>)); + setPlayers(new Map(resolvedPlayers + .filter((player): player is PlayerDTO => player != null) + .map((player) => [player.id, player]))); + } catch (err) { + logger.error('Failed to fetch trade negotiation detail:', err); + setError('Trade negotiation unavailable'); + } finally { + setLoading(false); + } + }, [isInitialized, negotiationId, worker]); + + useEffect(() => { + void fetchNegotiation(); + }, [fetchNegotiation, day, season, phase]); + + const statusLabels = useMemo(() => { + if (!negotiation) return []; + if (negotiation.isComplete) return ['Closed']; + return [ + negotiation.canAccept ? 'Accept available' : null, + negotiation.canCounter ? 'Counter available' : null, + negotiation.canReject ? 'Reject available' : null, + ].filter((label): label is string => label != null); + }, [negotiation]); + + if (!loading && (error || !negotiation)) { + return ( + <PageShell> + <div className="space-y-6"> + <Link + to="/trade-negotiations" + className="inline-flex items-center gap-1.5 font-heading text-sm text-dynasty-muted hover:text-accent-primary" + > + <ArrowLeft className="h-4 w-4" /> + Back to Inbox + </Link> + + <EmptyStatePanel + icon={error ? AlertTriangle : Handshake} + title={error ?? 'Trade negotiation not found'} + description={error + ? 'The worker did not return this trade negotiation. Try again after the simulation worker is ready.' + : 'This negotiation is unavailable in the current save or has already been removed.'} + actionLabel={error ? 'Retry' : 'Back to Inbox'} + actionHref={error ? undefined : '/trade-negotiations'} + onAction={error ? () => void fetchNegotiation() : undefined} + /> + </div> + </PageShell> + ); + } + + return ( + <PageShell loading={loading || negotiation == null} skeleton={<TradeNegotiationDetailSkeleton />}> + {negotiation ? ( + <div className="space-y-6"> + <Link + to="/trade-negotiations" + className="inline-flex items-center gap-1.5 font-heading text-sm text-dynasty-muted hover:text-accent-primary" + > + <ArrowLeft className="h-4 w-4" /> + Back to Inbox + </Link> + + <section className="rounded-lg border border-dynasty-border bg-dynasty-surface p-5"> + <div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between"> + <div> + <div className="font-data text-[11px] uppercase tracking-[0.18em] text-dynasty-muted"> + Trade Negotiation + </div> + <h1 className="mt-2 font-brand text-4xl tracking-wide text-dynasty-textBright"> + {negotiation.teamName} + </h1> + <div className="mt-3 flex flex-wrap gap-2"> + <Badge variant="outline" className="font-data text-[10px] uppercase tracking-[0.16em]"> + {negotiation.teamAbbreviation} + </Badge> + <Badge variant={negotiation.isComplete ? 'outline' : 'info'} className="font-data text-[10px] uppercase tracking-[0.16em]"> + {phaseLabel(negotiation.phase)} + </Badge> + <Badge variant="outline" className="font-data text-[10px] uppercase tracking-[0.16em]"> + Round {Math.max(1, negotiation.roundsCompleted)} + </Badge> + <Badge variant="outline" className="font-data text-[10px] uppercase tracking-[0.16em]"> + Expires Day {negotiation.expiresAtDay} + </Badge> + </div> + </div> + + <div className="grid gap-2 lg:min-w-[18rem] lg:text-right"> + <div className="font-heading text-sm text-dynasty-textBright"> + {negotiation.isComplete ? 'This negotiation is closed.' : 'This negotiation is active.'} + </div> + <div className="flex flex-wrap gap-1.5 lg:justify-end"> + {statusLabels.map((label) => ( + <span + key={label} + className="rounded border border-accent-primary/40 bg-accent-primary/10 px-2 py-1 font-data text-[10px] uppercase tracking-[0.14em] text-accent-primary" + > + {label} + </span> + ))} + </div> + </div> + </div> + </section> + + <div className="grid gap-4 lg:grid-cols-2"> + <TradePackagePanel title="Proposal" tradePackage={negotiation.proposal} players={players} /> + <TradePackagePanel + title="Counter-Offer" + tradePackage={negotiation.counterOffer} + players={players} + emptyLabel="Awaiting counter" + /> + </div> + + <DialogueThread negotiation={negotiation} /> + + <section className="rounded-lg border border-dynasty-border bg-dynasty-surface p-4"> + {negotiation.isComplete ? ( + <div className="font-heading text-sm text-dynasty-muted">This negotiation is closed.</div> + ) : ( + <div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between"> + <div> + <h2 className="font-heading text-sm font-semibold text-dynasty-textBright">Action Area</h2> + <p className="mt-1 font-heading text-xs text-dynasty-muted"> + The Inbox stays read-only. Use the Trade Builder to accept, counter, or reject. + </p> + </div> + <div className="flex flex-wrap gap-2"> + <Link + to={`/trade?negotiationId=${negotiation.id}`} + className="focus-ring inline-flex items-center gap-2 rounded-md border border-accent-primary/40 bg-accent-primary/10 px-3 py-2 font-heading text-xs text-accent-primary transition-colors hover:bg-accent-primary/20" + > + Open in Trade Builder + <ArrowRight className="h-3.5 w-3.5" /> + </Link> + {(['Accept', 'Counter', 'Reject'] as const).map((label) => ( + <button + key={label} + type="button" + disabled + title="Use the Trade Builder to act on this negotiation." + className="cursor-not-allowed rounded-md border border-dynasty-border px-3 py-2 font-heading text-xs text-dynasty-muted opacity-50" + > + {label} + </button> + ))} + </div> + </div> + )} + </section> + </div> + ) : null} + </PageShell> + ); +} diff --git a/apps/web/src/features/trade-negotiations/routes/TradeNegotiationsInboxPage.test.tsx b/apps/web/src/features/trade-negotiations/routes/TradeNegotiationsInboxPage.test.tsx new file mode 100644 index 0000000..e09a9ca --- /dev/null +++ b/apps/web/src/features/trade-negotiations/routes/TradeNegotiationsInboxPage.test.tsx @@ -0,0 +1,180 @@ +import { act } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createRoot, type Root } from 'react-dom/client'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { toast } from 'sonner'; +import TradeNegotiationsInboxPage from './TradeNegotiationsInboxPage'; +import { useWorker } from '@/shared/hooks/useWorker'; +import { useGameStore } from '@/shared/hooks/useGameStore'; + +vi.mock('@/shared/hooks/useWorker', () => ({ + useWorker: vi.fn(), +})); + +vi.mock('@/shared/hooks/useGameStore', () => ({ + useGameStore: vi.fn(), +})); + +vi.mock('sonner', () => ({ + toast: { + error: vi.fn(), + }, +})); + +const mockedUseWorker = vi.mocked(useWorker); +const mockedUseGameStore = vi.mocked(useGameStore); + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const openNegotiation = { + id: 'neg-open', + teamId: 'bos', + teamName: 'Boston Noreasters', + teamAbbreviation: 'BOS', + phase: 'counter_1', + roundsCompleted: 2, + expiresAtDay: 103, + dialogue: [ + { speaker: 'rival_gm', text: 'Boston wants one more piece to keep this alive.', tone: 'firm' }, + { speaker: 'agm_advisor', text: 'The counter is close enough to keep talking.', tone: 'measured' }, + ], + proposal: { + offeringAssets: [{ type: 'player', playerId: 'nym-1' }], + requestingAssets: [{ type: 'player', playerId: 'bos-1' }], + }, + counterOffer: null, + isComplete: false, + canAccept: true, + canCounter: true, + canReject: true, +}; + +const closedNegotiation = { + ...openNegotiation, + id: 'neg-closed', + teamName: 'Seattle Drizzle', + teamAbbreviation: 'SEA', + phase: 'rejected', + expiresAtDay: 99, + isComplete: true, + canAccept: false, + canCounter: false, + canReject: false, +}; + +describe('TradeNegotiationsInboxPage', () => { + let container: HTMLDivElement; + let root: Root; + let getOpenNegotiations: ReturnType<typeof vi.fn>; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + getOpenNegotiations = vi.fn().mockResolvedValue([closedNegotiation, openNegotiation]); + + mockedUseWorker.mockReturnValue({ + isReady: true, + getOpenNegotiations, + } as unknown as ReturnType<typeof useWorker>); + + mockedUseGameStore.mockReturnValue({ + isInitialized: true, + day: 100, + season: 4, + phase: 'regular', + userTeamId: 'nym', + teamName: 'Tycoons', + playerCount: 780, + gamesPlayed: 99, + isSimulating: false, + setSeason: vi.fn(), + setDay: vi.fn(), + setPhase: vi.fn(), + setSimulating: vi.fn(), + setInitialized: vi.fn(), + setUserTeamId: vi.fn(), + updateFromSim: vi.fn(), + initializeGame: vi.fn(), + }); + }); + + afterEach(async () => { + await act(async () => { + root.unmount(); + }); + container.remove(); + vi.clearAllMocks(); + }); + + async function renderPage(initialEntry = '/trade-negotiations') { + await act(async () => { + root.render( + <MemoryRouter initialEntries={[initialEntry]}> + <Routes> + <Route path="/trade-negotiations" element={<TradeNegotiationsInboxPage />} /> + <Route path="/trade-negotiations/:negotiationId" element={<div data-testid="detail-route">Detail route</div>} /> + </Routes> + </MemoryRouter>, + ); + await Promise.resolve(); + await Promise.resolve(); + }); + } + + it('renders open negotiations before closed negotiations with urgency metadata', async () => { + await renderPage(); + + const content = container.textContent ?? ''; + expect(content).toContain('Trade Negotiations Inbox'); + expect(content).toContain('Boston Noreasters'); + expect(content).toContain('BOS'); + expect(content).toContain('counter 1'); + expect(content).toContain('Round 2'); + expect(content).toContain('Expires in 3 days'); + expect(content).toContain('Accept'); + expect(content).toContain('Counter'); + expect(content).toContain('Reject'); + expect(content).toContain('The counter is close enough to keep talking.'); + expect(content.indexOf('Boston Noreasters')).toBeLessThan(content.indexOf('Seattle Drizzle')); + expect(getOpenNegotiations).toHaveBeenCalledTimes(1); + }); + + it('renders an empty state linking back to the Trade Hub', async () => { + getOpenNegotiations.mockResolvedValueOnce([]); + + await renderPage(); + + expect(container.textContent).toContain('No open trade negotiations'); + const link = Array.from(container.querySelectorAll('a[href="/trade"]')).find((candidate) => + candidate.textContent?.includes('Visit Trade Hub'), + ); + expect(link?.textContent).toContain('Visit Trade Hub'); + }); + + it('navigates to the detail route when a negotiation row is clicked', async () => { + await renderPage(); + + const row = container.querySelector('a[href="/trade-negotiations/neg-open"]') as HTMLAnchorElement | null; + expect(row).toBeTruthy(); + + await act(async () => { + row?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, button: 0 })); + await Promise.resolve(); + }); + + expect(container.querySelector('[data-testid="detail-route"]')?.textContent).toContain('Detail route'); + }); + + it('shows an inline error and toast when the worker call fails', async () => { + getOpenNegotiations.mockRejectedValueOnce(new Error('worker unavailable')); + + await renderPage(); + + expect(toast.error).toHaveBeenCalledWith('Trade negotiations could not be loaded.'); + expect(container.textContent).toContain('Trade negotiations unavailable'); + }); +}); diff --git a/apps/web/src/features/trade-negotiations/routes/TradeNegotiationsInboxPage.tsx b/apps/web/src/features/trade-negotiations/routes/TradeNegotiationsInboxPage.tsx new file mode 100644 index 0000000..230d769 --- /dev/null +++ b/apps/web/src/features/trade-negotiations/routes/TradeNegotiationsInboxPage.tsx @@ -0,0 +1,267 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { AlertTriangle, ArrowRight, Handshake, MessageSquareText } from 'lucide-react'; +import { toast } from 'sonner'; +import { Badge, Skeleton } from '@mbd/ui'; +import { EmptyStatePanel } from '@/shared/components/EmptyStatePanel'; +import { PageShell } from '@/shared/components/PageShell'; +import { useGameStore } from '@/shared/hooks/useGameStore'; +import { useWorker } from '@/shared/hooks/useWorker'; +import { logger } from '@/shared/lib/logger'; +import type { TradeNegotiationView } from '@/workers/sim.worker.trade'; + +function TradeNegotiationsSkeleton() { + return ( + <div className="space-y-5" data-testid="trade-negotiations-skeleton"> + <div className="space-y-3"> + <Skeleton className="h-10 w-72" /> + <Skeleton className="h-4 w-[34rem] max-w-full" /> + </div> + <div className="grid gap-3 md:grid-cols-3"> + <Skeleton className="h-24 rounded-lg" /> + <Skeleton className="h-24 rounded-lg" /> + <Skeleton className="h-24 rounded-lg" /> + </div> + <Skeleton className="h-36 rounded-lg" /> + <Skeleton className="h-36 rounded-lg" /> + </div> + ); +} + +function phaseLabel(phase: TradeNegotiationView['phase']): string { + return phase.replace(/_/g, ' '); +} + +function expiryLabel(expiresAtDay: number, currentDay: number): string { + const days = expiresAtDay - currentDay; + if (days < 0) return 'Expired'; + if (days === 0) return 'Deadline today'; + if (days === 1) return 'Expires in 1 day'; + return `Expires in ${days} days`; +} + +function actionLabels(negotiation: TradeNegotiationView): string[] { + if (negotiation.isComplete) return ['Closed']; + + const actions = [ + negotiation.canAccept ? 'Accept' : null, + negotiation.canCounter ? 'Counter' : null, + negotiation.canReject ? 'Reject' : null, + ].filter((label): label is string => label != null); + + return actions.length > 0 ? actions : ['Review']; +} + +function compareNegotiations(left: TradeNegotiationView, right: TradeNegotiationView): number { + if (left.isComplete !== right.isComplete) { + return left.isComplete ? 1 : -1; + } + + return left.expiresAtDay - right.expiresAtDay + || left.teamName.localeCompare(right.teamName) + || left.id.localeCompare(right.id); +} + +function latestDialogueText(negotiation: TradeNegotiationView): string { + const latest = negotiation.dialogue.at(-1); + return latest?.text ?? 'No dialogue has been logged for this negotiation yet.'; +} + +function TradeNegotiationRow({ + negotiation, + currentDay, +}: { + negotiation: TradeNegotiationView; + currentDay: number; +}) { + return ( + <Link + to={`/trade-negotiations/${negotiation.id}`} + className="focus-ring block rounded-lg border border-dynasty-border bg-dynasty-surface p-4 transition-colors hover:border-dynasty-muted hover:bg-dynasty-elevated/70" + > + <div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between"> + <div className="min-w-0"> + <div className="flex flex-wrap items-center gap-2"> + <h2 className="font-heading text-lg font-semibold leading-6 text-dynasty-textBright"> + {negotiation.teamName} + </h2> + <Badge variant="outline" className="font-data text-[10px] uppercase tracking-[0.16em]"> + {negotiation.teamAbbreviation} + </Badge> + <Badge variant={negotiation.isComplete ? 'outline' : 'info'} className="font-data text-[10px] uppercase tracking-[0.16em]"> + {phaseLabel(negotiation.phase)} + </Badge> + </div> + + <p className="mt-3 flex max-w-4xl items-start gap-2 font-heading text-sm leading-6 text-dynasty-text"> + <MessageSquareText className="mt-1 h-4 w-4 shrink-0 text-dynasty-muted" /> + <span>{latestDialogueText(negotiation)}</span> + </p> + </div> + + <div className="grid min-w-[14rem] gap-2 text-left lg:text-right"> + <div className="font-data text-xs uppercase tracking-[0.16em] text-dynasty-muted"> + Round {Math.max(1, negotiation.roundsCompleted)} + </div> + <div className="font-heading text-sm text-dynasty-textBright"> + {expiryLabel(negotiation.expiresAtDay, currentDay)} + </div> + <div className="flex flex-wrap gap-1.5 lg:justify-end"> + {actionLabels(negotiation).map((label) => ( + <span + key={`${negotiation.id}-${label}`} + className={[ + 'rounded border px-2 py-1 font-data text-[10px] uppercase tracking-[0.14em]', + label === 'Closed' + ? 'border-dynasty-border text-dynasty-muted' + : 'border-accent-primary/40 bg-accent-primary/10 text-accent-primary', + ].join(' ')} + > + {label} + </span> + ))} + </div> + </div> + </div> + + <div className="mt-3 inline-flex items-center gap-1 font-heading text-xs text-dynasty-muted"> + View detail + <ArrowRight className="h-3.5 w-3.5" /> + </div> + </Link> + ); +} + +export default function TradeNegotiationsInboxPage() { + const worker = useWorker(); + const { isInitialized, day, season, phase } = useGameStore(); + const [negotiations, setNegotiations] = useState<TradeNegotiationView[]>([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState<string | null>(null); + + const fetchNegotiations = useCallback(async () => { + if (!isInitialized || !worker.isReady) { + setNegotiations([]); + setLoading(false); + return; + } + + setLoading(true); + setError(null); + try { + const nextNegotiations = await worker.getOpenNegotiations(); + setNegotiations([...(nextNegotiations ?? [])] as TradeNegotiationView[]); + } catch (err) { + logger.error('Failed to fetch trade negotiations:', err); + toast.error('Trade negotiations could not be loaded.'); + setError('Trade negotiations unavailable'); + } finally { + setLoading(false); + } + }, [isInitialized, worker]); + + useEffect(() => { + void fetchNegotiations(); + }, [fetchNegotiations, season, day, phase]); + + const sortedNegotiations = useMemo( + () => [...negotiations].sort(compareNegotiations), + [negotiations], + ); + + const openCount = negotiations.filter((negotiation) => !negotiation.isComplete).length; + const closedCount = negotiations.length - openCount; + const nextExpiry = sortedNegotiations.find((negotiation) => !negotiation.isComplete); + + return ( + <PageShell loading={loading} skeleton={<TradeNegotiationsSkeleton />}> + <div className="space-y-5"> + <div> + <h1 className="font-brand text-4xl tracking-wide text-dynasty-textBright"> + Trade Negotiations Inbox + </h1> + <p className="mt-1 max-w-3xl font-heading text-sm leading-6 text-dynasty-muted"> + Active trade-room conversations ready for review before they expire. + </p> + </div> + + <div className="grid gap-3 md:grid-cols-3"> + <div className="rounded-lg border border-dynasty-border bg-dynasty-surface p-4"> + <div className="flex items-center gap-2 font-heading text-xs uppercase tracking-[0.16em] text-dynasty-muted"> + <Handshake className="h-4 w-4" /> + Open + </div> + <div className="mt-2 font-data text-3xl text-accent-primary">{openCount}</div> + <div className="mt-1 font-heading text-xs text-dynasty-muted">negotiations active</div> + </div> + <div className="rounded-lg border border-dynasty-border bg-dynasty-surface p-4"> + <div className="font-heading text-xs uppercase tracking-[0.16em] text-dynasty-muted">Closed</div> + <div className="mt-2 font-data text-3xl text-dynasty-textBright">{closedCount}</div> + <div className="mt-1 font-heading text-xs text-dynasty-muted">resolved or expired</div> + </div> + <div className="rounded-lg border border-dynasty-border bg-dynasty-surface p-4"> + <div className="font-heading text-xs uppercase tracking-[0.16em] text-dynasty-muted">Next Deadline</div> + <div className="mt-2 font-heading text-xl text-dynasty-textBright"> + {nextExpiry ? expiryLabel(nextExpiry.expiresAtDay, day) : 'No deadline'} + </div> + <div className="mt-1 font-heading text-xs text-dynasty-muted"> + {nextExpiry ? nextExpiry.teamAbbreviation : 'start a trade call'} + </div> + </div> + </div> + + <section className="rounded-lg border border-dynasty-border bg-dynasty-surface p-4"> + <div className="flex flex-col gap-3 border-b border-dynasty-border pb-4 md:flex-row md:items-end md:justify-between"> + <div> + <h2 className="font-heading text-sm font-semibold text-dynasty-textBright"> + Current Trade Calls + </h2> + <p className="mt-1 font-heading text-xs text-dynasty-muted"> + Open negotiations sort first, then by soonest expiration. + </p> + </div> + <Link + to="/trade" + className="focus-ring inline-flex items-center gap-1 rounded-md border border-dynasty-border px-3 py-1.5 font-heading text-xs text-dynasty-muted transition-colors hover:border-dynasty-muted hover:text-dynasty-text" + > + Trade Hub + <ArrowRight className="h-3.5 w-3.5" /> + </Link> + </div> + + {error ? ( + <div className="mt-4"> + <EmptyStatePanel + icon={AlertTriangle} + title={error} + description="The worker did not return the trade negotiation queue. Try again after the simulation worker is ready." + actionLabel="Retry" + onAction={() => void fetchNegotiations()} + /> + </div> + ) : sortedNegotiations.length === 0 ? ( + <div className="mt-4"> + <EmptyStatePanel + icon={Handshake} + title="No open trade negotiations" + description="Visit the Trade Hub to start one and track every counter here." + actionLabel="Visit Trade Hub" + actionHref="/trade" + /> + </div> + ) : ( + <div className="mt-4 space-y-3"> + {sortedNegotiations.map((negotiation) => ( + <TradeNegotiationRow + key={negotiation.id} + negotiation={negotiation} + currentDay={day} + /> + ))} + </div> + )} + </section> + </div> + </PageShell> + ); +} diff --git a/apps/web/src/features/trade/components/DeadlineDramaPanel.tsx b/apps/web/src/features/trade/components/DeadlineDramaPanel.tsx index 9722518..18ca42d 100644 --- a/apps/web/src/features/trade/components/DeadlineDramaPanel.tsx +++ b/apps/web/src/features/trade/components/DeadlineDramaPanel.tsx @@ -9,6 +9,7 @@ import { ChevronUp, Zap, } from 'lucide-react'; +import { Link } from 'react-router-dom'; import { useWorker } from '@/shared/hooks/useWorker'; // --------------------------------------------------------------------------- @@ -172,7 +173,12 @@ function BiddingWarCard({ war }: { war: ActiveBiddingWar }) { )} </div> <div className="mt-3 font-heading text-base font-semibold text-accent-warning"> - {war.targetPlayerName} + <Link + to={`/players/${war.targetPlayerId}`} + className="font-heading font-medium text-dynasty-text hover:text-accent-primary" + > + {war.targetPlayerName} + </Link> </div> <div className="mt-3 space-y-2"> {sortedRounds.map((round) => { diff --git a/apps/web/src/features/trade/routes/TradePage.test.tsx b/apps/web/src/features/trade/routes/TradePage.test.tsx index 962a956..f497389 100644 --- a/apps/web/src/features/trade/routes/TradePage.test.tsx +++ b/apps/web/src/features/trade/routes/TradePage.test.tsx @@ -14,6 +14,14 @@ vi.mock('@/shared/hooks/useGameStore', () => ({ useGameStore: vi.fn(), })); +const toastMock = vi.hoisted(() => ({ + error: vi.fn(), +})); + +vi.mock('sonner', () => ({ + toast: toastMock, +})); + const mockedUseWorker = vi.mocked(useWorker); const mockedUseGameStore = vi.mocked(useGameStore); ( @@ -350,6 +358,30 @@ function createWorkerMock() { tradeExecuted: false, negotiation: null, }), + getNegotiation: vi.fn().mockResolvedValue({ + id: 'neg-deep', + teamId: 'bos', + teamName: 'Boston Noreasters', + teamAbbreviation: 'BOS', + phase: 'counter_2', + roundsCompleted: 2, + expiresAtDay: 99, + dialogue: [ + { speaker: 'rival_gm', text: 'Inbox deep link loaded into the trade builder.', tone: 'firm' }, + ], + proposal: { + offeringAssets: [{ type: 'player', playerId: 'nyy-1' }], + requestingAssets: [{ type: 'player', playerId: 'bos-1' }], + }, + counterOffer: { + offeringAssets: [{ type: 'player', playerId: 'nyy-1' }], + requestingAssets: [{ type: 'player', playerId: 'bos-1' }], + }, + isComplete: false, + canAccept: true, + canCounter: true, + canReject: true, + }), resolveNegotiation: vi.fn().mockResolvedValue({ success: true, decision: 'accepted', @@ -480,6 +512,74 @@ describe('TradePage', () => { expect(container.textContent).toContain('Orlando Thunder sent Drew Example to Charlotte Hornets for Chris Sample.'); }); + it('renders trade player names as profile links', async () => { + mockedUseGameStore.mockReturnValue({ + season: 4, + day: 95, + phase: 'regular', + isInitialized: true, + userTeamId: 'nym', + teamName: 'Tycoons', + playerCount: 780, + gamesPlayed: 95, + isSimulating: false, + setSeason: vi.fn(), + setDay: vi.fn(), + setPhase: vi.fn(), + setSimulating: vi.fn(), + setInitialized: vi.fn(), + setUserTeamId: vi.fn(), + updateFromSim: vi.fn(), + initializeGame: vi.fn(), + }); + + mockedUseWorker.mockReturnValue(createWorkerMock() as unknown as ReturnType<typeof useWorker>); + + await renderPage(); + + const romanLinks = Array.from(container.querySelectorAll<HTMLAnchorElement>('a[href="/players/bos-1"]')); + const volpeLinks = Array.from(container.querySelectorAll<HTMLAnchorElement>('a[href="/players/nyy-1"]')); + + expect(romanLinks.some((link) => link.textContent?.includes('Roman Anthony'))).toBe(true); + expect(volpeLinks.some((link) => link.textContent?.includes('Anthony Volpe'))).toBe(true); + }); + + it('loads a trade negotiation from the query string into the builder', async () => { + mockedUseGameStore.mockReturnValue({ + season: 4, + day: 95, + phase: 'regular', + isInitialized: true, + userTeamId: 'nym', + teamName: 'Tycoons', + playerCount: 780, + gamesPlayed: 95, + isSimulating: false, + setSeason: vi.fn(), + setDay: vi.fn(), + setPhase: vi.fn(), + setSimulating: vi.fn(), + setInitialized: vi.fn(), + setUserTeamId: vi.fn(), + updateFromSim: vi.fn(), + initializeGame: vi.fn(), + }); + + const worker = createWorkerMock(); + mockedUseWorker.mockReturnValue(worker as unknown as ReturnType<typeof useWorker>); + + await renderPage('/trade?negotiationId=neg-deep'); + + expect(worker.getNegotiation).toHaveBeenCalledWith('neg-deep'); + expect(container.textContent).toContain('Negotiation Round'); + expect(container.textContent).toContain('Inbox deep link loaded into the trade builder.'); + expect(container.textContent).toContain('Send Negotiation Counter'); + const volpeLinks = Array.from(container.querySelectorAll<HTMLAnchorElement>('a[href="/players/nyy-1"]')); + const romanLinks = Array.from(container.querySelectorAll<HTMLAnchorElement>('a[href="/players/bos-1"]')); + expect(volpeLinks.some((link) => link.textContent?.includes('A. Volpe'))).toBe(true); + expect(romanLinks.some((link) => link.textContent?.includes('R. Anthony'))).toBe(true); + }); + it('renders the closed-state banner after the trade deadline', async () => { mockedUseGameStore.mockReturnValue({ season: 4, diff --git a/apps/web/src/features/trade/routes/TradePage.tsx b/apps/web/src/features/trade/routes/TradePage.tsx index 8b5e7a7..0c87820 100644 --- a/apps/web/src/features/trade/routes/TradePage.tsx +++ b/apps/web/src/features/trade/routes/TradePage.tsx @@ -12,7 +12,8 @@ import { X, } from 'lucide-react'; import { Badge, Skeleton } from '@mbd/ui'; -import { useSearchParams } from 'react-router-dom'; +import { Link, useSearchParams } from 'react-router-dom'; +import { toast } from 'sonner'; import { EmptyStatePanel } from '@/shared/components/EmptyStatePanel'; import { PageShell } from '@/shared/components/PageShell'; import { ProgressFill } from '@/shared/components/ProgressFill'; @@ -152,6 +153,7 @@ const EMPTY_TRADE_ASSET_INVENTORY: TradeAssetInventoryView = { const ALL_TEAMS = TEAMS.map((t) => ({ id: t.id, name: t.name, abbr: t.abbreviation })); const MULTI_TEAM_ROLE_ORDER: MultiTeamRole[] = ['initiator', 'partner', 'facilitator', 'facilitator']; +const PLAYER_PROFILE_LINK_CLASS = 'font-heading font-medium text-dynasty-text hover:text-accent-primary'; function normalizeMultiTeamRoles(lanes: MultiTeamLaneState[]): MultiTeamLaneState[] { return lanes.map((lane, index) => ({ @@ -372,6 +374,39 @@ function buildTradeAssetLabel( } } +function tradeAssetViewPlayerId(asset: TradeAssetView): string | null { + return asset.playerId ?? (asset.asset.type === 'player' ? asset.asset.playerId : null); +} + +function renderTradeAssetViewLabel(asset: TradeAssetView) { + const playerId = tradeAssetViewPlayerId(asset); + if (!playerId) { + return asset.label; + } + + return ( + <Link to={`/players/${playerId}`} className={PLAYER_PROFILE_LINK_CLASS}> + {asset.label} + </Link> + ); +} + +function renderTradeAssetLabel( + asset: TradeAsset, + resolvePlayer: (playerId: string) => PlayerDTO | undefined, +) { + const label = buildTradeAssetLabel(asset, resolvePlayer); + if (asset.type !== 'player') { + return label; + } + + return ( + <Link to={`/players/${asset.playerId}`} className={PLAYER_PROFILE_LINK_CLASS}> + {label} + </Link> + ); +} + function tradeAssetValue( asset: TradeAsset, currentSeason: number, @@ -446,7 +481,13 @@ function PlayerRow({ } ${selected ? 'bg-accent-primary/15' : disabled ? '' : 'hover:bg-dynasty-elevated'}`} > <td className="px-3 py-1.5 font-heading font-medium text-dynasty-text"> - {player.firstName} {player.lastName} + <Link + to={`/players/${player.id}`} + onClick={(event) => event.stopPropagation()} + className={PLAYER_PROFILE_LINK_CLASS} + > + {player.firstName} {player.lastName} + </Link> </td> <td className="px-2 py-1.5 font-data text-dynasty-muted">{player.position}</td> <td className="px-2 py-1.5 text-right font-data text-dynasty-text">{player.displayRating}</td> @@ -524,19 +565,23 @@ function MultiTeamLaneCard({ }`} > <div className="flex items-center justify-between gap-3"> - <button - type="button" - onClick={() => onTogglePlayer(player.id)} - disabled={disabled} - className="focus-ring flex-1 text-left" - > - <div className="font-heading text-sm text-dynasty-text"> + <div className="min-w-0 flex-1"> + <Link + to={`/players/${player.id}`} + className={PLAYER_PROFILE_LINK_CLASS} + > {player.firstName} {player.lastName} - </div> - <div className="mt-1 font-data text-[11px] uppercase tracking-[0.18em] text-dynasty-muted"> + </Link> + <button + type="button" + onClick={() => onTogglePlayer(player.id)} + disabled={disabled} + className="focus-ring mt-1 block text-left font-data text-[11px] uppercase tracking-[0.18em] text-dynasty-muted" + > + <span className="sr-only">{player.firstName} {player.lastName}</span> {player.position} · {player.displayRating} OVR · Age {player.age} - </div> - </button> + </button> + </div> <Badge className={assignment ? 'border-accent-primary/40 bg-accent-primary/10 text-accent-primary' : 'border-dynasty-border bg-dynasty-elevated text-dynasty-muted'}> {assignment ? 'In Framework' : 'Available'} </Badge> @@ -639,7 +684,7 @@ function OfferCard({ <div className="mt-2 space-y-1"> {offer.offeringAssets.map((asset) => ( <p key={asset.key} className="font-data text-xs text-dynasty-text"> - {asset.label} · {asset.detail} + {renderTradeAssetViewLabel(asset)} · {asset.detail} </p> ))} </div> @@ -649,7 +694,7 @@ function OfferCard({ <div className="mt-2 space-y-1"> {offer.requestingAssets.map((asset) => ( <p key={asset.key} className="font-data text-xs text-dynasty-text"> - {asset.label} · {asset.detail} + {renderTradeAssetViewLabel(asset)} · {asset.detail} </p> ))} </div> @@ -762,7 +807,7 @@ function HistoryCard({ trade }: { trade: TradeHistoryView }) { export default function TradePage() { const worker = useWorker(); - const [searchParams] = useSearchParams(); + const [searchParams, setSearchParams] = useSearchParams(); const { getTeamRoster, getTradeHistory, @@ -774,6 +819,7 @@ export default function TradePage() { generateConditionalClause, startNegotiation, advanceNegotiation, + getNegotiation, resolveNegotiation, proposeMultiTeam, executeMultiTeamTrade, @@ -812,10 +858,12 @@ export default function TradePage() { const [multiTeamSubmitting, setMultiTeamSubmitting] = useState(false); const [proposing, setProposing] = useState(false); const [activeCounterOfferId, setActiveCounterOfferId] = useState<string | null>(null); + const [loadedNegotiationId, setLoadedNegotiationId] = useState<string | null>(null); const [loading, setLoading] = useState(true); const workerReady = worker.isReady; const preselectedPlayerId = searchParams.get('playerId'); + const negotiationIdParam = searchParams.get('negotiationId'); const otherTeams = ALL_TEAMS.filter((team) => team.id !== userTeamId); const tradeMarketOpen = phase === 'regular' && ( (deadlineState?.deadlineMode ?? false) || ((deadlineState?.daysUntilDeadline ?? -1) > 0) @@ -1250,6 +1298,53 @@ export default function TradePage() { setRequestingIFAAmount(ifaAmountFromAssets(negotiation.proposal.requestingAssets)); }, []); + useEffect(() => { + if (!negotiationIdParam || !isInitialized || !workerReady || loadedNegotiationId === negotiationIdParam) { + return; + } + + let cancelled = false; + + const loadNegotiation = async () => { + const negotiation = await getNegotiation(negotiationIdParam) as TradeNegotiationView | null; + if (cancelled) { + return; + } + + if (!negotiation || negotiation.expiresAtDay < day || negotiation.isComplete) { + toast.error('Trade negotiation could not be loaded in the Trade Builder.'); + const nextParams = new URLSearchParams(searchParams); + nextParams.delete('negotiationId'); + setSearchParams(nextParams, { replace: true }); + setLoadedNegotiationId(null); + return; + } + + setSelectedTeam(negotiation.teamId); + setActiveCounterOfferId(null); + setActiveNegotiation(negotiation); + applyNegotiationToBuilder(negotiation); + setTradeResult(null); + setLoadedNegotiationId(negotiationIdParam); + }; + + void loadNegotiation(); + + return () => { + cancelled = true; + }; + }, [ + applyNegotiationToBuilder, + day, + getNegotiation, + isInitialized, + loadedNegotiationId, + negotiationIdParam, + searchParams, + setSearchParams, + workerReady, + ]); + const offeringAssets = useMemo(() => { const assets: TradeAsset[] = [...offering.map(playerAsset), ...offeringPicks]; const poolAmount = parsePoolAmount(offeringIFAAmount); @@ -1277,6 +1372,7 @@ export default function TradePage() { ? draftPickKey(asset) : `ifa:${asset.amount.toFixed(2)}`, label: buildTradeAssetLabel(asset, playerById), + playerId: asset.type === 'player' ? asset.playerId : null, })), [offeringAssets, playerById], ); @@ -1290,6 +1386,7 @@ export default function TradePage() { ? draftPickKey(asset) : `ifa:${asset.amount.toFixed(2)}`, label: buildTradeAssetLabel(asset, playerById), + playerId: asset.type === 'player' ? asset.playerId : null, })), [playerById, requestingAssets], ); @@ -1829,7 +1926,7 @@ export default function TradePage() { <div className="mt-2 space-y-1"> {activeNegotiation.proposal.offeringAssets.map((asset) => ( <div key={`offer-${buildTradeAssetLabel(asset, playerById)}`} className="font-heading text-xs text-dynasty-text"> - {buildTradeAssetLabel(asset, playerById)} + {renderTradeAssetLabel(asset, playerById)} </div> ))} </div> @@ -1839,7 +1936,7 @@ export default function TradePage() { <div className="mt-2 space-y-1"> {activeNegotiation.proposal.requestingAssets.map((asset) => ( <div key={`request-${buildTradeAssetLabel(asset, playerById)}`} className="font-heading text-xs text-dynasty-text"> - {buildTradeAssetLabel(asset, playerById)} + {renderTradeAssetLabel(asset, playerById)} </div> ))} </div> @@ -2072,7 +2169,11 @@ export default function TradePage() { key={asset.key} className="rounded border border-dynasty-border bg-dynasty-surface px-2 py-1 font-data text-xs text-dynasty-text" > - {asset.label} + {asset.playerId ? ( + <Link to={`/players/${asset.playerId}`} className={PLAYER_PROFILE_LINK_CLASS}> + {asset.label} + </Link> + ) : asset.label} </span> ); }) @@ -2096,7 +2197,11 @@ export default function TradePage() { key={asset.key} className="rounded border border-dynasty-border bg-dynasty-surface px-2 py-1 font-data text-xs text-dynasty-text" > - {asset.label} + {asset.playerId ? ( + <Link to={`/players/${asset.playerId}`} className={PLAYER_PROFILE_LINK_CLASS}> + {asset.label} + </Link> + ) : asset.label} </span> ); }) @@ -2283,7 +2388,11 @@ export default function TradePage() { const player = multiTeamRosters[team.teamId]?.find((candidate) => candidate.id === playerId); return ( <span key={`${team.teamId}-send-${playerId}`} className="rounded border border-dynasty-border bg-dynasty-elevated px-2 py-1 font-data text-xs text-dynasty-text"> - {player ? `${player.firstName} ${player.lastName}` : playerId} + {player ? ( + <Link to={`/players/${player.id}`} className={PLAYER_PROFILE_LINK_CLASS}> + {player.firstName} {player.lastName} + </Link> + ) : playerId} </span> ); }) @@ -2296,11 +2405,18 @@ export default function TradePage() { {team.receivingPlayerIds.length === 0 ? ( <span className="font-heading text-xs text-dynasty-muted">No inbound players yet.</span> ) : ( - team.receivingPlayerIds.map((playerId) => ( - <span key={`${team.teamId}-receive-${playerId}`} className="rounded border border-dynasty-border bg-dynasty-elevated px-2 py-1 font-data text-xs text-dynasty-text"> - {multiTeamMovedPlayers.find((candidate) => candidate.playerId === playerId)?.label ?? playerId} - </span> - )) + team.receivingPlayerIds.map((playerId) => { + const movedPlayer = multiTeamMovedPlayers.find((candidate) => candidate.playerId === playerId); + return ( + <span key={`${team.teamId}-receive-${playerId}`} className="rounded border border-dynasty-border bg-dynasty-elevated px-2 py-1 font-data text-xs text-dynasty-text"> + {movedPlayer ? ( + <Link to={`/players/${movedPlayer.playerId}`} className={PLAYER_PROFILE_LINK_CLASS}> + {movedPlayer.label} + </Link> + ) : playerId} + </span> + ); + }) )} </div> </div> diff --git a/apps/web/src/shared/hooks/useWorker.ts b/apps/web/src/shared/hooks/useWorker.ts index d52adcd..275a0fd 100644 --- a/apps/web/src/shared/hooks/useWorker.ts +++ b/apps/web/src/shared/hooks/useWorker.ts @@ -393,6 +393,10 @@ export function useWorker() { async (playerId: string) => api.getPlayerProfileView(playerId), [api], ); + const getPlayerTradeValue = useCallback( + async (playerId: string) => api.getPlayerTradeValue(playerId), + [api], + ); const getPlayerMoments = useCallback( async (playerId: string) => api.getPlayerMoments(playerId), [api], @@ -908,7 +912,7 @@ export function useWorker() { simPlayoffGame, simPlayoffSeries, simPlayoffRound, simRemainingPlayoffs, getState, exportSnapshot, importSnapshot, createWhatIfBranch, deleteWhatIfBranch, archiveOldSeasons, pruneStaleData, - getStandings, getScheduleView, getTeamRoster, getFullRoster, getPlayer, getPlayerProfileView, getPlayerMoments, getTeamMoments, getRecentLeagueMoments, getRecentTeamMoments, getThisWeekInHistory, getPlayerArcsOfSeason, getNicknamesForPlayer, getRelationships, getRelationshipWith, getPlayerStoryArcs, getMilestoneAlerts, getAdvancedStats, + getStandings, getScheduleView, getTeamRoster, getFullRoster, getPlayer, getPlayerProfileView, getPlayerTradeValue, getPlayerMoments, getTeamMoments, getRecentLeagueMoments, getRecentTeamMoments, getThisWeekInHistory, getPlayerArcsOfSeason, getNicknamesForPlayer, getRelationships, getRelationshipWith, getPlayerStoryArcs, getMilestoneAlerts, getAdvancedStats, getLeagueLeaders, getPlayoffBracket, getHallOfFame, getFranchiseTimeline, getDynastyScore, getBranches, compareWithBranch, getAchievements, getPerformanceDiagnostics, getDashboardSummary, getGamePlayByPlay, getRecentGameRecaps, getSeasonRecap, getOffseasonHeadline, getMonthlyPulse, getCurrentLeagueEvents, getLeagueEventHistory, getCeremonyState, getTickerFeed, getSeasonFlowState, getScoutingStaff, scoutPlayerReport, getIFAPool, scoutIFAPlayer, signIFAPlayer, tradeIFAPoolSpace, getDraftClass, getDraftCommentary, getDraftProspectReaction, getDraftPostDraftGrades, startDraft, makeDraftPick, scoutDraftPlayer, toggleDraftBigBoard, signDraftPick, simulateRemainingDraft,