Step 9.2: add "while you were away" summary
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
1eb96beb22
commit
b1ddb1154b
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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,6 +46,7 @@ export function App() {
|
||||||
if (state.data === null) return <StartScreen />;
|
if (state.data === null) return <StartScreen />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<Layout credits={formatCredits(state.data.company.credits)} />}>
|
<Route element={<Layout credits={formatCredits(state.data.company.credits)} />}>
|
||||||
{SCREENS.map((screen) => (
|
{SCREENS.map((screen) => (
|
||||||
|
|
@ -57,5 +59,7 @@ export function App() {
|
||||||
))}
|
))}
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
<AwaySummary />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue