From 9b45a239a5748e2d63b5f2ea260860650ab066d5 Mon Sep 17 00:00:00 2001
From: fireshaper
Date: Mon, 5 Oct 2026 18:13:31 -0400
Subject: [PATCH] Step 11.2: offline shell behaviour
Co-Authored-By: Claude Opus 5.5
---
DECISIONS.md | 8 ++
apps/web/e2e/offline.e2e.ts | 139 +++++++++++++++++++++++
apps/web/src/App.tsx | 47 +++++---
apps/web/src/clock/clock.ts | 26 ++++-
apps/web/src/components/DealModal.tsx | 1 +
apps/web/src/components/GoalsCard.tsx | 1 +
apps/web/src/components/LoanCard.tsx | 6 +-
apps/web/src/screens/BackupSettings.tsx | 22 +++-
apps/web/src/screens/CompanySettings.tsx | 12 +-
apps/web/src/screens/DevTools.tsx | 11 +-
apps/web/src/screens/DispatchSheet.tsx | 7 +-
apps/web/src/screens/Fleet.tsx | 5 +-
apps/web/src/screens/LocationBoards.tsx | 8 +-
apps/web/src/screens/Locations.tsx | 1 +
apps/web/src/screens/Market.tsx | 2 +
apps/web/src/screens/Pilots.tsx | 7 +-
apps/web/src/screens/ShipUpgrades.tsx | 1 +
apps/web/src/screens/StartScreen.tsx | 1 +
apps/web/src/screens/TutorialBoard.tsx | 1 +
apps/web/src/state/actions.ts | 12 +-
apps/web/src/state/connection.ts | 79 +++++++++++++
apps/web/src/state/player.ts | 34 +++++-
apps/web/src/ui/Button.tsx | 16 ++-
apps/web/test/fixtures.ts | 76 +++++++++++++
apps/web/test/offline.test.tsx | 73 ++++++++++++
apps/web/test/render.tsx | 75 +-----------
apps/web/test/setup.ts | 3 +
apps/web/test/states.test.tsx | 4 +-
memory-bank/architecture.md | 9 +-
memory-bank/progress.md | 4 +-
30 files changed, 570 insertions(+), 121 deletions(-)
create mode 100644 apps/web/e2e/offline.e2e.ts
create mode 100644 apps/web/src/state/connection.ts
create mode 100644 apps/web/test/fixtures.ts
create mode 100644 apps/web/test/offline.test.tsx
diff --git a/DECISIONS.md b/DECISIONS.md
index 9c2b050..a8242fc 100644
--- a/DECISIONS.md
+++ b/DECISIONS.md
@@ -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.
diff --git a/apps/web/e2e/offline.e2e.ts b/apps/web/e2e/offline.e2e.ts
new file mode 100644
index 0000000..f82d8fc
--- /dev/null
+++ b/apps/web/e2e/offline.e2e.ts
@@ -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();
+ });
+});
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index 3bcf6be..d822237 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -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 ;
- // A later refresh failed: keep showing the last state, with a way to retry.
- const banner = state.isError ? (
-
- Offline — showing last update.
-
-
- ) : null;
+ // Offline, or a later refresh failed: keep showing the last state (Step 11.2).
+ const banner =
+ !online || state.isError ? (
+
+
+ Offline — showing last update
+ {lastUpdate !== null && ` from ${formatTimeOfDay(lastUpdate, clockFormat)}`}.
+
+
+
+ ) : null;
return (
<>
@@ -113,8 +125,9 @@ export function App() {
} />
-
-
+ {/* Both exist to send something to the server: wait until back online. */}
+ {online && }
+ {online && }
>
);
}
diff --git a/apps/web/src/clock/clock.ts b/apps/web/src/clock/clock.ts
index c9136a4..b39fcd1 100644
--- a/apps/web/src/clock/clock.ts
+++ b/apps/web/src/clock/clock.ts
@@ -10,11 +10,22 @@ interface ClockState {
now: RealTime;
}
-export const useClock = create(() => ({
- 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(() => {
+ 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 {
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. */
diff --git a/apps/web/src/components/DealModal.tsx b/apps/web/src/components/DealModal.tsx
index b547a84..0b034db 100644
--- a/apps/web/src/components/DealModal.tsx
+++ b/apps/web/src/components/DealModal.tsx
@@ -85,6 +85,7 @@ export function DealModal() {
)}