Step 11.2: offline shell behaviour
CI / check (push) Successful in 6m11s
Details
CI / check (push) Successful in 6m11s
Details
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
feb84aecaa
commit
9b45a239a5
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ export function DealModal() {
|
|||
</p>
|
||||
)}
|
||||
<Button
|
||||
needsServer
|
||||
size="lg"
|
||||
block
|
||||
onClick={() => accept.mutate()}
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ export function GoalsCard() {
|
|||
</span>
|
||||
{ready && (
|
||||
<Button
|
||||
needsServer
|
||||
variant="success"
|
||||
size="sm"
|
||||
disabled={claim.isPending}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)}>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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 })}
|
||||
|
|
|
|||
|
|
@ -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={() =>
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ export function StartScreen() {
|
|||
empire across the solar system.
|
||||
</p>
|
||||
<Button
|
||||
needsServer
|
||||
size="lg"
|
||||
onClick={() => start.mutate()}
|
||||
disabled={start.isPending}
|
||||
|
|
|
|||
|
|
@ -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 })}
|
||||
|
|
|
|||
|
|
@ -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.";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 = "/") {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.**
|
||||
|
|
|
|||
Loading…
Reference in New Issue