From b1ddb1154bed99dc4a2386b5113e2dda09d5b0a2 Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sat, 3 Oct 2026 02:08:43 -0400 Subject: [PATCH] Step 9.2: add "while you were away" summary Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 5 ++ apps/web/src/App.tsx | 28 ++++---- apps/web/src/components/AwaySummary.tsx | 67 +++++++++++++++++++ apps/web/test/away-summary.test.tsx | 87 +++++++++++++++++++++++++ memory-bank/progress.md | 2 +- 5 files changed, 176 insertions(+), 13 deletions(-) create mode 100644 apps/web/src/components/AwaySummary.tsx create mode 100644 apps/web/test/away-summary.test.tsx diff --git a/DECISIONS.md b/DECISIONS.md index 18d6127..45c86ac 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -402,3 +402,8 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **The player-state query is fresh for 10 seconds** (`staleTime`), so screens that read it do not each refetch on mount. Returning to the app (`refetchOnWindowFocus: "always"`), arrivals and actions still refetch immediately. - **The arrival watcher runs once, in `App`,** not in every component that reads player state. - **Shared screen helpers:** `useLoadedPlayer()` (screens render only after state has loaded), `useGameAction(route)` (call an endpoint, then invalidate player state and boards), `Countdown` (live, from the synced clock), and `src/format.ts` (credits, durations, names). + +## Step 9.2 + +- **The summary dialog sits above all screens** (rendered by `App`). Dismissing hides it immediately (local state) and sends `acknowledgeSummaries` for the listed ids; the follow-up state refetch confirms it. +- **Tutorial runs are shown as "Company loaner"**, since the loaner is not in the ships list. diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 0ebd555..35c1653 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -2,6 +2,7 @@ import { useEffect, type ReactNode } from "react"; import { Route, Routes } from "react-router"; import { useApi } from "./api/context"; import { startClockTicker, syncClock } from "./clock/clock"; +import { AwaySummary } from "./components/AwaySummary"; import { formatCredits } from "./format"; import { Layout } from "./layout/Layout"; import { SCREENS } from "./routes"; @@ -45,17 +46,20 @@ export function App() { if (state.data === null) return ; return ( - - }> - {SCREENS.map((screen) => ( - } - /> - ))} - - + <> + + }> + {SCREENS.map((screen) => ( + } + /> + ))} + + + + ); } diff --git a/apps/web/src/components/AwaySummary.tsx b/apps/web/src/components/AwaySummary.tsx new file mode 100644 index 0000000..eb2df3d --- /dev/null +++ b/apps/web/src/components/AwaySummary.tsx @@ -0,0 +1,67 @@ +import { useState } from "react"; +import { formatCredits, locationName } from "../format"; +import { useGameAction, useLoadedPlayer } from "../state/actions"; + +/** "While you were away": deliveries resolved since the player last looked (Step 9.2). */ +export function AwaySummary() { + const state = useLoadedPlayer(); + const acknowledge = useGameAction("acknowledgeSummaries"); + // Hide as soon as the player dismisses; the refetch clears the list for real. + const [dismissed, setDismissed] = useState>(new Set()); + + const summaries = state.summaries.filter((s) => !dismissed.has(s.dispatchId)); + if (summaries.length === 0) return null; + + const shipName = (shipId: string, tutorial: boolean) => + tutorial ? "Company loaner" : (state.ships.find((s) => s.id === shipId)?.name ?? "A ship"); + + const dismiss = () => { + const ids = summaries.map((s) => s.dispatchId); + setDismissed(new Set([...dismissed, ...ids])); + acknowledge.mutate({ dispatchIds: ids }); + }; + + return ( +
+
+

+ While you were away +

+
    + {summaries.map((s) => ( +
  • +
    {shipName(s.shipId, s.tutorial)}
    +
    + {locationName(s.originId)} → {locationName(s.destinationId)} +
    +
    + = 0 ? "text-emerald-400" : "text-red-400"}> + {s.credits >= 0 ? "+" : ""} + {formatCredits(s.credits)} + + +{s.pilotXp} XP +
    + {s.discoveredLocationId && ( +
    + Discovered {locationName(s.discoveredLocationId)}! +
    + )} +
  • + ))} +
+ +
+
+ ); +} diff --git a/apps/web/test/away-summary.test.tsx b/apps/web/test/away-summary.test.tsx new file mode 100644 index 0000000..588aecd --- /dev/null +++ b/apps/web/test/away-summary.test.tsx @@ -0,0 +1,87 @@ +import type { ResolutionSummary, Ship } from "@deadhead/schema"; +import { screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import { fakeApi, renderApp, samplePlayerState } from "./render"; + +const ship: Ship = { + id: "s1", + modelId: "courier", + name: "Dusty Mule", + status: "idle", + locationId: "mars", + dispatchId: null, + pilotId: "avatar", +}; + +const summaries: ResolutionSummary[] = [ + { + dispatchId: "d1", + shipId: "s1", + pilotId: "avatar", + originId: "earth", + destinationId: "mars", + tutorial: false, + credits: 51_200, + pilotXp: 32, + discoveredLocationId: "mars", + resolvedAt: 1, + }, + { + dispatchId: "d2", + shipId: "loaner", + pilotId: "avatar", + originId: "moon", + destinationId: "earth", + tutorial: true, + credits: 240, + pilotXp: 10, + discoveredLocationId: null, + resolvedAt: 2, + }, +]; + +describe("while you were away", () => { + it("lists each completed delivery, and dismissing acknowledges them and hides the dialog", async () => { + let acknowledged = false; + const api = fakeApi({ + getPlayerState: () => + samplePlayerState({ + tutorial: null, + ships: [ship], + summaries: acknowledged ? [] : summaries, + }), + acknowledgeSummaries: ({ dispatchIds }) => { + acknowledged = true; + return { acknowledged: dispatchIds.length }; + }, + }); + renderApp(api); + + const dialog = await screen.findByRole("dialog", { name: "While you were away" }); + const items = within(dialog).getAllByRole("listitem"); + expect(items).toHaveLength(2); + + expect(items[0]).toHaveTextContent("Dusty Mule"); + expect(items[0]).toHaveTextContent("Earth → Mars"); + expect(items[0]).toHaveTextContent("+51,200 cr"); + expect(items[0]).toHaveTextContent("+32 XP"); + expect(items[0]).toHaveTextContent("Discovered Mars!"); + + expect(items[1]).toHaveTextContent("Company loaner"); + expect(items[1]).toHaveTextContent("Moon → Earth"); + expect(items[1]).not.toHaveTextContent("Discovered"); + + await userEvent.click(within(dialog).getByRole("button", { name: "Got it" })); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + await waitFor(() => + expect(api.call).toHaveBeenCalledWith("acknowledgeSummaries", { dispatchIds: ["d1", "d2"] }), + ); + }); + + it("does not show the dialog when there is nothing to report", async () => { + renderApp(fakeApi({ getPlayerState: () => samplePlayerState() })); + await screen.findByRole("heading", { level: 1, name: "HQ" }); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); +}); diff --git a/memory-bank/progress.md b/memory-bank/progress.md index eed319b..5bd7f49 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -86,7 +86,7 @@ ## Phase 9 — Client Screens - [x] **Step 9.1 — HQ screen.** `src/screens/HQ.tsx`: credits, game date, idle/in-flight counts, next arrival with live countdown, tutorial goal progress (cheapest ship + Earth bay). Shared helpers: `src/format.ts`, `Countdown`, `useLoadedPlayer` / `useGameAction` (`src/state/actions.ts`). -- [ ] Step 9.2 — "While you were away" summary +- [x] **Step 9.2 — "While you were away" summary.** `src/components/AwaySummary.tsx`: dialog over every screen listing each unacknowledged delivery (ship or "Company loaner", route, credits, XP, discovery); "Got it" hides it at once and calls `acknowledgeSummaries`. - [ ] Step 9.3 — Tutorial contract board - [ ] Step 9.4 — Bay contract boards - [ ] Step 9.5 — Dispatch flow