diff --git a/CHANGELOG.md b/CHANGELOG.md
index 898205d..2026f9c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,16 @@
# Changelog
+## v0.9.0 — 2026-09-17 14:24
+- **Single-screen 3-pane shell** (PLAN.md "Layout" increment): `app/page.tsx` is now a CSS grid (`grid-template-columns: 300px minmax(0, 1fr) 320px`) — left Assets pane, center = the existing `` **untouched** (its `.map-wrap` fills the middle column, header included), right placeholder `data-testid="right-pane"` with a subtle "Jobs & Market" heading so the next increment has a home
+- **Single polling source**: new hook `lib/useGameState.ts` polls `GET /api/state` every 1000 ms (configurable arg) and returns `{state, loading, error, refresh}` — overlap guard skips a tick while the previous request is still in flight, interval cleaned up on unmount. Called ONCE in `app/page.tsx`; the snapshot flows down as props so later panes share one poll loop instead of running their own
+- **Left Assets pane** (`components/LeftPane.tsx`, `data-testid="left-pane"`): wallet cash (formatted EUR), reputation, and a fleet list — per plane: catalog display name (ATR 72-600), status badge, fuel bar + `x / y L`, location (grounded → airport name; flying → "origin → destination" from its bound contract). Time controls: pause/resume toggle + 1x–60x scale select wired to `POST /api/sim`; after each action the pane calls the hook's `refresh()` so it refetches immediately instead of waiting for the next poll tick. Sim clock shown as a UTC wall-clock (`data-testid="sim-clock"`)
+- Pure display formatters in new `lib/format.ts` — `formatCash` (EUR, 2 dp), `formatFuel` ("x / y L"), `formatSimClock` (epoch ms → deterministic UTC string)
+- No backend changes: all endpoints pre-existed (`GET /api/state`, `POST /api/sim`); no OpenAPI change; Map.tsx and its header untouched per the increment constraint
+- Tests: unit — new `lib/format.test.ts` (currency/fuel/clock formatting incl. fractional cash + rounding) and `lib/useGameState.test.ts` (`fetchState` success shape + exact fetch args, non-2xx error with status, network failure via mocked global fetch); e2e — new `e2e/panes.spec.ts`: left pane renders wallet (cash > 0)/reputation/starter ATR 72-600 at Frankfurt + sim clock, time controls pause the sim and set scale 60 through the UI (asserted against authoritative `/api/state` AND in-pane button/select state; restored to scale 1 + unpaused in-test and again in `afterEach`, same pattern as sim.spec.ts), right pane placeholder present
+- **Known interaction with v0.4.x map e2e**: the three-pane layout narrows the map column to ~660 px at the default 1280×720 viewport, which puts UKBB Boryspil (~475 px east of the [10,52] center at zoom 4) outside the initial view — `e2e/map.spec.ts` marker-click tests re-pointed from UKBB to **LFRS Nantes Atlantique**, now the most isolated large_airport marker inside the new initial viewport (nearest neighbour LFRB ~78 px away), same pattern as the v0.2.7/v0.2.8 dataset re-points
+- **Pre-existing e2e race fixed** (found while landing this increment, reproduced 5/5 on a clean v0.8.0 worktree deploy — NOT caused by the layout): MapLibre's diff-based `setStyle` removes our custom airports source/layer before `ensureAirportsLayer` re-adds it on the next `styledata` event (~20–40 ms window), and the style-switcher test's post-switch wait (`queryRenderedFeatures > 0`) can be satisfied by stale pre-switch worker data, so its single marker click could land in that gap. The test now retries the click until the popup opens (marker is clickable again within milliseconds; the `toHaveCount(1)` assertion still guards against duplicate listeners)
+- Verified: unit suite 143 → 150 green; `tsc --noEmit` clean; e2e 18 → 21 green against the rebuilt compose instance (marker-click, zoom clamp and style switch all pass in the narrower middle column)
+
## v0.8.0 — 2026-09-17 13:54
- **Player-actions API** (PLAN.md "Core loop" increment, server-side only — NO frontend/UI changes): the core loop now works end-to-end through five new endpoints in `lib/game/actions.ts` + `app/api/`. Every action endpoint follows one convention: tick the sim clock FIRST (so validation/mutation see current game time), validate, mutate, persist atomically under the same promise lock as ticks (`engine.runAction()` — no request can interleave between tick and mutation), then return the **FULL state snapshot** (same shape as GET /api/state) with 200 so clients never need a follow-up poll; errors are proper 4xx `{error}` bodies
- **Accept**: `POST /api/contracts/:id/accept` — open → accepted only. 404 unknown id, 409 if the contract is not 'open' (accepted/completed/expired). Single-player, so no ownership tracking beyond the status flip; dispatch binds the plane
diff --git a/app/globals.css b/app/globals.css
index dd51e60..4eb251a 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -67,3 +67,171 @@ body {
font-size: 12px;
color: #cbd5e1;
}
+
+/* v0.9.0 — single-screen 3-pane shell: left Assets / center map / right Jobs & Market */
+.app-shell {
+ display: grid;
+ grid-template-columns: 300px minmax(0, 1fr) 320px;
+ height: 100%;
+}
+
+.pane {
+ background: #0f172a;
+ color: #e2e8f0;
+ padding: 14px;
+ font-size: 13px;
+ overflow-y: auto;
+}
+
+.left-pane {
+ border-right: 1px solid rgba(148, 163, 184, 0.25);
+}
+
+.right-pane {
+ border-left: 1px solid rgba(148, 163, 184, 0.25);
+}
+
+.pane-title {
+ margin: 0 0 12px;
+ font-size: 14px;
+ font-weight: 600;
+ color: #f8fafc;
+}
+
+.pane-heading {
+ margin: 0 0 8px;
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: #94a3b8;
+}
+
+.pane-section {
+ margin-bottom: 16px;
+}
+
+.pane-muted {
+ color: #94a3b8;
+ font-size: 12px;
+}
+
+.pane-error {
+ color: #fca5a5;
+ font-size: 12px;
+}
+
+.stat-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ padding: 2px 0;
+}
+
+.stat-row span {
+ color: #94a3b8;
+ font-size: 12px;
+}
+
+.stat-row strong {
+ font-size: 13px;
+ color: #f8fafc;
+}
+
+.fleet-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.plane-row {
+ background: rgba(30, 41, 59, 0.6);
+ border-radius: 6px;
+ padding: 8px;
+}
+
+.plane-main {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ gap: 8px;
+}
+
+.plane-model {
+ font-weight: 600;
+ color: #f1f5f9;
+}
+
+.plane-status {
+ font-size: 11px;
+ padding: 1px 6px;
+ border-radius: 4px;
+ background: rgba(148, 163, 184, 0.2);
+ color: #cbd5e1;
+}
+
+.status-flying {
+ background: rgba(59, 130, 246, 0.25);
+ color: #bfdbfe;
+}
+
+.fuel-bar {
+ height: 4px;
+ border-radius: 2px;
+ background: rgba(148, 163, 184, 0.25);
+ margin: 6px 0 4px;
+ overflow: hidden;
+}
+
+.fuel-bar > div {
+ height: 100%;
+ border-radius: 2px;
+ background: #34d399;
+}
+
+.plane-sub {
+ display: flex;
+ justify-content: space-between;
+ gap: 8px;
+ font-size: 11px;
+ color: #cbd5e1;
+}
+
+.plane-loc {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.sim-clock {
+ margin: 0 0 8px;
+ font-size: 12px;
+ color: #cbd5e1;
+ font-variant-numeric: tabular-nums;
+}
+
+.time-controls {
+ display: flex;
+ gap: 8px;
+}
+
+.time-controls button,
+.time-controls select {
+ font-size: 12px;
+ padding: 4px 10px;
+ border-radius: 4px;
+ border: 1px solid rgba(148, 163, 184, 0.4);
+ background: rgba(30, 41, 59, 0.9);
+ color: #e2e8f0;
+}
+
+.time-controls button {
+ cursor: pointer;
+}
+
+.time-controls select {
+ flex: 1;
+}
diff --git a/app/page.tsx b/app/page.tsx
index 8a6d19d..ffe534e 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,9 +1,23 @@
'use client';
import dynamic from 'next/dynamic';
+import LeftPane from '@/components/LeftPane';
+import { useGameState } from '@/lib/useGameState';
const Map = dynamic(() => import('@/components/Map'), { ssr: false });
export default function Home() {
- return ;
+ // Single polling source for the whole shell (v0.9.0): one /api/state loop, snapshot passed
+ // down as props so later panes can share it without extra poll loops.
+ const { state, loading, error, refresh } = useGameState(1000);
+
+ return (
+
+ );
}
diff --git a/components/LeftPane.tsx b/components/LeftPane.tsx
new file mode 100644
index 0000000..e4f74ec
--- /dev/null
+++ b/components/LeftPane.tsx
@@ -0,0 +1,137 @@
+'use client';
+
+import { useState, type ChangeEvent } from 'react';
+import type { ContractState, PlaneState, StateSnapshot } from '@/lib/game/state';
+import { PLANE_TYPES } from '@/lib/game/catalog';
+import { formatCash, formatFuel, formatSimClock } from '@/lib/format';
+
+interface LeftPaneProps {
+ state: StateSnapshot | null;
+ loading: boolean;
+ error: string | null;
+ // The shell's single-poll refresh — called after a sim-control POST so the pane updates immediately.
+ onRefresh: () => void;
+}
+
+const SCALE_OPTIONS = Array.from({ length: 60 }, (_, i) => i + 1); // 1x–60x, matching POST /api/sim's clamp range
+
+function planeLocation(p: PlaneState, contracts: ContractState[]): string {
+ if (p.status === 'flying' && p.contractId) {
+ const c = contracts.find((c) => c.id === p.contractId);
+ if (c) return `${p.locationName} → ${c.destName}`;
+ }
+ return p.locationName;
+}
+
+export default function LeftPane({ state, loading, error, onRefresh }: LeftPaneProps) {
+ const [busy, setBusy] = useState(false);
+
+ // POST /api/sim, then refetch the full snapshot immediately so the pane reflects the new
+ // controls without waiting for the next poll tick. On failure keep the stale UI — the
+ // running poll retries on its own and surfaces the error state.
+ const applySimControls = async (body: { timeScale?: number; paused?: boolean }) => {
+ if (busy) return;
+ setBusy(true);
+ try {
+ const res = await fetch('/api/sim', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ cache: 'no-store',
+ });
+ if (!res.ok) throw new Error(`POST /api/sim → HTTP ${res.status}`);
+ onRefresh();
+ } catch (err) {
+ console.error('failed to apply sim controls', err);
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const paused = state?.simState.paused ?? false;
+ const timeScale = state?.simState.timeScale ?? 1;
+
+ return (
+
+ );
+}
diff --git a/e2e/map.spec.ts b/e2e/map.spec.ts
index 4b394d0..257bb5c 100644
--- a/e2e/map.spec.ts
+++ b/e2e/map.spec.ts
@@ -51,15 +51,15 @@ test('clicking an airport marker opens a popup with name and ICAO', async ({ pag
{ timeout: 15_000 },
).toBeGreaterThan(0);
- // Project UKBB (Boryspil) to screen space via the dev hook, then click it.
- // Boryspil is used because with only large_airport rows remaining it is the
- // most isolated marker inside the initial zoom-4 viewport: its nearest
- // surviving airport is Chişinău (LUKK), ~126 px away at the initial zoom
- // (Kyiv Zhuliany, geographically closer, was a medium_airport and got cut),
- // so its marker cannot be overlapped by a neighbouring circle.
+ // Project LFRS (Nantes Atlantique) to screen space via the dev hook, then click it.
+ // Nantes is used because the v0.9.0 three-pane layout narrows the map column to ~660 px at
+ // the default 1280×720 viewport, which puts UKBB Boryspil (~475 px east of the [10,52] center)
+ // outside the initial view; LFRS is now the most isolated large_airport marker inside it — its
+ // nearest neighbour is LFRB Nantes–Montoir ~78 px away at the initial zoom, so its marker cannot
+ // be overlapped by a neighbouring circle.
const pt = await page.evaluate(() => {
const map = (window as any).__flightMap;
- const p = map.project([30.894699, 50.345001]);
+ const p = map.project([-1.61073, 47.153198]); // LFRS Nantes Atlantique — see the marker-click test above for why
const rect = map.getContainer().getBoundingClientRect();
return { x: rect.left + p.x, y: rect.top + p.y };
});
@@ -67,8 +67,8 @@ test('clicking an airport marker opens a popup with name and ICAO', async ({ pag
const popup = page.getByTestId('airport-popup');
await expect(popup).toBeVisible();
- await expect(popup).toContainText('Boryspil International Airport');
- await expect(popup).toContainText('UKBB');
+ await expect(popup).toContainText('Nantes Atlantique Airport');
+ await expect(popup).toContainText('LFRS');
});
test('wheel zoom is clamped between min and max zoom', async ({ page }) => {
@@ -185,14 +185,21 @@ test('style switcher defaults to Fiord, switches styles, and keeps the airports
await expect.poll(renderedCircles, { timeout: 15_000 }).toBeGreaterThan(0);
// Clicking a marker after the switch opens exactly one popup (the click handler is
- // re-wired idempotently — no duplicate listeners).
- const pt = await page.evaluate(() => {
- const map = (window as any).__flightMap;
- const p = map.project([30.894699, 50.345001]); // UKBB Boryspil
- const rect = map.getContainer().getBoundingClientRect();
- return { x: rect.left + p.x, y: rect.top + p.y };
- });
- await page.mouse.click(pt.x, pt.y);
+ // re-wired idempotently — no duplicate listeners). MapLibre's diff-based setStyle removes our
+ // custom source/layer before ensureAirportsLayer re-adds it on the next styledata event, so
+ // retry the click until the popup opens instead of asserting on a single shot (the marker is
+ // clickable again within milliseconds; toHaveCount(1) below still catches duplicate listeners).
+ await expect.poll(async () => {
+ const pt = await page.evaluate(() => {
+ const map = (window as any).__flightMap;
+ const p = map.project([-1.61073, 47.153198]); // LFRS Nantes Atlantique — see the marker-click test above for why
+ const rect = map.getContainer().getBoundingClientRect();
+ return { x: rect.left + p.x, y: rect.top + p.y };
+ });
+ await page.mouse.click(pt.x, pt.y);
+ return (await page.getByTestId('airport-popup').count()) > 0;
+ }, { timeout: 15_000 }).toBe(true);
+
const popups = page.getByTestId('airport-popup');
await expect(popups.first()).toBeVisible();
await expect(popups).toHaveCount(1);
@@ -248,7 +255,7 @@ test('falls back to remote OFM styles when the local asset pack is absent', asyn
const pt = await page.evaluate(() => {
const map = (window as any).__flightMap;
- const p = map.project([30.894699, 50.345001]); // UKBB Boryspil
+ const p = map.project([-1.61073, 47.153198]); // LFRS Nantes Atlantique — see the marker-click test above for why
const rect = map.getContainer().getBoundingClientRect();
return { x: rect.left + p.x, y: rect.top + p.y };
});
@@ -256,6 +263,6 @@ test('falls back to remote OFM styles when the local asset pack is absent', asyn
const popup = page.getByTestId('airport-popup');
await expect(popup).toBeVisible();
- await expect(popup).toContainText('Boryspil International Airport');
- await expect(popup).toContainText('UKBB');
+ await expect(popup).toContainText('Nantes Atlantique Airport');
+ await expect(popup).toContainText('LFRS');
});
diff --git a/e2e/panes.spec.ts b/e2e/panes.spec.ts
new file mode 100644
index 0000000..fdd7d5e
--- /dev/null
+++ b/e2e/panes.spec.ts
@@ -0,0 +1,87 @@
+import { test, expect, type Page } from '@playwright/test';
+
+// v0.9.0 — single-screen 3-pane shell + left Assets pane against the live origin.
+// The panes render from the shell's single /api/state poll (lib/useGameState.ts); time
+// controls POST to /api/sim and refetch immediately, so UI assertions settle within a tick.
+
+interface SimState {
+ clockMs: number;
+ timeScale: number;
+ paused: boolean;
+}
+
+interface StateBody {
+ account: { cash: number; reputation: number };
+ simState: SimState;
+ planes: Array>;
+ contracts: Array>;
+}
+
+async function getState(page: Page): Promise {
+ const res = await page.request.get('/api/state');
+ expect(res.status()).toBe(200);
+ return (await res.json()) as StateBody;
+}
+
+// Leave the live instance in a sane state for the other specs (scale 1, running) — same
+// pattern as e2e/sim.spec.ts.
+test.afterEach(async ({ page }) => {
+ const res = await page.request.post('/api/sim', { data: { timeScale: 1, paused: false } });
+ expect(res.status()).toBe(200);
+});
+
+test('left pane renders wallet, reputation and the starter fleet at Frankfurt', async ({ page }) => {
+ await page.goto('/');
+ await expect(page.getByTestId('left-pane')).toBeVisible();
+
+ // Wallet: cash > 0 (formatted EUR) — poll until the first snapshot lands.
+ const cashEl = page.getByTestId('wallet-cash');
+ await expect(cashEl).toBeVisible({ timeout: 15_000 });
+ const cashText = (await cashEl.textContent()) ?? '';
+ expect(Number(cashText.replace(/[^0-9.]/g, ''))).toBeGreaterThan(0);
+
+ // Reputation present and numeric.
+ await expect(page.getByTestId('reputation')).toBeVisible();
+ expect(Number((await page.getByTestId('reputation').textContent()) ?? 'NaN')).toBeGreaterThanOrEqual(0);
+
+ // Fleet: the starter ATR 72-600 at Frankfurt (EDDF).
+ const row = page.getByTestId('fleet-list').locator('.plane-row').first();
+ await expect(row, 'starter plane row appears once the first snapshot lands').toBeVisible({ timeout: 15_000 });
+ await expect(row).toContainText('ATR 72-600');
+ await expect(row).toContainText('Frankfurt');
+
+ // Sim clock renders (UTC wall-clock of the game time).
+ await expect(page.getByTestId('sim-clock')).toContainText(/UTC/);
+});
+
+test('time controls: pause freezes the sim and the scale selector sets timeScale=60', async ({ page }) => {
+ await page.goto('/');
+ const pauseBtn = page.getByTestId('pause-toggle');
+ await expect(pauseBtn).toBeVisible({ timeout: 15_000 });
+
+ // Pause via the UI button — assert against the authoritative /api/state…
+ await pauseBtn.click();
+ await expect.poll(async () => (await getState(page)).simState.paused, { timeout: 10_000 }).toBe(true);
+ // …and in the pane itself (the refetch after POST flips the button to Resume).
+ await expect(pauseBtn).toHaveText('Resume');
+
+ // Scale via the UI select.
+ const scaleSelect = page.getByTestId('time-scale-select');
+ await scaleSelect.selectOption('60');
+ await expect.poll(async () => (await getState(page)).simState.timeScale, { timeout: 10_000 }).toBe(60);
+ await expect(scaleSelect).toHaveValue('60');
+
+ // Restore the live instance to scale 1 + unpaused (afterEach enforces this too).
+ const restore = await page.request.post('/api/sim', { data: { timeScale: 1, paused: false } });
+ expect(restore.status()).toBe(200);
+ const after = await getState(page);
+ expect(after.simState.timeScale).toBe(1);
+ expect(after.simState.paused).toBe(false);
+});
+
+test('right pane placeholder exists with the Jobs & Market heading', async ({ page }) => {
+ await page.goto('/');
+ const right = page.getByTestId('right-pane');
+ await expect(right).toBeVisible();
+ await expect(right).toContainText('Jobs & Market');
+});
diff --git a/lib/format.test.ts b/lib/format.test.ts
new file mode 100644
index 0000000..b390cbe
--- /dev/null
+++ b/lib/format.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from 'vitest';
+import { formatCash, formatFuel, formatSimClock } from './format';
+
+describe('formatCash', () => {
+ it('formats EUR with thousands separators and two decimals', () => {
+ expect(formatCash(20_000)).toBe('€20,000.00');
+ expect(formatCash(1_234_567.891)).toBe('€1,234,567.89');
+ });
+
+ it('handles zero and fractional values (refuel costs are fractional)', () => {
+ expect(formatCash(0)).toBe('€0.00');
+ expect(formatCash(30.4)).toBe('€30.40');
+ });
+});
+
+describe('formatFuel', () => {
+ it('shows current / max liters with the current value rounded', () => {
+ expect(formatFuel(6300, 6300)).toBe('6300 / 6300 L');
+ expect(formatFuel(1234.6, 6300)).toBe('1235 / 6300 L');
+ expect(formatFuel(0, 6300)).toBe('0 / 6300 L');
+ });
+});
+
+describe('formatSimClock', () => {
+ it('formats epoch ms as a UTC wall-clock string (deterministic across timezones)', () => {
+ expect(formatSimClock(Date.UTC(2026, 8, 17, 14, 5))).toBe('2026-09-17 14:05 UTC');
+ expect(formatSimClock(Date.UTC(2026, 0, 1, 0, 0))).toBe('2026-01-01 00:00 UTC');
+ });
+});
diff --git a/lib/format.ts b/lib/format.ts
new file mode 100644
index 0000000..39bc39a
--- /dev/null
+++ b/lib/format.ts
@@ -0,0 +1,20 @@
+// Pure display formatters for the client panes (v0.9.0) — no game logic, unit-testable in node.
+
+const CURRENCY = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'EUR' });
+
+export function formatCash(value: number): string {
+ return CURRENCY.format(value);
+}
+
+// "6300 / 6300 L" — current value rounded (live fuel is fractional mid-flight).
+export function formatFuel(fuel: number, maxFuel: number): string {
+ return `${Math.round(fuel)} / ${maxFuel} L`;
+}
+
+// The sim clock is epoch ms (seeded from real time) — shown as a UTC wall-clock so the
+// display is deterministic regardless of the viewer's timezone.
+export function formatSimClock(clockMs: number): string {
+ const d = new Date(clockMs);
+ const pad = (n: number) => String(n).padStart(2, '0');
+ return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())} UTC`;
+}
diff --git a/lib/useGameState.test.ts b/lib/useGameState.test.ts
new file mode 100644
index 0000000..9fb4707
--- /dev/null
+++ b/lib/useGameState.test.ts
@@ -0,0 +1,39 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { fetchState } from './useGameState';
+
+// The hook's fetch logic (lib/useGameState.ts) against a mocked global fetch — the polling
+// loop itself is plain setInterval and covered by e2e (panes.spec.ts renders from it).
+
+const SNAPSHOT = {
+ account: { cash: 20_000, reputation: 10 },
+ simState: { clockMs: 1_750_000_000_000, timeScale: 1, paused: false },
+ planes: [],
+ contracts: [],
+};
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe('fetchState', () => {
+ it('GETs /api/state with no-store and returns the parsed snapshot on success', async () => {
+ const fetchMock = vi.fn(async () => ({ ok: true, status: 200, json: async () => SNAPSHOT }));
+ vi.stubGlobal('fetch', fetchMock);
+
+ await expect(fetchState()).resolves.toEqual(SNAPSHOT);
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ expect(fetchMock).toHaveBeenCalledWith('/api/state', { cache: 'no-store' });
+ });
+
+ it('throws with the HTTP status on a non-2xx response', async () => {
+ vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 500, json: async () => ({}) })));
+ await expect(fetchState()).rejects.toThrow('GET /api/state → HTTP 500');
+ });
+
+ it('throws on a network failure (no response at all)', async () => {
+ vi.stubGlobal('fetch', vi.fn(async () => {
+ throw new Error('fetch failed');
+ }));
+ await expect(fetchState()).rejects.toThrow('fetch failed');
+ });
+});
diff --git a/lib/useGameState.ts b/lib/useGameState.ts
new file mode 100644
index 0000000..9814a33
--- /dev/null
+++ b/lib/useGameState.ts
@@ -0,0 +1,55 @@
+'use client';
+
+import { useCallback, useEffect, useRef, useState } from 'react';
+import type { StateSnapshot } from './game/state';
+
+export interface GameStateResult {
+ state: StateSnapshot | null;
+ // True until the first successful fetch (stale-while-error afterwards).
+ loading: boolean;
+ error: string | null;
+ // Trigger an immediate refetch (used after POST /api/sim so the UI updates without waiting for the next tick).
+ refresh: () => void;
+}
+
+// Single fetch of the full game-state snapshot. Exported separately from the hook so it can be unit-tested with a mocked fetch.
+export async function fetchState(): Promise {
+ const res = await fetch('/api/state', { cache: 'no-store' });
+ if (!res.ok) throw new Error(`GET /api/state → HTTP ${res.status}`);
+ return (await res.json()) as StateSnapshot;
+}
+
+// The shell's single polling source (v0.9.0): one GET /api/state loop for the whole page,
+// so every pane renders from the same snapshot instead of running its own poll loops.
+export function useGameState(intervalMs = 1000): GameStateResult {
+ const [state, setState] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ // Overlap guard: if the previous request is still in flight (slow server), skip this tick.
+ const inFlightRef = useRef(false);
+
+ const refresh = useCallback(() => {
+ if (inFlightRef.current) return;
+ inFlightRef.current = true;
+ fetchState()
+ .then((snapshot) => {
+ setState(snapshot);
+ setError(null);
+ setLoading(false);
+ })
+ .catch((err: unknown) => {
+ setError(err instanceof Error ? err.message : String(err));
+ })
+ .finally(() => {
+ inFlightRef.current = false;
+ });
+ }, []);
+
+ useEffect(() => {
+ refresh(); // fetch immediately on mount, don't wait for the first interval tick
+ const id = setInterval(refresh, intervalMs);
+ return () => clearInterval(id);
+ }, [intervalMs, refresh]);
+
+ return { state, loading, error, refresh };
+}
diff --git a/package.json b/package.json
index 10d9c1a..52ec511 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "flight-ops",
- "version": "0.8.0",
+ "version": "0.9.0",
"private": true,
"scripts": {
"dev": "next dev -p 3000",