diff --git a/CHANGELOG.md b/CHANGELOG.md
index 90e1612..daee047 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,10 @@
# Changelog
+## v0.21.0 — 2026-09-19 21:01
+- **Relocate panel moves to the left pane (ISS-023).** Flying an empty/grounded plane to another airport is "anything with our plane", so it belongs in the left pane, not as a market row on the right. The `.market-row.market-row-relocate` JSX (`relocate-plane` select of grounded planes labeled `{planeName} — {icao}`, `relocate-dest` name-sorted select, `relocate-go` button → `POST /api/planes/{id}/fly {destIcao}`) moved verbatim from `components/RightPane.tsx` into a new "Relocate" `` in `components/LeftPane.tsx`, inserted between On board and Time. Its state (`relocatePlaneId` / `relocateDestIcao`) and the one-time `/api/airports` fetch (promise-ref pattern, GeoJSON → `{name, icao}[]` sorted by name) moved with it — the relocate-dest select was that fetch's only consumer in RightPane, so nothing else there changed. Busy gating rewired from RightPane's `busy` to LeftPane's own `useGameAction` instance (`actionsBusy`); a grounded-plane filter was added next to the existing `onboard` compute; plane labels reuse the expression already inlined in the Fleet rows. Testids kept byte-identical (`relocate-plane` / `relocate-dest` / `relocate-go`) and the row keeps its `.market-row market-row-relocate` classes (global CSS, works in the 360 px left pane); stale "280 px right pane" comment updated in `app/globals.css`. The map-popup relocation controls (`airport-popup-*`, Map.tsx) are a separate surface fed by the snapshot only — untouched
+- Tests: e2e — only `relocation.spec.ts` touches these testids and all locators are global, so no locator changes; the first test's title (which mentioned "right pane") and the file header comments now say left-pane Relocate section. 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.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)
diff --git a/app/globals.css b/app/globals.css
index 25540a3..2c24cdf 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -364,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 280 px right pane (ISS-021 v0.20.0). */
+ squeezing both below their readable width in the 360 px left pane (moved there in ISS-023 v0.21.0). */
.market-row-relocate {
flex-wrap: wrap;
}
diff --git a/components/LeftPane.tsx b/components/LeftPane.tsx
index 23a80d5..de4d211 100644
--- a/components/LeftPane.tsx
+++ b/components/LeftPane.tsx
@@ -1,6 +1,7 @@
'use client';
import { useCallback, useEffect, useRef, useState, type ChangeEvent } from 'react';
+import type { GeoJsonFeatureCollection } from '@/lib/airports';
import type { PlaneState, StateSnapshot } from '@/lib/game/state';
import { PLANE_TYPES } from '@/lib/game/catalog';
import { abandonPenalty } from '@/lib/game/penalty';
@@ -95,6 +96,34 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
// 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);
+ // ISS-023 (v0.21.0): Relocate selections — moved here from the right-pane market row; same
+ // "effective selection" pattern as there: the user's pick while it stays valid, else a sensible
+ // default (first grounded plane / first airport).
+ const [relocatePlaneId, setRelocatePlaneId] = useState(null);
+ const [relocateDestIcao, setRelocateDestIcao] = useState(null);
+
+ // ISS-010 (v0.18.0): one-time /api/airports fetch per page life — destination options for the
+ // Relocate section, sorted by name so e2e selection is stable. Same promise-ref pattern as Map.tsx;
+ // on failure airportOptions stays null and the Relocate button simply remains disabled. Moved here
+ // from RightPane in ISS-023 (v0.21.0) — the relocate-dest select was its only consumer there.
+ const [airportOptions, setAirportOptions] = useState | null>(null);
+ const airportsPromiseRef = useRef> | null>(null);
+ useEffect(() => {
+ if (!airportsPromiseRef.current) {
+ airportsPromiseRef.current = fetch('/api/airports', { cache: 'no-store' })
+ .then((res) => {
+ if (!res.ok) throw new Error(`GET /api/airports → HTTP ${res.status}`);
+ return res.json() as Promise;
+ })
+ .then((fc) =>
+ fc.features
+ .map((f) => ({ name: f.properties.name, icao: f.properties.icao }))
+ .sort((a, b) => a.name.localeCompare(b.name, 'en')),
+ );
+ }
+ void airportsPromiseRef.current.then(setAirportOptions).catch(() => {});
+ }, []);
+
// 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
@@ -168,6 +197,14 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
const clockMs = state?.simState.clockMs ?? 0;
const onboard = state ? state.planes.filter((p) => p.status === 'flying' || slotsUsedFor(state, p.id) > 0) : [];
+ // ISS-023 (v0.21.0): Relocate — any GROUNDED plane can be repositioned to any airport via the
+ // existing fly endpoint (pure repositioning, no package needed since v0.12.0). Moved here from
+ // the right-pane market row; with state null both fall back to null and the section isn't rendered.
+ const grounded = state ? state.planes.filter((p) => p.status === 'available') : [];
+ const relocatePlane = grounded.find((p) => p.id === relocatePlaneId) ?? grounded[0] ?? null;
+ const relocateDest =
+ airportOptions === null ? null : airportOptions.find((a) => a.icao === relocateDestIcao) ?? airportOptions[0] ?? null;
+
// 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
@@ -399,6 +436,53 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
)}
+ {/* ISS-023 (v0.21.0): moved here from the right-pane market row — it belongs with
+ everything else about our planes, between On board and Time. */}
+
+
Relocate
+ {/* ISS-010 (v0.18.0): relocate a grounded plane — the fly endpoint already allows pure
+ repositioning, this row just exposes it for planes with nothing on board. */}
+
+
+
+
+
+
+
Time
diff --git a/components/RightPane.tsx b/components/RightPane.tsx
index fbdbca4..7bf1131 100644
--- a/components/RightPane.tsx
+++ b/components/RightPane.tsx
@@ -1,7 +1,6 @@
'use client';
-import { useEffect, useRef, useState } from 'react';
-import type { GeoJsonFeatureCollection } from '@/lib/airports';
+import { useState } from 'react';
import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state';
import { PLANE_TYPES } from '@/lib/game/catalog';
import { formatCash, formatDeadlineRelative } from '@/lib/format';
@@ -119,31 +118,8 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
const [sellPlaneId, setSellPlaneId] = useState(null);
const [refuelPlaneId, setRefuelPlaneId] = useState(null);
- // ISS-010 (v0.18.0): Relocate row selections — same "effective selection" pattern as the rows above:
- // the user's pick while it stays valid, else a sensible default (first grounded plane / first airport).
- const [relocatePlaneId, setRelocatePlaneId] = useState(null);
- const [relocateDestIcao, setRelocateDestIcao] = useState(null);
-
- // ISS-010 (v0.18.0): one-time /api/airports fetch per page life — destination options for the
- // Relocate row, sorted by name so e2e selection is stable. Same promise-ref pattern as Map.tsx;
- // on failure airportOptions stays null and the Relocate button simply remains disabled.
- const [airportOptions, setAirportOptions] = useState | null>(null);
- const airportsPromiseRef = useRef> | null>(null);
- useEffect(() => {
- if (!airportsPromiseRef.current) {
- airportsPromiseRef.current = fetch('/api/airports', { cache: 'no-store' })
- .then((res) => {
- if (!res.ok) throw new Error(`GET /api/airports → HTTP ${res.status}`);
- return res.json() as Promise;
- })
- .then((fc) =>
- fc.features
- .map((f) => ({ name: f.properties.name, icao: f.properties.icao }))
- .sort((a, b) => a.name.localeCompare(b.name, 'en')),
- );
- }
- void airportsPromiseRef.current.then(setAirportOptions).catch(() => {});
- }, []);
+ // ISS-023 (v0.21.0): the Relocate row moved to the left pane — it belongs with everything else
+ // about our planes, not in the market. Its state + /api/airports fetch went with it.
if (loading && !state) {
return (
@@ -168,12 +144,6 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
state.planes.find((p) => p.id === refuelPlaneId && p.status === 'available' && p.fuel < p.maxFuel) ??
refuelable[0] ??
null;
- // ISS-010 (v0.18.0): Relocate — any GROUNDED plane can be repositioned to any airport via the
- // existing fly endpoint (pure repositioning, no package needed since v0.12.0).
- const relocatePlane = grounded.find((p) => p.id === relocatePlaneId) ?? grounded[0] ?? null;
- const relocateDest =
- airportOptions === null ? null : airportOptions.find((a) => a.icao === relocateDestIcao) ?? airportOptions[0] ?? null;
-
// Previews computed client-side from the snapshot's catalog + economy constants (v0.10.0).
const sellEntry = sellPlane ? catalog.find((e) => e.model === sellPlane.model) : undefined;
const sellPayout = sellEntry ? Math.round(sellEntry.price * state.sellFraction * 100) / 100 : null;
@@ -329,47 +299,7 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
- {/* ISS-010 (v0.18.0): relocate a grounded plane — the fly endpoint already allows pure
- repositioning, this row just exposes it for planes with nothing on board. */}
-
-
-
-
-
+ {/* ISS-023 (v0.21.0): the Relocate row moved to the left pane's "Relocate" section. */}
{actionError &&
{actionError}
}
diff --git a/e2e/relocation.spec.ts b/e2e/relocation.spec.ts
index 844f04e..f5a0554 100644
--- a/e2e/relocation.spec.ts
+++ b/e2e/relocation.spec.ts
@@ -3,7 +3,7 @@ import { expect, test } from '@playwright/test';
// v0.18.0 — ISS-010: relocating a GROUNDED (empty) plane to any airport against the live origin.
// Two UI surfaces drive the EXISTING POST /api/planes/{id}/fly endpoint (pure repositioning has
-// been allowed since v0.12.0 — no API change): the right-pane market row
+// been allowed since v0.12.0 — no API change): the left-pane Relocate section (ISS-023, v0.21.0)
// (relocate-plane / relocate-dest / relocate-go) and the Relocate row inside the airport popup on
// the map (airport-popup-plane / airport-popup-relocate). Marker-size regression is covered by
// the existing e2e/map.spec.ts click tests, not here.
@@ -90,7 +90,7 @@ test.afterEach(async ({ page }) => {
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
-test('market row relocates a grounded plane via the right pane', async ({ page }) => {
+test('left pane Relocate section relocates a grounded plane', async ({ page }) => {
test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + one short leg
await flyHomeViaApi(page);
diff --git a/package.json b/package.json
index 94a51b2..f213c0a 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "flight-ops",
- "version": "0.20.0",
+ "version": "0.21.0",
"private": true,
"scripts": {
"dev": "next dev -p 3000",