Step 9.1: add HQ screen

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

View File

@ -395,3 +395,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **Arrival refetch is driven by the ticking server clock** (`useClock`), not by per-dispatch timers: when `now` passes an in-flight dispatch's `arrivalAt`, state is refetched, which makes the server resolve it lazily. Each dispatch triggers one refetch; Phase 7's job covers anything the client misses.
- **`refetchOnWindowFocus: true` is set explicitly** (it is TanStack Query's default) to document the requirement.
- **Tests fake only `Date` and `setInterval`** for the arrival case, leaving `setTimeout` real so TanStack Query's scheduling works; the focus test runs on real timers.
## Step 9.1
- **Tutorial goal cost = cheapest ship model + Earth bay lease**, read from content, so it follows any rebalancing.
- **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).

View File

@ -1,16 +1,19 @@
import { useEffect } from "react";
import { useEffect, type ReactNode } from "react";
import { Route, Routes } from "react-router";
import { useApi } from "./api/context";
import { startClockTicker, syncClock } from "./clock/clock";
import { formatCredits } from "./format";
import { Layout } from "./layout/Layout";
import { SCREENS } from "./routes";
import { HQ } from "./screens/HQ";
import { Placeholder } from "./screens/Placeholder";
import { StartScreen } from "./screens/StartScreen";
import { usePlayerState } from "./state/player";
import { usePlayerState, useRefetchOnArrival } from "./state/player";
export function formatCredits(credits: number): string {
return `${credits.toLocaleString("en-US")} cr`;
}
/** Built screens by path; the rest show a placeholder. */
const SCREEN_COMPONENTS: Partial<Record<(typeof SCREENS)[number]["path"], ReactNode>> = {
"/": <HQ />,
};
/** Syncs the clock with the server once, then ticks it every second. */
function useServerClock() {
@ -27,6 +30,7 @@ function useServerClock() {
export function App() {
useServerClock();
const state = usePlayerState();
useRefetchOnArrival(state.data ?? null, state.refetch);
if (state.isPending) {
return <p className="p-6 text-slate-400">Loading…</p>;
@ -48,7 +52,7 @@ export function App() {
key={screen.path}
index={screen.path === "/"}
path={screen.path === "/" ? undefined : screen.path}
element={<Placeholder title={screen.title} />}
element={SCREEN_COMPONENTS[screen.path] ?? <Placeholder title={screen.title} />}
/>
))}
</Route>

View File

@ -0,0 +1,9 @@
import { useClock } from "../clock/clock";
import { formatDuration } from "../format";
/** Live time remaining until `to` (server clock); shows `done` once it has passed. */
export function Countdown({ to, done = "Arriving…" }: { to: number; done?: string }) {
const now = useClock((s) => s.now);
const remaining = to - now;
return <span className="tabular-nums">{remaining > 0 ? formatDuration(remaining) : done}</span>;
}

42
apps/web/src/format.ts Normal file
View File

@ -0,0 +1,42 @@
import { content } from "@deadhead/content";
export function formatCredits(credits: number): string {
return `${Math.round(credits).toLocaleString("en-US")} cr`;
}
/** A duration as the two largest units: "2h 05m", "4m 09s", "45s". */
export function formatDuration(ms: number): string {
const totalSeconds = Math.max(0, Math.ceil(ms / 1000));
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
const pad = (n: number) => String(n).padStart(2, "0");
if (hours > 0) return `${hours}h ${pad(minutes)}m`;
if (minutes > 0) return `${minutes}m ${pad(seconds)}s`;
return `${seconds}s`;
}
/** A game-hours duration, e.g. "5.4 h" or "250 h". */
export function formatGameHours(hours: number): string {
return hours < 10 ? `${hours.toFixed(1)} h` : `${Math.round(hours)} h`;
}
export function formatMillionKm(distance: number): string {
return distance < 10 ? `${distance.toFixed(2)}M km` : `${Math.round(distance)}M km`;
}
export function locationName(id: string): string {
return content.location(id)?.name ?? id;
}
export function shipModelName(id: string): string {
return content.shipModel(id)?.name ?? id;
}
export function cargoName(id: string): string {
return content.cargoType(id)?.name ?? id;
}
export function skillName(id: string): string {
return content.skill(id)?.name ?? id;
}

View File

@ -0,0 +1,80 @@
import { content } from "@deadhead/content";
import { realToGame } from "@deadhead/sim";
import { useClock } from "../clock/clock";
import { formatGameTime } from "../clock/GameClock";
import { Countdown } from "../components/Countdown";
import { formatCredits, locationName } from "../format";
import { useLoadedPlayer } from "../state/actions";
/** Cost of the tutorial goal: the cheapest ship plus an Earth bay. */
export function tutorialGoalCost(): number {
const cheapest = Math.min(...content.shipModels.map((s) => s.price));
return cheapest + (content.location("earth")?.bayLeasePrice ?? 0);
}
export function HQ() {
const state = useLoadedPlayer();
const now = useClock((s) => s.now);
const idle = state.ships.filter((s) => s.status === "idle").length;
const flying = state.ships.filter((s) => s.status === "in-flight").length;
const next = [...state.activeDispatches].sort((a, b) => a.arrivalAt - b.arrivalAt)[0];
const goal = tutorialGoalCost();
const progress = Math.min(100, Math.floor((state.company.credits / goal) * 100));
return (
<section className="space-y-4 p-4">
<h1 className="text-xl font-semibold">HQ</h1>
<div className="grid grid-cols-2 gap-3">
<Stat label="Credits" value={formatCredits(state.company.credits)} />
<Stat label="Game date" value={formatGameTime(realToGame(now, content.config))} />
<Stat label="Ships idle" value={String(idle)} />
<Stat label="Ships in flight" value={String(flying)} />
</div>
<div className="rounded-lg border border-slate-800 p-3">
<h2 className="text-sm font-semibold text-slate-300">Next arrival</h2>
{next ? (
<p className="mt-1">
{locationName(next.originId)} → {locationName(next.destinationId)} in{" "}
<Countdown to={next.arrivalAt} />
</p>
) : (
<p className="mt-1 text-sm text-slate-400">Nothing in flight.</p>
)}
</div>
{state.tutorial && (
<div className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-3">
<h2 className="font-semibold text-amber-300">
Save up for your first ship and an Earth bay
</h2>
<p className="mt-1 text-sm text-slate-300">
{formatCredits(state.company.credits)} of {formatCredits(goal)}
</p>
<div
role="progressbar"
aria-label="Tutorial goal"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={progress}
className="mt-2 h-2 overflow-hidden rounded bg-slate-800"
>
<div className="h-full bg-amber-400" style={{ width: `${progress}%` }} />
</div>
<p className="mt-1 text-right text-xs text-slate-400">{progress}%</p>
</div>
)}
</section>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-lg border border-slate-800 p-3">
<div className="text-xs text-slate-400">{label}</div>
<div className="mt-1 font-semibold tabular-nums">{value}</div>
</div>
);
}

View File

@ -0,0 +1,33 @@
import type { PlayerState, RouteName, RouteRequest, RouteResponse } from "@deadhead/schema";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { ApiError } from "../api/client";
import { useApi } from "../api/context";
import { PLAYER_STATE_KEY, usePlayerState } from "./player";
/** Player state on screens that only render once it has loaded (see `App`). */
export function useLoadedPlayer(): PlayerState {
const { data } = usePlayerState();
if (!data) throw new Error("useLoadedPlayer used before player state loaded");
return data;
}
/**
* Calls one game endpoint, then refreshes player state (and any boards) so
* every screen sees the result.
*/
export function useGameAction<N extends RouteName>(name: N) {
const api = useApi();
const queryClient = useQueryClient();
return useMutation<RouteResponse<N>, Error, RouteRequest<N>>({
mutationFn: (input) => api.call(name, input),
onSettled: async () => {
await queryClient.invalidateQueries({ queryKey: PLAYER_STATE_KEY });
await queryClient.invalidateQueries({ queryKey: ["board"] });
},
});
}
/** The server's message for a failed action, or a generic one. */
export function errorMessage(error: unknown): string {
return error instanceof ApiError ? error.message : "Something went wrong. Try again.";
}

View File

@ -18,17 +18,19 @@ export async function fetchPlayerState(api: ApiClient): Promise<PlayerState | nu
}
/**
* Server player state (Step 8.5). Refetched when the app regains focus and,
* via `useRefetchOnArrival`, when an in-flight dispatch arrives.
* Server player state (Step 8.5), shared by every screen. Refetched when the app
* regains focus; `App` also runs `useRefetchOnArrival` once for arrivals.
*/
export function usePlayerState() {
const api = useApi();
const query = useQuery({
queryKey: PLAYER_STATE_KEY,
queryFn: () => fetchPlayerState(api),
refetchOnWindowFocus: true,
// Screens share this query; fresh for 10 s so mounting a screen does not refetch.
// Returning to the app, arrivals and actions always refetch.
staleTime: 10_000,
refetchOnWindowFocus: "always",
});
useRefetchOnArrival(query.data ?? null, query.refetch);
return query;
}

92
apps/web/test/hq.test.tsx Normal file
View File

@ -0,0 +1,92 @@
import type { Dispatch, Ship } from "@deadhead/schema";
import { act, screen, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useClock } from "../src/clock/clock";
import { tutorialGoalCost } from "../src/screens/HQ";
import { fakeApi, renderApp, samplePlayerState } from "./render";
const NOW = Date.parse("2100-01-01T00:00:00Z");
const ship = (id: string, status: Ship["status"]): Ship => ({
id,
modelId: "courier",
name: id,
status,
locationId: status === "idle" ? "earth" : null,
dispatchId: status === "idle" ? null : `d-${id}`,
pilotId: null,
});
const dispatch = (shipId: string, arrivalAt: number): Dispatch => ({
id: `d-${shipId}`,
shipId,
pilotId: "avatar",
contract: null,
originId: "earth",
destinationId: "moon",
tutorial: false,
departureAt: NOW - 60_000,
arrivalAt,
distanceMillionKm: 0.39,
fuelCost: 50,
expectedPayout: 0,
seed: 1,
status: "in-flight",
resolvedAt: null,
});
describe("HQ screen", () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
vi.setSystemTime(NOW);
useClock.setState({ offsetMs: 0, synced: true, now: NOW });
});
afterEach(() => vi.useRealTimers());
it("shows idle and in-flight counts and counts down to the next arrival", async () => {
const state = samplePlayerState({
tutorial: null,
ships: [ship("a", "idle"), ship("b", "in-flight"), ship("c", "in-flight")],
activeDispatches: [dispatch("b", NOW + 125_000), dispatch("c", NOW + 65_000)],
});
renderApp(fakeApi({ getPlayerState: () => state }));
await screen.findByRole("heading", { level: 1, name: "HQ" });
const statValue = (label: string) => screen.getByText(label).nextElementSibling;
expect(statValue("Ships idle")).toHaveTextContent("1");
expect(statValue("Ships in flight")).toHaveTextContent("2");
const next = screen.getByRole("heading", { name: "Next arrival" }).parentElement!;
expect(within(next).getByText("1m 05s")).toBeInTheDocument();
act(() => vi.advanceTimersByTime(10_000));
expect(within(next).getByText("55s")).toBeInTheDocument();
expect(screen.queryByText(/Save up for your first ship/)).not.toBeInTheDocument();
});
it("shows tutorial goal progress as a percentage of a ship plus an Earth bay", async () => {
const goal = tutorialGoalCost();
const state = samplePlayerState({
company: { id: "c", name: "D", credits: Math.round(goal * 0.4) },
});
renderApp(fakeApi({ getPlayerState: () => state }));
expect(
await screen.findByText("Save up for your first ship and an Earth bay"),
).toBeInTheDocument();
expect(screen.getByRole("progressbar", { name: "Tutorial goal" })).toHaveAttribute(
"aria-valuenow",
"40",
);
expect(screen.getByText("40%")).toBeInTheDocument();
expect(screen.getByText("Nothing in flight.")).toBeInTheDocument();
});
it("caps tutorial progress at 100%", async () => {
const state = samplePlayerState({
company: { id: "c", name: "D", credits: tutorialGoalCost() * 3 },
});
renderApp(fakeApi({ getPlayerState: () => state }));
expect(await screen.findByText("100%")).toBeInTheDocument();
});
});

View File

@ -6,9 +6,14 @@ import { fakeApi, renderApp, samplePlayerState, unauthorized } from "./render";
describe("guest onboarding", () => {
it("shows the start screen without a session; the button creates a guest and opens HQ", async () => {
// Like the real server: no session until the guest is created.
let signedUp = false;
const api = fakeApi({
getPlayerState: unauthorized,
createGuest: () => ({ playerId: "p1", state: samplePlayerState() }),
getPlayerState: () => (signedUp ? samplePlayerState() : unauthorized()),
createGuest: () => {
signedUp = true;
return { playerId: "p1", state: samplePlayerState() };
},
});
renderApp(api, "/fleet");

View File

@ -77,8 +77,21 @@
## Phase 8 — Client Foundation
- [x] **Step 8.1 — Vite + React app.** `apps/web`: Vite 8, React 19, Tailwind 4, React Router 8. Layout (header with credits and game clock, main area, fixed six-tab bottom navigation) and placeholder screens for HQ, Contracts, Fleet, Pilots, Market, Locations. Component tests in jsdom. Dev server proxies `/api` to `127.0.0.1:3000`. Manual 375px check left to the user.
- [x] **Step 8.1 — Vite + React app.** `apps/web`: Vite 8, React 19, Tailwind 4, React Router 8. Layout (header with credits and game clock, main area, fixed six-tab bottom navigation) and placeholder screens for HQ, Contracts, Fleet, Pilots, Market, Locations. Component tests in jsdom. Dev server proxies `/api` to `127.0.0.1:3000`. Manual 375px check: passed on the user's iPhone.
- [x] **Step 8.2 — Typed API client.** `apps/web/src/api/client.ts`: `createApiClient().call(name, input)` built from the route catalog: path params filled, GET query / POST JSON body, `credentials: "include"`, responses validated (`ApiValidationError`), server errors raised as `ApiError` with code and message.
- [x] **Step 8.3 — Server-synced clock.** `apps/web/src/clock`: Zustand `useClock` store, `syncClock(api)` (offset from server time, half round trip), `serverNow()`, `startClockTicker()` (1 s), and a `GameClock` header component showing in-game time.
- [x] **Step 8.4 — Guest onboarding.** No session (state request returns 401) shows `StartScreen`; "Start your company" calls `createGuest`, caches the returned state and opens HQ. The session is Better Auth's persistent cookie. TanStack Query and an `ApiProvider` context added; header shows live credits. Verified end to end through the Vite proxy.
- [x] **Step 8.5 — Player state store.** `usePlayerState` (TanStack Query) refetches on window focus and, via `useRefetchOnArrival`, once per dispatch when the server clock passes its arrival time.
## 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
- [ ] Step 9.3 — Tutorial contract board
- [ ] Step 9.4 — Bay contract boards
- [ ] Step 9.5 — Dispatch flow
- [ ] Step 9.6 — Fleet screen
- [ ] Step 9.7 — Pilots screen
- [ ] Step 9.8 — Market screen
- [ ] Step 9.9 — Locations screen
- [ ] Step 9.10 — Empty and error states