Step 11.2: offline shell behaviour
CI / check (push) Successful in 6m11s Details

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-05 18:13:31 -04:00
parent feb84aecaa
commit 9b45a239a5
30 changed files with 570 additions and 121 deletions

View File

@ -1248,3 +1248,11 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **Icons are generated placeholders** (the Courier sprite on `#080d1d`), with the maskable icon keeping the ship inside the 80 % safe zone. Replace by putting a logo through `scripts/icons.mjs`.
- **Precache is ~6.7 MB**, mostly the @2x map art the CR-003 amendment asks for. Only woff2 fonts are cached (every supporting browser uses them before woff).
- **Auto-update**: a new build's service worker takes over on the next load without a prompt; there is no player data in the cache to lose.
## Step 11.2
- **"Offline" means no reply, not an error reply.** A fetch that rejects (no network, server unreachable) switches to the cached state; a 4xx/5xx from the server is shown as an error as before, because the server is there.
- **The last player state is cached in local storage**, schema-checked on read (a build with a different shape ignores it) and cleared on sign-out. It's a convenience cache: if storage is blocked, an offline start shows "Could not reach the server" as before.
- **Actions are disabled per button (`needsServer`)**, not app-wide: navigation, closing sheets, reading details, the map and display/help settings keep working. Buttons that only open an action sheet (Dispatch, Deadhead, Buy, Rename, Back up, Restore, New company) are disabled too, so the player isn't led into a form they can't submit. Viewing a ship's upgrades stays open (it's informational; its Install button is disabled).
- **The away summary and the deal wait for the connection**: both exist to send something to the server, and a modal that can't be dismissed would lock the app offline.
- **The browser test fakes the API with Playwright route interception** against the real production build and service worker, so it needs no server or database; the end-to-end tests against a real server are Phase 12's.

139
apps/web/e2e/offline.e2e.ts Normal file
View File

@ -0,0 +1,139 @@
import type { Dispatch, PlayerState } from "@deadhead/schema";
import { expect, test, type Page } from "@playwright/test";
import { samplePlayerState, shipExtras } from "../test/fixtures";
/**
* A company with one ship in flight (Earth → Moon, 20 minutes left) and one idle
* at Earth, as the server would return it.
*/
function midgame(now: number): PlayerState {
const base = samplePlayerState({ tutorial: null });
const flight: Dispatch = {
id: "d1",
shipId: "s-fly",
pilotId: "p2",
contract: null,
originId: "earth",
destinationId: "moon",
tutorial: false,
departureAt: now - 60_000,
arrivalAt: now + 20 * 60_000,
distanceMillionKm: 0.38,
fuelCost: 160,
expectedPayout: 0,
seed: 1,
status: "in-flight",
resolvedAt: null,
};
return {
...base,
company: { id: "co1", name: "Deadhead Freight", credits: 50_000, loanBalance: 0 },
bays: [{ id: "b1", locationId: "earth" }],
ships: [
{
id: "s-idle",
modelId: "courier",
name: "Dusty Mule",
status: "idle",
locationId: "earth",
dispatchId: null,
pilotId: "avatar",
lastArrivalAt: 0,
...shipExtras("courier"),
},
{
id: "s-fly",
modelId: "clipper",
name: "Slow Ox",
status: "in-flight",
locationId: null,
dispatchId: "d1",
pilotId: "p2",
lastArrivalAt: 0,
...shipExtras("clipper"),
},
],
pilots: [
{ ...base.pilots[0]!, assignedShipId: "s-idle" },
{
...base.pilots[0]!,
id: "p2",
name: "Rae Okafor",
isAvatar: false,
assignedShipId: "s-fly",
status: "in-flight",
locationId: null,
},
],
activeDispatches: [flight],
};
}
/** Stands in for the API server; with `offline` set, every request fails as if unplugged. */
async function fakeServer(page: Page) {
const server = { offline: false, state: midgame(Date.now()) };
await page.route("**/api/**", async (route) => {
if (server.offline) return route.abort("internetdisconnected");
const path = new URL(route.request().url()).pathname;
if (path === "/api/state") return route.fulfill({ json: server.state });
if (path === "/api/time") return route.fulfill({ json: { realTime: Date.now(), gameTime: 0 } });
if (path.endsWith("/board")) {
return route.fulfill({ json: { contracts: [], periodEndsAt: Date.now() + 600_000 } });
}
return route.fulfill({
status: 404,
json: { error: { code: "not-found", message: "Not in this test" } },
});
});
return server;
}
const progress = async (page: Page) =>
Number(await page.getByRole("progressbar").first().getAttribute("aria-valuenow"));
test.describe("offline shell (Step 11.2)", () => {
test("opens from the cache with the last state, keeps flights moving, and blocks actions", async ({
page,
context,
}) => {
const server = await fakeServer(page);
await page.goto("/fleet");
await expect(page.getByRole("heading", { level: 1, name: "Fleet" })).toBeVisible();
await page.evaluate(() => navigator.serviceWorker.ready.then(() => undefined));
await expect(page.getByRole("button", { name: /Deadhead/ })).toBeEnabled();
// Network off, then a full reload: everything must come from the device.
server.offline = true;
await context.setOffline(true);
await page.reload();
await expect(page.getByRole("heading", { level: 1, name: "Fleet" })).toBeVisible();
await expect(page.getByText(/Offline — showing last update from/)).toBeVisible();
await expect(page.getByText("Slow Ox")).toBeVisible();
// The flight's progress bar keeps advancing from the cached dispatch.
const before = await progress(page);
await expect.poll(() => progress(page), { timeout: 10_000 }).toBeGreaterThan(before);
// Server actions are disabled; reading and navigating still work.
await expect(page.getByRole("button", { name: /Deadhead/ })).toBeDisabled();
await page.getByRole("link", { name: "Shop" }).first().click();
await expect(page.getByRole("button", { name: "Buy Courier" })).toBeDisabled();
// Settings (CR-011): server-backed ones off, display ones still work.
await page.goto("/settings").catch(() => undefined);
await expect(page.getByRole("heading", { level: 1, name: "Settings" })).toBeVisible();
for (const name of ["Rename", "Back up company", "Restore a company", "Start a new company"]) {
await expect(page.getByRole("button", { name })).toBeDisabled();
}
const clock24 = page.getByRole("radio", { name: "24-hour" });
await page.getByText("24-hour", { exact: true }).click();
await expect(clock24).toBeChecked();
// Back online: the banner goes and actions return.
server.offline = false;
await context.setOffline(false);
await expect(page.getByText(/Offline — showing last update/)).toHaveCount(0);
await expect(page.getByRole("button", { name: "Rename" })).toBeEnabled();
});
});

View File

@ -1,4 +1,4 @@
import { lazy, Suspense, useEffect, type ReactNode } from "react";
import { lazy, Suspense, useCallback, useEffect, type ReactNode } from "react";
import { Route, Routes } from "react-router";
import { useApi } from "./api/context";
import { startClockTicker, syncClock } from "./clock/clock";
@ -15,7 +15,9 @@ import { Locations } from "./screens/Locations";
import { Market } from "./screens/Market";
import { Pilots } from "./screens/Pilots";
import { Settings } from "./screens/Settings";
import { useMotionPreference } from "./state/preferences";
import { useConnection, useConnectionEvents } from "./state/connection";
import { usePreferences, useMotionPreference } from "./state/preferences";
import { formatTimeOfDay } from "./clock/timeFormat";
import { StartScreen } from "./screens/StartScreen";
import { usePlayerState, useRefetchOnArrival } from "./state/player";
import { Button } from "./ui";
@ -54,7 +56,13 @@ export function App() {
useMotionPreference();
const state = usePlayerState();
useRefetchOnArrival(state.data ?? null, state.refetch);
const retry = () => void state.refetch();
const { refetch } = state;
const retry = () => void refetch();
// Back online: refresh at once (Step 11.2).
useConnectionEvents(useCallback(() => void refetch(), [refetch]));
const online = useConnection((s) => s.online);
const lastUpdate = useConnection((s) => s.lastUpdate);
const clockFormat = usePreferences((p) => p.clock);
if (state.isPending) {
return (
@ -76,18 +84,22 @@ export function App() {
}
if (state.data === null) return <StartScreen />;
// A later refresh failed: keep showing the last state, with a way to retry.
const banner = state.isError ? (
<div
role="alert"
className="flex items-center justify-between gap-2 border-b border-rose-500/40 bg-rose-950/80 px-4 py-2 text-sm text-rose-200 backdrop-blur"
>
<span>Offline — showing last update.</span>
<Button variant="danger" size="sm" onClick={retry}>
Retry
</Button>
</div>
) : null;
// Offline, or a later refresh failed: keep showing the last state (Step 11.2).
const banner =
!online || state.isError ? (
<div
role="alert"
className="flex items-center justify-between gap-2 border-b border-rose-500/40 bg-rose-950/80 px-4 py-2 text-sm text-rose-200 backdrop-blur"
>
<span>
Offline — showing last update
{lastUpdate !== null && ` from ${formatTimeOfDay(lastUpdate, clockFormat)}`}.
</span>
<Button variant="danger" size="sm" onClick={retry}>
Retry
</Button>
</div>
) : null;
return (
<>
@ -113,8 +125,9 @@ export function App() {
<Route path="/settings" element={<Settings />} />
</Route>
</Routes>
<AwaySummary />
<DealModal />
{/* Both exist to send something to the server: wait until back online. */}
{online && <AwaySummary />}
{online && <DealModal />}
</>
);
}

View File

@ -10,11 +10,22 @@ interface ClockState {
now: RealTime;
}
export const useClock = create<ClockState>(() => ({
offsetMs: 0,
synced: false,
now: Date.now(),
}));
const OFFSET_KEY = "deadhead:clock-offset";
/** The last synced offset, so an offline start still shows server time (Step 11.2). */
function savedOffset(): number {
try {
const value = Number(localStorage.getItem(OFFSET_KEY));
return Number.isFinite(value) ? value : 0;
} catch {
return 0;
}
}
export const useClock = create<ClockState>(() => {
const offsetMs = savedOffset();
return { offsetMs, synced: false, now: Date.now() + offsetMs };
});
/** The current time on the server's clock: device time plus the synced offset. */
export function serverNow(): RealTime {
@ -31,6 +42,11 @@ export async function syncClock(api: ApiClient): Promise<void> {
const receivedAt = Date.now();
const offsetMs = realTime - (sentAt + receivedAt) / 2;
useClock.setState({ offsetMs, synced: true, now: Date.now() + offsetMs });
try {
localStorage.setItem(OFFSET_KEY, String(offsetMs));
} catch {
// Storage blocked: an offline start uses the device clock.
}
}
/** Updates `now` every second. Returns a function that stops the ticker. */

View File

@ -85,6 +85,7 @@ export function DealModal() {
</p>
)}
<Button
needsServer
size="lg"
block
onClick={() => accept.mutate()}

View File

@ -51,6 +51,7 @@ export function GoalsCard() {
</span>
{ready && (
<Button
needsServer
variant="success"
size="sm"
disabled={claim.isPending}

View File

@ -61,7 +61,11 @@ export function LoanCard() {
onChange={(e) => setInput(e.target.value)}
className="min-h-11 min-w-0 flex-1 rounded-lg border border-space-600 bg-space-950/70 px-3 font-display tabular-nums placeholder:text-ink-500 focus:border-amber-400 focus:outline-none"
/>
<Button type="submit" disabled={input === "" || problem !== null || repay.isPending}>
<Button
needsServer
type="submit"
disabled={input === "" || problem !== null || repay.isPending}
>
Pay down
</Button>
</form>

View File

@ -67,7 +67,13 @@ export function BackupCompany() {
<p className="mt-2 text-sm text-amber-200">
Your old code will stop working as soon as you generate a new one.
</p>
<Button block className="mt-3" disabled={create.isPending} onClick={() => create.mutate()}>
<Button
needsServer
block
className="mt-3"
disabled={create.isPending}
onClick={() => create.mutate()}
>
Generate a new code
</Button>
</>
@ -79,7 +85,13 @@ export function BackupCompany() {
A backup code lets you open this company on any device, or get it back if this one is
lost. No email or password.
</p>
<Button block className="mt-3" disabled={create.isPending} onClick={() => create.mutate()}>
<Button
needsServer
block
className="mt-3"
disabled={create.isPending}
onClick={() => create.mutate()}
>
Create a backup code
</Button>
</>
@ -88,7 +100,7 @@ export function BackupCompany() {
return (
<>
<Button variant="secondary" size="sm" onClick={() => setOpen(true)}>
<Button needsServer variant="secondary" size="sm" onClick={() => setOpen(true)}>
Back up company
</Button>
{open && (
@ -130,7 +142,7 @@ export function RestoreCompany({ pastTutorial }: { pastTutorial: boolean }) {
return (
<>
<Button variant="secondary" size="sm" onClick={() => setOpen(true)}>
<Button needsServer variant="secondary" size="sm" onClick={() => setOpen(true)}>
Restore a company
</Button>
{open && (
@ -169,7 +181,7 @@ export function RestoreCompany({ pastTutorial }: { pastTutorial: boolean }) {
</p>
)}
<div className="flex gap-2">
<Button type="submit" block disabled={!code.trim() || restore.isPending}>
<Button needsServer type="submit" block disabled={!code.trim() || restore.isPending}>
Restore
</Button>
<Button variant="secondary" block onClick={() => setOpen(false)}>

View File

@ -20,7 +20,12 @@ export function CompanyName() {
<span className="text-ink-400">Company name</span>
<span className="flex items-center gap-2">
<span className="font-display font-semibold text-ink-50">{state.company.name}</span>
<Button size="sm" variant="ghost" onClick={() => setDraft(state.company.name)}>
<Button
needsServer
size="sm"
variant="ghost"
onClick={() => setDraft(state.company.name)}
>
Rename
</Button>
</span>
@ -60,7 +65,7 @@ export function CompanyName() {
</p>
)}
<div className="flex gap-2">
<Button size="sm" type="submit" disabled={!check.success || rename.isPending}>
<Button needsServer size="sm" type="submit" disabled={!check.success || rename.isPending}>
Save
</Button>
<Button size="sm" variant="secondary" onClick={() => setDraft(null)}>
@ -83,7 +88,7 @@ export function NewCompany() {
return (
<>
<Button variant="danger" size="sm" onClick={() => setOpen(true)}>
<Button needsServer variant="danger" size="sm" onClick={() => setOpen(true)}>
Start a new company
</Button>
{open && (
@ -110,6 +115,7 @@ export function NewCompany() {
)}
<div className="mt-4 flex gap-2">
<Button
needsServer
variant="danger"
block
disabled={typed !== name || start.isPending}

View File

@ -36,6 +36,7 @@ export function DevTools() {
<div className="space-y-2">
<div className="flex flex-wrap gap-2">
<Button
needsServer
variant="secondary"
size="sm"
disabled={busy}
@ -44,6 +45,7 @@ export function DevTools() {
Advance 10 minutes
</Button>
<Button
needsServer
variant="secondary"
size="sm"
disabled={busy}
@ -51,10 +53,17 @@ export function DevTools() {
>
Advance 1 hour
</Button>
<Button variant="secondary" size="sm" disabled={busy} onClick={() => credits.mutate()}>
<Button
needsServer
variant="secondary"
size="sm"
disabled={busy}
onClick={() => credits.mutate()}
>
Add 10,000 credits
</Button>
<Button
needsServer
variant="secondary"
size="sm"
disabled={busy || lastArrival <= serverNow()}

View File

@ -425,7 +425,12 @@ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClo
<Button variant="secondary" onClick={onClose} className="flex-1">
Cancel
</Button>
<Button onClick={confirm} disabled={!selected || dispatch.isPending} className="flex-1">
<Button
needsServer
onClick={confirm}
disabled={!selected || dispatch.isPending}
className="flex-1"
>
Confirm
</Button>
</div>

View File

@ -16,6 +16,7 @@ import { strandedText } from "./DispatchSheet";
import { formatClock, formatCredits, formatDuration, locationName, shipModelName } from "../format";
import { location, shipModel, tripRealMs, shipStatsOf, simShip } from "../game";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
import { useOnline } from "../state/connection";
import {
Art,
Button,
@ -160,6 +161,7 @@ function ShipRow(props: {
</Link>
)}
<Button
needsServer
variant="secondary"
size="sm"
onClick={props.onDeadhead}
@ -204,6 +206,7 @@ export function DeadheadPicker({ ship, onClose }: { ship: Ship; onClose: () => v
const state = useLoadedPlayer();
const now = useClock((s) => s.now);
const reposition = useGameAction("createDispatch");
const online = useOnline();
// Effective stats: upgraded fuel tanks reach farther (CR-009).
const model = shipStatsOf(ship);
const origin = ship.locationId!;
@ -260,7 +263,7 @@ export function DeadheadPicker({ ship, onClose }: { ship: Ship; onClose: () => v
<button
type="button"
aria-label={locationName(option.id)}
disabled={option.problem !== null || reposition.isPending}
disabled={option.problem !== null || reposition.isPending || !online}
onClick={() =>
reposition.mutate(
{

View File

@ -292,7 +292,12 @@ function LocationBoard({
· expires in <Countdown to={contract.expiresAt} done="expired" />
</span>
{onSelect && (
<Button size="sm" onClick={() => onSelect(contract)} className="shrink-0">
<Button
needsServer
size="sm"
onClick={() => onSelect(contract)}
className="shrink-0"
>
Dispatch
</Button>
)}
@ -350,6 +355,7 @@ function NothingHere({
</p>
<div className="mt-3 flex flex-wrap gap-2">
<Button
needsServer
variant="secondary"
size="sm"
disabled={rest.isPending || state.tutorial !== null}

View File

@ -125,6 +125,7 @@ export function Locations() {
<>
<p className="mt-3 text-ink-400">{HQ_EXPLANATION}</p>
<Button
needsServer
aria-label={`Lease bay at ${location.name}`}
block
disabled={!affordable || lease.isPending}

View File

@ -90,6 +90,7 @@ export function Market() {
<Reach model={model} />
<OwnedUpgraded modelId={model.id} />
<Button
needsServer
aria-label={`Buy ${model.name}`}
block
disabled={!affordable || state.bays.length === 0 || buy.isPending}
@ -206,6 +207,7 @@ function DeliveryPicker(props: {
})}
</fieldset>
<Button
needsServer
block
disabled={bayId === null || props.pending}
onClick={() => bayId && props.onBuy(bayId)}

View File

@ -11,6 +11,7 @@ import {
shipModelName,
} from "../format";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
import { useOnline } from "../state/connection";
import { Button, Icon, Meter, Panel, Pill, Screen, ScreenHeader } from "../ui";
/** XP needed for the pilot's current and next level, or null at max level. */
@ -77,6 +78,7 @@ function HirePilot() {
</select>
</label>
<Button
needsServer
disabled={!hasBay || state.company.credits < cost || hire.isPending}
onClick={() => hire.mutate({ locationId: bay })}
className="ml-auto"
@ -97,6 +99,7 @@ function HirePilot() {
function PilotCard({ pilot }: { pilot: Pilot }) {
const state = useLoadedPlayer();
const spend = useGameAction("spendSkillPoint");
const online = useOnline();
const progress = levelProgress(pilot.xp, pilot.level);
const ship = state.ships.find((s) => s.id === pilot.assignedShipId);
const lockedToLoaner = pilot.isAvatar && state.tutorial !== null;
@ -198,7 +201,7 @@ function PilotCard({ pilot }: { pilot: Pilot }) {
<button
type="button"
aria-label={`Raise ${skill.name} for ${pilot.name}`}
disabled={spend.isPending}
disabled={spend.isPending || !online}
onClick={() => spend.mutate({ pilotId: pilot.id, skillId: skill.id })}
className="grid size-8 shrink-0 animate-glow place-items-center rounded-lg border border-amber-400/70 bg-amber-500/15 text-amber-200 transition active:scale-95"
>
@ -284,6 +287,7 @@ function Duty({ pilot, canRest }: { pilot: Pilot; canRest: boolean }) {
</p>
<div className="mt-2 flex gap-2">
<Button
needsServer
size="sm"
disabled={rest.isPending}
onClick={() =>
@ -339,6 +343,7 @@ function AssignShip({ pilot }: { pilot: Pilot }) {
))}
</select>
<Button
needsServer
variant="secondary"
disabled={!shipId || assign.isPending}
onClick={() => assign.mutate({ pilotId: pilot.id, shipId })}

View File

@ -148,6 +148,7 @@ export function ShipUpgrades({ shipId, onClose }: { shipId: string; onClose: ()
</p>
<div className="mt-2 flex gap-2">
<Button
needsServer
size="sm"
disabled={start.isPending}
onClick={() =>

View File

@ -51,6 +51,7 @@ export function StartScreen() {
empire across the solar system.
</p>
<Button
needsServer
size="lg"
onClick={() => start.mutate()}
disabled={start.isPending}

View File

@ -177,6 +177,7 @@ export function TutorialBoard() {
</div>
</dl>
<Button
needsServer
size="sm"
disabled={runInFlight || fly.isPending}
onClick={() => fly.mutate({ kind: "tutorial", contractId: contract.id })}

View File

@ -2,7 +2,8 @@ import type { PlayerState, RouteName, RouteRequest, RouteResponse } from "@deadh
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { ApiError } from "../api/client";
import { useApi } from "../api/context";
import { PLAYER_STATE_KEY, usePlayerState } from "./player";
import { markOffline } from "./connection";
import { isNetworkError, PLAYER_STATE_KEY, usePlayerState } from "./player";
/** Player state on screens that only render once it has loaded (see `App`). */
export function useLoadedPlayer(): PlayerState {
@ -20,6 +21,10 @@ export function useGameAction<N extends RouteName>(name: N) {
const queryClient = useQueryClient();
return useMutation<RouteResponse<N>, Error, RouteRequest<N>>({
mutationFn: (input) => api.call(name, input),
// A request that never reached the server means we're offline (Step 11.2).
onError: (error) => {
if (isNetworkError(error)) markOffline();
},
onSettled: async () => {
await queryClient.invalidateQueries({ queryKey: PLAYER_STATE_KEY });
await queryClient.invalidateQueries({ queryKey: ["board"] });
@ -29,5 +34,8 @@ export function useGameAction<N extends RouteName>(name: N) {
/** 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.";
if (error instanceof ApiError) return error.message;
return isNetworkError(error)
? "You're offline. Try again once you're back online."
: "Something went wrong. Try again.";
}

View File

@ -0,0 +1,79 @@
import { playerStateSchema, type PlayerState } from "@deadhead/schema";
import { useEffect } from "react";
import { create } from "zustand";
/**
* Whether the server can be reached (Step 11.2). Offline when the browser says
* so or when a request failed without reaching the server; back online on the
* browser's "online" event or the next successful request.
*/
interface ConnectionState {
online: boolean;
/** When the player state on screen was fetched (real ms); null before the first. */
lastUpdate: number | null;
}
export const useConnection = create<ConnectionState>(() => ({
online: typeof navigator === "undefined" ? true : navigator.onLine,
lastUpdate: null,
}));
export const markOnline = (lastUpdate: number) =>
useConnection.setState({ online: true, lastUpdate });
export const markOffline = () => useConnection.setState({ online: false });
/** True while the server can be reached; server actions are disabled otherwise. */
export function useOnline(): boolean {
return useConnection((s) => s.online);
}
/** Follows the browser's online/offline events; `onOnline` refetches. */
export function useConnectionEvents(onOnline: () => void) {
useEffect(() => {
const goOnline = () => {
useConnection.setState({ online: true });
onOnline();
};
window.addEventListener("online", goOnline);
window.addEventListener("offline", markOffline);
return () => {
window.removeEventListener("online", goOnline);
window.removeEventListener("offline", markOffline);
};
}, [onOnline]);
}
const LAST_STATE_KEY = "deadhead:last-state";
interface SavedState {
state: PlayerState;
savedAt: number;
}
/**
* The last player state fetched on this device, so the app can open offline
* (Step 11.2). A cache only: the server stays authoritative, and a device that
* blocks storage simply has nothing to show offline.
*/
export function saveLastState(state: PlayerState | null, savedAt: number) {
try {
if (state === null) localStorage.removeItem(LAST_STATE_KEY);
else localStorage.setItem(LAST_STATE_KEY, JSON.stringify({ state, savedAt }));
} catch {
// Storage blocked or full: offline start just won't have a state to show.
}
}
export function loadLastState(): SavedState | null {
try {
const raw = localStorage.getItem(LAST_STATE_KEY);
if (!raw) return null;
const saved = JSON.parse(raw) as { state: unknown; savedAt: unknown };
// Saved by an older build with a different shape: ignore it.
const state = playerStateSchema.safeParse(saved.state);
if (!state.success || typeof saved.savedAt !== "number") return null;
return { state: state.data, savedAt: saved.savedAt };
} catch {
return null;
}
}

View File

@ -1,18 +1,44 @@
import type { PlayerState } from "@deadhead/schema";
import { useQuery } from "@tanstack/react-query";
import { useEffect, useRef } from "react";
import { ApiError, type ApiClient } from "../api/client";
import { ApiError, ApiValidationError, type ApiClient } from "../api/client";
import { useApi } from "../api/context";
import { useClock } from "../clock/clock";
import { loadLastState, markOffline, markOnline, saveLastState, useConnection } from "./connection";
export const PLAYER_STATE_KEY = ["player-state"] as const;
/** Player state, or null when there is no session (the server says 401). */
/** The request never reached the server (offline, DNS, server down), as opposed to an error reply. */
export function isNetworkError(error: unknown): boolean {
return !(error instanceof ApiError) && !(error instanceof ApiValidationError);
}
/**
* Player state, or null when there is no session (the server says 401). Each
* fetched state is kept on the device; when the server can't be reached, the
* last one is returned instead and the app goes offline (Step 11.2).
*/
export async function fetchPlayerState(api: ApiClient): Promise<PlayerState | null> {
try {
return await api.call("getPlayerState", {});
const state = await api.call("getPlayerState", {});
const now = Date.now();
saveLastState(state, now);
markOnline(now);
return state;
} catch (error) {
if (error instanceof ApiError && error.code === "unauthorized") return null;
if (error instanceof ApiError && error.code === "unauthorized") {
saveLastState(null, Date.now());
markOnline(Date.now());
return null;
}
if (isNetworkError(error)) {
markOffline();
const saved = loadLastState();
if (saved) {
useConnection.setState({ lastUpdate: saved.savedAt });
return saved.state;
}
}
throw error;
}
}

View File

@ -1,4 +1,5 @@
import type { ButtonHTMLAttributes } from "react";
import { useOnline } from "../state/connection";
export type ButtonVariant = "primary" | "secondary" | "ghost" | "success" | "danger";
export type ButtonSize = "sm" | "md" | "lg";
@ -41,6 +42,8 @@ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
size?: ButtonSize;
/** Full width. */
block?: boolean;
/** Calls the server (or opens a sheet that does): disabled while offline (Step 11.2). */
needsServer?: boolean;
}
export function Button({
@ -49,9 +52,20 @@ export function Button({
block,
className = "",
type = "button",
needsServer = false,
disabled,
title,
...rest
}: ButtonProps) {
const online = useOnline();
const offline = needsServer && !online;
return (
<button type={type} className={`${buttonClass(variant, size, block)} ${className}`} {...rest} />
<button
type={type}
className={`${buttonClass(variant, size, block)} ${className}`}
disabled={disabled || offline}
title={offline ? "Unavailable offline" : title}
{...rest}
/>
);
}

76
apps/web/test/fixtures.ts Normal file
View File

@ -0,0 +1,76 @@
import { content } from "@deadhead/content";
import type { PlayerState, Ship } from "@deadhead/schema";
import { effectiveStats } from "@deadhead/sim";
// Plain data builders shared by component tests and the Playwright tests (no Vitest imports).
/**
* The CR-009 fields of a ship with no upgrades and not in the shop: its model's
* stats as effective stats. Spread into Ship fixtures, then override as needed.
*/
export function shipExtras(
modelId: string,
upgrades: Record<string, number> = {},
): Pick<Ship, "upgrades" | "stats" | "inShopUntil" | "shop"> {
const model = content.shipModel(modelId);
if (!model) throw new Error(`Unknown ship model ${modelId}`);
const s = effectiveStats(model, upgrades, content);
return {
upgrades,
stats: {
cruiseSpeedKmPerGameHour: s.cruiseSpeedKmPerGameHour,
cargoCapacityTonnes: s.cargoCapacityTonnes,
fuelRangeMillionKm: s.fuelRangeMillionKm,
overheadGameHours: s.overheadGameHours,
handlingGameHoursPerTonne: s.handlingGameHoursPerTonne,
},
inShopUntil: null,
shop: null,
};
}
/** A brand-new tutorial player, as guest sign-up returns it. */
export function samplePlayerState(overrides: Partial<PlayerState> = {}): PlayerState {
return {
playerId: "player-1",
company: { id: "co1", name: "Deadhead Freight", credits: 0, loanBalance: 0 },
ships: [],
pilots: [
{
id: "avatar",
name: "Captain",
isAvatar: true,
isVeteran: false,
xp: 0,
level: 0,
skills: {},
skillPointsAvailable: 0,
assignedShipId: null,
status: "idle",
locationId: "earth",
dutyHours: 0,
dutyLimit: 12,
resting: false,
restEndsAt: null,
},
],
bays: [],
locations: [
{ id: "earth", discovered: true },
{ id: "moon", discovered: true },
{ id: "mars", discovered: false },
{ id: "ceres-station", discovered: false },
{ id: "jupiter-cloud-platform", discovered: false },
],
tutorial: {
loanerLocationId: "earth",
runInFlight: false,
runsCompleted: 0,
dealAvailable: false,
},
activeDispatches: [],
summaries: [],
goals: [],
...overrides,
};
}

View File

@ -0,0 +1,73 @@
import { render, screen } from "@testing-library/react";
import { act } from "react";
import { describe, expect, it } from "vitest";
import { ApiError } from "../src/api/client";
import { loadLastState, saveLastState, useConnection } from "../src/state/connection";
import { fetchPlayerState } from "../src/state/player";
import { Button } from "../src/ui";
import { fakeApi, samplePlayerState } from "./render";
const networkDown = () => {
throw new TypeError("Failed to fetch");
};
describe("offline cache (Step 11.2)", () => {
it("keeps each fetched state and goes online", async () => {
const state = samplePlayerState();
await expect(fetchPlayerState(fakeApi({ getPlayerState: () => state }))).resolves.toEqual(
state,
);
expect(loadLastState()?.state).toEqual(state);
expect(useConnection.getState()).toMatchObject({ online: true });
});
it("returns the last state when the server can't be reached, and goes offline", async () => {
const state = samplePlayerState({ playerId: "cached" });
saveLastState(state, 1234);
await expect(fetchPlayerState(fakeApi({ getPlayerState: networkDown }))).resolves.toEqual(
state,
);
expect(useConnection.getState()).toEqual({ online: false, lastUpdate: 1234 });
});
it("still fails a first load offline with nothing saved", async () => {
await expect(fetchPlayerState(fakeApi({ getPlayerState: networkDown }))).rejects.toThrow();
});
it("does not fall back on a server error reply, only on no reply", async () => {
saveLastState(samplePlayerState(), 1);
const failing = fakeApi({
getPlayerState: () => {
throw new ApiError(500, "internal-error", "Boom");
},
});
await expect(fetchPlayerState(failing)).rejects.toThrow("Boom");
});
it("forgets the state when signed out, and ignores a saved state of another shape", async () => {
saveLastState(samplePlayerState(), 1);
saveLastState(null, 2);
expect(loadLastState()).toBeNull();
localStorage.setItem("deadhead:last-state", JSON.stringify({ state: { old: 1 }, savedAt: 3 }));
expect(loadLastState()).toBeNull();
});
});
describe("server actions offline", () => {
it("disables a needsServer button only while offline", () => {
render(
<>
<Button needsServer>Buy</Button>
<Button>Close</Button>
</>,
);
expect(screen.getByRole("button", { name: "Buy" })).toBeEnabled();
act(() => useConnection.setState({ online: false }));
expect(screen.getByRole("button", { name: "Buy" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Buy" })).toHaveAttribute(
"title",
"Unavailable offline",
);
expect(screen.getByRole("button", { name: "Close" })).toBeEnabled();
});
});

View File

@ -1,6 +1,4 @@
import { content } from "@deadhead/content";
import type { PlayerState, RouteName, RouteRequest, RouteResponse, Ship } from "@deadhead/schema";
import { effectiveStats } from "@deadhead/sim";
import type { RouteName, RouteRequest, RouteResponse } from "@deadhead/schema";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render } from "@testing-library/react";
import { MemoryRouter } from "react-router";
@ -33,76 +31,7 @@ export const unauthorized = () => {
throw new ApiError(401, "unauthorized", "Sign in to continue.");
};
/** A brand-new tutorial player, as guest sign-up returns it. */
/**
* The CR-009 fields of a ship with no upgrades and not in the shop: its model's
* stats as effective stats. Spread into Ship fixtures, then override as needed.
*/
export function shipExtras(
modelId: string,
upgrades: Record<string, number> = {},
): Pick<Ship, "upgrades" | "stats" | "inShopUntil" | "shop"> {
const model = content.shipModel(modelId);
if (!model) throw new Error(`Unknown ship model ${modelId}`);
const s = effectiveStats(model, upgrades, content);
return {
upgrades,
stats: {
cruiseSpeedKmPerGameHour: s.cruiseSpeedKmPerGameHour,
cargoCapacityTonnes: s.cargoCapacityTonnes,
fuelRangeMillionKm: s.fuelRangeMillionKm,
overheadGameHours: s.overheadGameHours,
handlingGameHoursPerTonne: s.handlingGameHoursPerTonne,
},
inShopUntil: null,
shop: null,
};
}
export function samplePlayerState(overrides: Partial<PlayerState> = {}): PlayerState {
return {
playerId: "player-1",
company: { id: "co1", name: "Deadhead Freight", credits: 0, loanBalance: 0 },
ships: [],
pilots: [
{
id: "avatar",
name: "Captain",
isAvatar: true,
isVeteran: false,
xp: 0,
level: 0,
skills: {},
skillPointsAvailable: 0,
assignedShipId: null,
status: "idle",
locationId: "earth",
dutyHours: 0,
dutyLimit: 12,
resting: false,
restEndsAt: null,
},
],
bays: [],
locations: [
{ id: "earth", discovered: true },
{ id: "moon", discovered: true },
{ id: "mars", discovered: false },
{ id: "ceres-station", discovered: false },
{ id: "jupiter-cloud-platform", discovered: false },
],
tutorial: {
loanerLocationId: "earth",
runInFlight: false,
runsCompleted: 0,
dealAvailable: false,
},
activeDispatches: [],
summaries: [],
goals: [],
...overrides,
};
}
export { samplePlayerState, shipExtras } from "./fixtures";
/** Renders the whole app at `path` with a fresh query cache and the given API. */
export function renderApp(api: ApiClient, path = "/") {

View File

@ -1,11 +1,14 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, configure } from "@testing-library/react";
import { afterEach, vi } from "vitest";
import { useConnection } from "../src/state/connection";
afterEach(() => {
cleanup();
// The board remembers what was seen per location (CR-008); start each test fresh.
if (typeof localStorage !== "undefined") localStorage.clear();
// Offline in one test must not leak into the next (Step 11.2).
useConnection.setState({ online: true, lastUpdate: null });
});
// findBy*/waitFor default to 1 s, which a busy parallel run can exceed; wait up to 3 s.

View File

@ -52,13 +52,13 @@ describe("network errors", () => {
focusManager.setFocused(false);
focusManager.setFocused(true);
});
const banner = await screen.findByText("Offline — showing last update.");
const banner = await screen.findByText(/Offline — showing last update/);
expect(screen.getByRole("heading", { level: 1, name: title })).toBeInTheDocument();
online = true;
await userEvent.click(within(banner.parentElement!).getByRole("button", { name: "Retry" }));
await act(async () => {});
expect(screen.queryByText("Offline — showing last update.")).not.toBeInTheDocument();
expect(screen.queryByText(/Offline — showing last update/)).not.toBeInTheDocument();
},
);

View File

@ -460,7 +460,7 @@ Vite + React 19 + Tailwind 4, React Router 8 (declarative routes). In developmen
|---|---|
| `src/api/client.ts` | `createApiClient().call(route, input)`: typed by the route catalog; validates responses; throws `ApiError` (server code) or `ApiValidationError` |
| `src/api/context.tsx` | `ApiProvider` / `useApi` (tests inject a fake) |
| `src/clock/clock.ts` | Zustand `useClock`: server offset, `serverNow()`, 1 s ticker |
| `src/clock/clock.ts` | Zustand `useClock`: server offset (last one kept in `deadhead:clock-offset` for offline starts), `serverNow()`, 1 s ticker |
| `src/state/player.ts` | `usePlayerState` (TanStack Query, key `player-state`): `null` means no session; refetch on focus and on arrival |
| `src/App.tsx` | Clock sync; loading / start screen / routed layout |
| `src/layout/Layout.tsx` | Header (credits, game clock), screen outlet, bottom tab bar |
@ -473,6 +473,9 @@ Vite + React 19 + Tailwind 4, React Router 8 (declarative routes). In developmen
| `src/map/overlays.ts` | Per-location state overlays (lock, desaturate/dim, HQ marker, idle badge), applied by the renderer |
| `src/map/ticker.ts` | `runWhileVisible`: pause the map's ticker while the page is hidden; stop it on unmount |
| `src/map/renderer.ts` | `MapRenderer`: all PixiJS drawing for the map, layer by layer; `MapCanvas` owns the app, input and loading state |
| `src/state/connection.ts` | Offline (Step 11.2): Zustand `useConnection` (`online`, `lastUpdate`), browser online/offline events, and the last player state cache (`deadhead:last-state`, schema-checked on load) |
| `vite.config.ts` (PWA) | `vite-plugin-pwa` (Step 11.1): manifest, generateSW precache of the shell, woff2 fonts, `map-gen` art and icons (`public/icons`, from `scripts/icons.mjs`); `/api` never cached |
| `e2e/*.e2e.ts` | Playwright tests against `vite preview` of the build (install, offline); the API is faked with route interception |
| `src/state/preferences.ts` | Device preferences (CR-011): clock and countdown format, reduced-motion override, dismissed tips, saved map camera; local storage, every access wrapped |
| `src/screens/*` | HQ, Contracts (TutorialBoard / LocationBoards + DispatchSheet), Fleet, Pilots, Market, Locations, StartScreen |
| `src/components/*` | `AwaySummary` dialog, `Countdown` |
@ -480,4 +483,6 @@ Vite + React 19 + Tailwind 4, React Router 8 (declarative routes). In developmen
| `src/map/*` | System map: `projection` (square-root radial scale), `camera` (Zustand pan/zoom), `scene` (locations, flights, idle badges), `hitTest`, `MapCanvas` (PixiJS v8, lazy-loaded with the Map screen) |
| `src/game.ts`, `src/format.ts` | Sim-based predictions (distance, trip time) and display formatting |
Session: Better Auth's cookie (`credentials: "include"`); the client stores nothing itself.
Session: Better Auth's cookie (`credentials: "include"`). On the device the client keeps only caches and preferences: the last player state and clock offset (offline start), device preferences, the device id and seen board ids.
Offline (Step 11.2): a request that gets no reply marks the app offline and `getPlayerState` falls back to the cached state; the banner reads "Offline — showing last update from HH:MM". Buttons with `needsServer` (every server action and the sheets that lead to one) are disabled while offline; the away summary and the deal wait until back online. The browser's `online` event refetches.

View File

@ -300,4 +300,6 @@ Spec: `memory-bank/change-requests/CR-013-veterans-clipper.md` (received as "CR-
## Phase 11 — PWA
- [x] **Step 11.1 — Installable PWA.** `vite-plugin-pwa` 2.0 (generateSW, auto-update): manifest "Deadhead: Space Freight Tycoon" / "Deadhead", standalone, theme and background `#080d1d`, icons 192, 512 and maskable 512 (plus favicon and Apple touch icon in `index.html`). The service worker precaches the app shell, the self-hosted font files (woff2) and all 25 generated map images (64 entries, ~6.7 MB); source art in `assets/map/` is excluded; `/api` is never served from the cache. Icons come from `scripts/icons.mjs` (`pnpm --filter @deadhead/web icons`): the Courier sprite on the space background, placeholder until there is a logo. Playwright is set up for browser tests against the production build (`apps/web/playwright.config.ts`, `e2e/*.e2e.ts`, `pnpm --filter @deadhead/web e2e`), and CI builds the web app, installs Chromium and runs them. Tests (`e2e/install.e2e.ts`): Chrome reports no installability errors; the manifest has the name, standalone display, both icon sizes and a maskable icon, all served; the precache holds index.html, map-gen art and both fonts, and no source art. **Manual check for the user:** install to a phone home screen and launch standalone.
- [ ] Step 11.2 — Offline shell behavior
- [x] **Step 11.2 — Offline shell behavior.** A request that gets no reply (or the browser's `offline` event) marks the app offline (`state/connection.ts`). Each fetched player state is kept on the device (schema-checked when read back) and returned when the server can't be reached, so a reload offline opens from the service worker cache with the last state; the last clock offset is kept too. The banner reads "Offline — showing last update from HH:MM" with Retry; the browser's `online` event refetches and clears it. `Button` gains `needsServer`, set on every server action and the buttons that open action sheets (dispatch, deadhead, buy, hire, lease, assign, rest, goals, loan, deal, tutorial runs, and the CR-011 rename, backup, restore, new company and developer tools); the two plain buttons (deadhead destinations, raise skill) check `useOnline`. The away summary and deal modal wait until back online. Display and help settings still work. Shared fixtures moved to `test/fixtures.ts` for Playwright. Tests: `e2e/offline.e2e.ts` (fake API by route interception; network off and reload: Fleet renders from cache with the banner and the in-flight ship, its progress bar advances, Deadhead and Buy are disabled, server-backed settings are disabled while the clock format still changes; back online the banner goes and actions return) and `test/offline.test.tsx` (cache fallback only on no reply, not on an error reply; first load offline with no cache still fails; cache cleared on sign-out and ignored if malformed; `needsServer` buttons disabled only offline).
**Phase 11 complete — waiting for review.**