Step 9.2: add "while you were away" summary

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-03 02:08:43 -04:00
parent 1eb96beb22
commit b1ddb1154b
5 changed files with 176 additions and 13 deletions

View File

@ -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 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. - **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). - **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.

View File

@ -2,6 +2,7 @@ import { useEffect, type ReactNode } from "react";
import { Route, Routes } from "react-router"; import { Route, Routes } from "react-router";
import { useApi } from "./api/context"; import { useApi } from "./api/context";
import { startClockTicker, syncClock } from "./clock/clock"; import { startClockTicker, syncClock } from "./clock/clock";
import { AwaySummary } from "./components/AwaySummary";
import { formatCredits } from "./format"; import { formatCredits } from "./format";
import { Layout } from "./layout/Layout"; import { Layout } from "./layout/Layout";
import { SCREENS } from "./routes"; import { SCREENS } from "./routes";
@ -45,17 +46,20 @@ export function App() {
if (state.data === null) return <StartScreen />; if (state.data === null) return <StartScreen />;
return ( return (
<Routes> <>
<Route element={<Layout credits={formatCredits(state.data.company.credits)} />}> <Routes>
{SCREENS.map((screen) => ( <Route element={<Layout credits={formatCredits(state.data.company.credits)} />}>
<Route {SCREENS.map((screen) => (
key={screen.path} <Route
index={screen.path === "/"} key={screen.path}
path={screen.path === "/" ? undefined : screen.path} index={screen.path === "/"}
element={SCREEN_COMPONENTS[screen.path] ?? <Placeholder title={screen.title} />} path={screen.path === "/" ? undefined : screen.path}
/> element={SCREEN_COMPONENTS[screen.path] ?? <Placeholder title={screen.title} />}
))} />
</Route> ))}
</Routes> </Route>
</Routes>
<AwaySummary />
</>
); );
} }

View File

@ -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<ReadonlySet<string>>(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 (
<div className="fixed inset-0 z-20 flex items-end justify-center bg-black/60 p-4 sm:items-center">
<div
role="dialog"
aria-modal="true"
aria-labelledby="away-title"
className="max-h-[80dvh] w-full max-w-md overflow-y-auto rounded-xl border border-slate-700 bg-slate-900 p-4"
>
<h2 id="away-title" className="text-lg font-semibold">
While you were away
</h2>
<ul className="mt-3 space-y-3">
{summaries.map((s) => (
<li key={s.dispatchId} className="rounded-lg border border-slate-800 p-3 text-sm">
<div className="font-medium">{shipName(s.shipId, s.tutorial)}</div>
<div className="text-slate-400">
{locationName(s.originId)} → {locationName(s.destinationId)}
</div>
<div className="mt-1 flex gap-4 tabular-nums">
<span className={s.credits >= 0 ? "text-emerald-400" : "text-red-400"}>
{s.credits >= 0 ? "+" : ""}
{formatCredits(s.credits)}
</span>
<span>+{s.pilotXp} XP</span>
</div>
{s.discoveredLocationId && (
<div className="mt-1 text-amber-300">
Discovered {locationName(s.discoveredLocationId)}!
</div>
)}
</li>
))}
</ul>
<button
type="button"
onClick={dismiss}
className="mt-4 w-full rounded-lg bg-amber-500 py-2 font-semibold text-slate-950"
>
Got it
</button>
</div>
</div>
);
}

View File

@ -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();
});
});

View File

@ -86,7 +86,7 @@
## Phase 9 — Client Screens ## 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`). - [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.3 — Tutorial contract board
- [ ] Step 9.4 — Bay contract boards - [ ] Step 9.4 — Bay contract boards
- [ ] Step 9.5 — Dispatch flow - [ ] Step 9.5 — Dispatch flow