diff --git a/CHANGELOG.md b/CHANGELOG.md
index e83f9d1..b93e074 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,12 @@
# Changelog
+## v0.20.0 — 2026-09-19 19:21
+- **On board moved to the left pane (ISS-021).** The "On board" section (per-plane blocks with loaded/expired cargo rows, Refuel-for-leg / Fly-to / Abandon + penalty preview) now renders in `components/LeftPane.tsx` between Fleet and Time — wallet stays on top. The JSX moved verbatim (same testids: `onboard-{planeId}`, `onboard-package-{pkg.id}`, `refuel-for-*`, `fly-to-*`, `abandon-*` + penalty preview), along with the `onboard` computation (`planes.filter(flying || slotsUsedFor > 0)`) and per-plane cargo filter. All onboard e2e locators are global testids, so existing specs work unchanged
+- **Left pane widened: grid swapped to 360/1fr/280.** `.app-shell` in `app/globals.css` goes from `280px minmax(0, 1fr) 360px` to `360px minmax(0, 1fr) 280px` — total fixed width stays 640 px, so the map column is pixel-identical (map.spec.ts clicks via dynamic `map.project()`). Stale comments updated (on-board blocks now in the left pane; Relocate row comment reflects the 280 px right pane)
+- **Shared action runner hook.** RightPane's local `runAction` (busy gate, inline pane error + shell toast on failure, full post-action snapshot pushed via `applySnapshot`) extracted to new `lib/useGameAction.ts`: `useGameAction(state, applySnapshot, onError) → { runAction, busy, actionError }`, exact semantics kept incl. the non-2xx throw/catch and inline-error auto-clear on the next snapshot. Used in BOTH panes; LeftPane receives `applySnapshot` from the shell (`app/page.tsx`). RightPane keeps `[data-testid="right-error"]`; LeftPane gains a matching `[data-testid="left-action-error"]`. Pure helpers `slotsUsedFor` / `loadEligiblePlanes` stay exported from RightPane (unit tests import them there; LeftPane imports `slotsUsedFor` from it); the Market Relocate row + `/api/airports` fetch stay in RightPane
+- Tests: e2e — `panes.spec.ts` first test now also asserts the "On board" heading is visible inside `[data-testid="left-pane"]` and absent from the right pane; stale header comments updated in `actions.spec.ts` / `right-pane.spec.ts`. No unit-test changes (baseline 283)
+- Verified: `tsc --noEmit` clean; unit suite green locally; e2e not run locally by design — CI verifies against an isolated stack
+
## v0.19.2 — 2026-09-19 20:06
- **AGENTS.md "Change process" item 1** — connected issues/tasks that reference the same part of code may now be done together on one branch/PR with a single version bump (e.g. two UI tweaks to the same panel)
- **AGENTS.md "E2E tests" section rewritten to match current reality** — e2e runs ONLY in CI, against an isolated docker stack inside dind (`docker-compose.ci.yml`); there are NO local e2e runs at all (local verification = `tsc --noEmit` + `npx vitest run` + `npx playwright test --list`; the full suite is verified by CI on every PR). Dropped the stale local-run instructions (deploying to the Traefik-routed instance before testing, driving browsers through the remote Playwright server at 192.168.1.4:3000); kept and reworded the CI env vars that actually exist in `playwright.config.ts` (`E2E_BASE_URL`, `E2E_DEPLOY_CMD`, `PW_REMOTE_BROWSER=0`, `E2E_COMPOSE_ARGS`)
diff --git a/app/globals.css b/app/globals.css
index 657085f..d0a6d0d 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -102,10 +102,12 @@ body {
color: #334155;
}
-/* v0.9.0 — single-screen 3-pane shell: left Assets / center map / right Jobs & Market */
+/* v0.9.0 — single-screen 3-pane shell: left Assets / center map / right Jobs & Market.
+ ISS-021 (v0.20.0): left widened to 360px (it now carries the On board section), right
+ narrowed to 280px — total fixed width stays 640px, so the map column is unchanged. */
.app-shell {
display: grid;
- grid-template-columns: 280px minmax(0, 1fr) 360px;
+ grid-template-columns: 360px minmax(0, 1fr) 280px;
height: 100%;
}
@@ -362,7 +364,7 @@ body {
}
/* ISS-010 (v0.18.0) — the Relocate row carries TWO selects, so let it wrap instead of
- squeezing both below their readable width in the 360 px pane. */
+ squeezing both below their readable width in the 280 px right pane (ISS-021 v0.20.0). */
.market-row-relocate {
flex-wrap: wrap;
}
@@ -374,7 +376,7 @@ body {
font-variant-numeric: tabular-nums;
}
-/* v0.12.0 — on-board blocks (per plane) in the right pane */
+/* v0.12.0 — on-board blocks (per plane); moved to the left pane in ISS-021 (v0.20.0) */
.onboard-block {
background: rgba(30, 41, 59, 0.6);
border-radius: 6px;
diff --git a/app/page.tsx b/app/page.tsx
index 5777fb8..5aa721f 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -43,7 +43,9 @@ export default function Home() {
return (
-
+ {/* ISS-021 (v0.20.0) — applySnapshot also reaches the left pane: its On board section's
+ refuel/fly/abandon actions push their response snapshot into the shared state */}
+
{/* v0.11.0 — the same polled snapshot drives the map's plane markers + route lines;
ISS-010 (v0.18.0) — applySnapshot lets the airport popup's Relocate row push its
action response into the shared state, exactly like RightPane does */}
diff --git a/components/LeftPane.tsx b/components/LeftPane.tsx
index 6d73475..858089a 100644
--- a/components/LeftPane.tsx
+++ b/components/LeftPane.tsx
@@ -3,9 +3,12 @@
import { useCallback, useEffect, useRef, useState, type ChangeEvent } from 'react';
import type { PlaneState, StateSnapshot } from '@/lib/game/state';
import { PLANE_TYPES } from '@/lib/game/catalog';
-import { formatCash, formatFuel, formatSimClock } from '@/lib/format';
+import { abandonPenalty } from '@/lib/game/penalty';
+import { formatCash, formatDeadlineRelative, formatFuel, formatSimClock } from '@/lib/format';
import { TIME_SCALES, scaleToSlider, sliderToScale } from '@/lib/timeScale';
+import { useGameAction } from '@/lib/useGameAction';
import { useSimClock } from '@/lib/useSimClock';
+import { slotsUsedFor } from './RightPane';
import type { HoverTarget } from './hoverTypes';
interface LeftPaneProps {
@@ -14,6 +17,9 @@ interface LeftPaneProps {
error: string | null;
// The shell's single-poll refresh — called after a sim-control POST so the pane updates immediately.
onRefresh: () => void;
+ // ISS-021 (v0.20.0): push the full snapshot returned by an action endpoint into the shared shell
+ // state — needed now that the On board section's refuel/fly/abandon actions live in this pane.
+ applySnapshot: (snapshot: StateSnapshot) => void;
// ISS-002 (v0.15.0): report fleet-row hovers to the shell so the map can highlight the plane.
onHover: (target: HoverTarget | null) => void;
// ISS-008 (v0.17.0): report a failed sim-control POST to the shell as an error toast.
@@ -83,9 +89,12 @@ export async function retrySimPost(
return { ok: false, reason: 'exhausted', error: lastError };
}
-export default function LeftPane({ state, loading, error, onRefresh, onHover, onError }: LeftPaneProps) {
+export default function LeftPane({ state, loading, error, onRefresh, applySnapshot, onHover, onError }: LeftPaneProps) {
const [busy, setBusy] = useState(false);
+ // ISS-021 (v0.20.0): shared action runner for the On board section (moved here from RightPane).
+ const { runAction, busy: actionsBusy, actionError } = useGameAction(state, applySnapshot, onError);
+
// POST /api/sim (retried up to SIM_POST_MAX_ATTEMPTS× on failure — CI runs #44/#47), then
// refetch the full snapshot immediately so the pane reflects the new controls without waiting
// for the next poll tick. After the final attempt fails keep the stale UI and surface an error
@@ -153,6 +162,12 @@ export default function LeftPane({ state, loading, error, onRefresh, onHover, on
const paused = state?.simState.paused ?? false;
const timeScale = state?.simState.timeScale ?? 1;
+ // On board (v0.12.0, moved from the right pane in ISS-021 v0.20.0): every plane that carries
+ // cargo or is mid-flight gets a block — its loaded/expired packages with fly-to-destination +
+ // abandon controls, and flying planes show their current leg.
+ const clockMs = state?.simState.clockMs ?? 0;
+ const onboard = state ? state.planes.filter((p) => p.status === 'flying' || slotsUsedFor(state, p.id) > 0) : [];
+
// Slider commit (v0.9.1): the displayed value follows the drag optimistically via
// pendingScale; POST /api/sim fires after a 250 ms debounce, or immediately when the
// user releases the slider (browsers fire a native 'change' on release). The server's
@@ -294,6 +309,94 @@ export default function LeftPane({ state, loading, error, onRefresh, onHover, on
)}
+ {/* ISS-021 (v0.20.0): moved here from the right pane — between Fleet and Time. */}
+
+
On board
+ {onboard.length === 0 ? (
+
Nothing on board.
+ ) : (
+ onboard.map((plane) => {
+ const cargo = state.packages.filter(
+ (pkg) => pkg.planeId === plane.id && (pkg.status === 'loaded' || pkg.status === 'expired'),
+ );
+ return (
+ // ISS-002: hovering the on-board block (including its cargo rows) highlights the plane.
+ // Handlers live only on the outer div — React's enter/leave don't fire when moving to a
+ // child, so this covers the whole block without flicker between sub-elements.
+
@@ -347,6 +450,8 @@ export default function LeftPane({ state, loading, error, onRefresh, onHover, on
{error}
)}
+ {/* ISS-021 (v0.20.0): inline error from a failed On board action — mirrors right-error. */}
+ {actionError &&
{actionError}
}
>
) : null}
diff --git a/components/RightPane.tsx b/components/RightPane.tsx
index 91c9aeb..fbdbca4 100644
--- a/components/RightPane.tsx
+++ b/components/RightPane.tsx
@@ -1,11 +1,11 @@
'use client';
-import { useCallback, useEffect, useRef, useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
import type { GeoJsonFeatureCollection } from '@/lib/airports';
import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state';
import { PLANE_TYPES } from '@/lib/game/catalog';
-import { abandonPenalty } from '@/lib/game/penalty';
import { formatCash, formatDeadlineRelative } from '@/lib/format';
+import { useGameAction } from '@/lib/useGameAction';
import type { HoverTarget } from './hoverTypes';
interface RightPaneProps {
@@ -109,13 +109,9 @@ function LoadControls({
}
export default function RightPane({ state, loading, applySnapshot, onHover, onError }: RightPaneProps) {
- const [busy, setBusy] = useState(false);
- // Inline error from a failed action — auto-clears on the next successful poll (the shell's
- // snapshot prop changes every tick) or the next successful action.
- const [actionError, setActionError] = useState(null);
- useEffect(() => {
- if (state !== null) setActionError(null);
- }, [state]);
+ // ISS-021 (v0.20.0): shared action runner — busy gate, inline pane error + shell toast on
+ // failure, full post-action snapshot pushed into the shared state on success.
+ const { runAction, busy, actionError } = useGameAction(state, applySnapshot, onError);
// Market picker selections — same "effective selection" pattern as LoadControls: the
// user's pick while it stays valid, else a sensible default (first catalog entry / first plane).
@@ -149,36 +145,6 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
void airportsPromiseRef.current.then(setAirportOptions).catch(() => {});
}, []);
- // POST an action endpoint. Every success returns the FULL post-action snapshot (v0.8.0
- // convention) — pushed into the shared state immediately; a 4xx {error} body becomes the
- // inline pane error and the stale UI stays until the next poll. Buttons are disabled while
- // a request is in flight, so at most one action runs at a time.
- const runAction = useCallback(
- async (path: string, body?: unknown) => {
- if (busy) return;
- setBusy(true);
- try {
- const res = await fetch(path, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: body === undefined ? undefined : JSON.stringify(body),
- cache: 'no-store',
- });
- const data = (await res.json()) as StateSnapshot & { error?: string };
- if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
- setActionError(null);
- applySnapshot(data);
- } catch (err) {
- const message = err instanceof Error ? err.message : String(err);
- setActionError(message); // inline pane error stays…
- onError?.(message); // …and the shell mirrors it as a bottom-right toast (ISS-008)
- } finally {
- setBusy(false);
- }
- },
- [applySnapshot, busy, onError],
- );
-
if (loading && !state) {
return (
-
-
On board
- {onboard.length === 0 ? (
-
Nothing on board.
- ) : (
- onboard.map((plane) => {
- const cargo = state.packages.filter(
- (pkg) => pkg.planeId === plane.id && (pkg.status === 'loaded' || pkg.status === 'expired'),
- );
- return (
- // ISS-002: hovering the on-board block (including its cargo rows) highlights the plane.
- // Handlers live only on the outer div — React's enter/leave don't fire when moving to a
- // child, so this covers the whole block without flicker between sub-elements.
-
- );
- })
- )}
-
+ {/* ISS-021 (v0.20.0): the On board section moved to the left pane (between Fleet and Time). */}
Market
diff --git a/e2e/actions.spec.ts b/e2e/actions.spec.ts
index 6faad17..77df59f 100644
--- a/e2e/actions.spec.ts
+++ b/e2e/actions.spec.ts
@@ -3,7 +3,8 @@ import { expect, test } from '@playwright/test';
// v0.12.0 package model against the live origin (https://flight.dev.kkhost.pl):
// the full core loop through the UI — hook creates a short package at EDDF → Load
-// via the right pane → click its destination in the on-board list → fly at 1000× →
+// via the right pane → click its destination in the on-board list (left pane since
+// v0.20.0, ISS-021) → fly at 1000× →
// arrival delivers EXACTLY the reward + rep; abandon deducts exactly the formula
// penalty while grounded; expired packages deliver/abandon with no reward and no
// penalty. Mirrors lib/game/penalty.ts for exact-value assertions.
diff --git a/e2e/panes.spec.ts b/e2e/panes.spec.ts
index c4dfd49..34413c7 100644
--- a/e2e/panes.spec.ts
+++ b/e2e/panes.spec.ts
@@ -69,6 +69,12 @@ test('left pane renders wallet, reputation and the starter fleet at Frankfurt',
// Sim clock renders (UTC wall-clock of the game time).
await expect(page.getByTestId('sim-clock')).toContainText(/UTC/);
+
+ // ISS-021 (v0.20.0): the On board section moved to the left pane (between Fleet and Time) —
+ // its heading renders here…
+ await expect(page.getByTestId('left-pane').getByRole('heading', { name: 'On board', exact: true })).toBeVisible();
+ // …and no longer in the right pane.
+ await expect(page.getByTestId('right-pane').getByRole('heading', { name: 'On board', exact: true })).toHaveCount(0);
});
test('time controls: pause freezes the sim and the ladder slider sets timeScale', async ({ page }) => {
diff --git a/e2e/right-pane.spec.ts b/e2e/right-pane.spec.ts
index 8fd8a89..9014339 100644
--- a/e2e/right-pane.spec.ts
+++ b/e2e/right-pane.spec.ts
@@ -2,8 +2,9 @@ import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
// v0.12.0 — right Jobs & Market pane against the live origin: open packages render with
-// names/reward/deadline and Load (UI click) binds one to the starter; the on-board list shows
-// loaded packages with fly-to-destination + abandon buttons (penalty preview from the same
+// names/reward/deadline and Load (UI click) binds one to the starter; the on-board list
+// (moved to the left pane in v0.20.0, ISS-021) shows loaded packages with fly-to-destination +
+// abandon buttons (penalty preview from the same
// formula as the server); flying planes show their current leg; market buy/sell/refuel previews
// match the catalog + economy constants exposed in /api/state. Packages are created via
// POST /api/test/create-package (FLIGHT_TEST_HOOKS=1), same pattern as e2e/actions.spec.ts.
diff --git a/lib/useGameAction.ts b/lib/useGameAction.ts
new file mode 100644
index 0000000..9af3091
--- /dev/null
+++ b/lib/useGameAction.ts
@@ -0,0 +1,53 @@
+'use client';
+
+import { useCallback, useEffect, useState } from 'react';
+import type { StateSnapshot } from '@/lib/game/state';
+
+// ISS-021 (v0.20.0) — shared action runner for the side panes (extracted verbatim from
+// RightPane.runAction when the On board section moved to LeftPane). POSTs an action endpoint;
+// every success returns the FULL post-action snapshot (v0.8.0 convention), pushed into the
+// shared shell state immediately via applySnapshot so the UI updates without waiting for the
+// next poll tick. A non-2xx {error} body becomes the pane's inline error AND is mirrored to
+// the shell as a bottom-right toast via onError (ISS-008 — both surfaces by design). Buttons
+// are disabled while a request is in flight, so at most one action runs per pane at a time.
+export function useGameAction(
+ state: StateSnapshot | null,
+ applySnapshot: (snapshot: StateSnapshot) => void,
+ onError?: (message: string) => void,
+): { runAction: (path: string, body?: unknown) => Promise; busy: boolean; actionError: string | null } {
+ const [busy, setBusy] = useState(false);
+ // Inline error from a failed action — auto-clears on the next successful poll (the shell's
+ // snapshot prop changes every tick) or the next successful action.
+ const [actionError, setActionError] = useState(null);
+ useEffect(() => {
+ if (state !== null) setActionError(null);
+ }, [state]);
+
+ const runAction = useCallback(
+ async (path: string, body?: unknown) => {
+ if (busy) return;
+ setBusy(true);
+ try {
+ const res = await fetch(path, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: body === undefined ? undefined : JSON.stringify(body),
+ cache: 'no-store',
+ });
+ const data = (await res.json()) as StateSnapshot & { error?: string };
+ if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
+ setActionError(null);
+ applySnapshot(data);
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ setActionError(message); // inline pane error stays…
+ onError?.(message); // …and the shell mirrors it as a bottom-right toast (ISS-008)
+ } finally {
+ setBusy(false);
+ }
+ },
+ [applySnapshot, busy, onError],
+ );
+
+ return { runAction, busy, actionError };
+}
diff --git a/package.json b/package.json
index 8423d54..94a51b2 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "flight-ops",
- "version": "0.19.2",
+ "version": "0.20.0",
"private": true,
"scripts": {
"dev": "next dev -p 3000",