diff --git a/CHANGELOG.md b/CHANGELOG.md
index a262b65..b5a36e8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,10 @@
# Changelog
+## v0.23.0 — 2026-09-20 08:25
+- **Refuel-to-full row moves to the left pane (ISS-035).** Refueling a grounded plane is "anything with our plane", so it belongs in the left pane next to Relocate, not as a market row on the right. The `.market-row` JSX (`market-refuel-none` "All tanks full." span / `market-refuel-plane` select of grounded planes labeled `{planeName} — {fuel}/{maxFuel} L`, `market-refuel-cost` preview, `market-refuel` button → `POST /api/planes/{id}/refuel`) moved verbatim from the Market section in `components/RightPane.tsx` into a new "Refuel" `` in `components/LeftPane.tsx`, inserted between Relocate and Time. Its state (`refuelPlaneId`) + derived values (`refuelable`, effective-selection `refuelPlane`, `refuelCost`) moved with it, reusing LeftPane's existing `grounded` list; busy gating rewired from RightPane's `busy` to LeftPane's own `useGameAction` instance (`actionsBusy`). Testids kept byte-identical so all e2e locators are unchanged. The per-package "Refuel" button in the On board section (`refuel-for-{pkg.id}`, mode 'forFlight') is a different feature and stays put; exported helpers `slotsUsedFor` / `loadEligiblePlanes` untouched
+- Tests: e2e — `right-pane.spec.ts`: the market test keeps its buy/sell assertions, renamed to reflect that the refuel row now renders inside `[data-testid="left-pane"]` (asserted before the click); functional cash/tank assertions unchanged; the for-leg test's title/comment updated from "market button" to left pane. `panes.spec.ts`: first test gains structural assertions — Refuel heading + `market-refuel` visible in the left pane, both absent from the right pane (same present-in-left / absent-from-right pattern as On board). No unit-test changes (suite stays at 288)
+- Verified: `tsc --noEmit` clean; unit suite green locally (288); `npx playwright test --list` = 41 tests in 12 files; e2e not run locally by design — CI verifies against an isolated stack
+
## v0.22.2 — 2026-09-20 07:32
- Docs: ORCHESTRATOR.md successor-agent manual committed to the repo; its token reference now points to the gitignored local .env (GITEA_TOKEN, rotated after the Sept 2026 incident); §9 rewritten as durable 'how to find current state' guidance instead of a stale snapshot; no code changes
diff --git a/components/LeftPane.tsx b/components/LeftPane.tsx
index c6b6538..382d608 100644
--- a/components/LeftPane.tsx
+++ b/components/LeftPane.tsx
@@ -102,6 +102,11 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
const [relocatePlaneId, setRelocatePlaneId] = useState(null);
const [relocateDestIcao, setRelocateDestIcao] = useState(null);
+ // ISS-035 (v0.23.0): Refuel-to-full selection — moved here from the right-pane market row; same
+ // "effective selection" pattern as Relocate above: the user's pick while it stays refuelable, else
+ // a sensible default (first grounded plane with fuel below max).
+ const [refuelPlaneId, setRefuelPlaneId] = 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
@@ -205,6 +210,16 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
const relocateDest =
airportOptions === null ? null : airportOptions.find((a) => a.icao === relocateDestIcao) ?? airportOptions[0] ?? null;
+ // ISS-035 (v0.23.0): Refuel-to-full — any GROUNDED plane with fuel below max can be topped up via the
+ // existing refuel endpoint (no body → server defaults to mode 'full'). Moved here from the right-pane
+ // market row; reuses `grounded` above, and with state null falls back to null.
+ const refuelable = grounded.filter((p) => p.fuel < p.maxFuel);
+ const refuelPlane =
+ (state?.planes.find((p) => p.id === refuelPlaneId && p.status === 'available' && p.fuel < p.maxFuel)) ??
+ refuelable[0] ??
+ null;
+ const refuelCost = refuelPlane && state ? (refuelPlane.maxFuel - refuelPlane.fuel) * state.fuelPricePerL : 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
@@ -507,6 +522,50 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
+ {/* ISS-035 (v0.23.0): moved here from the right-pane market row — it belongs with everything else
+ about our planes, next to Relocate. Plain section: no hover handlers (refuel is not a route). */}
+
+
Refuel
+ {/* Moved verbatim from the right-pane Market section — same testids/labels; the per-package
+ "Refuel" button in On board (mode 'forFlight') is a different feature and stays there. */}
+
diff --git a/components/RightPane.tsx b/components/RightPane.tsx
index 7bf1131..8f0ac0b 100644
--- a/components/RightPane.tsx
+++ b/components/RightPane.tsx
@@ -116,10 +116,11 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
// user's pick while it stays valid, else a sensible default (first catalog entry / first plane).
const [buyModelId, setBuyModelId] = useState(null);
const [sellPlaneId, setSellPlaneId] = useState(null);
- const [refuelPlaneId, setRefuelPlaneId] = useState(null);
// 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.
+ // ISS-035 (v0.23.0): same for the Refuel-to-full row — its state + derived values moved to the
+ // left pane's "Refuel" section next to Relocate.
if (loading && !state) {
return (
@@ -139,15 +140,9 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
const grounded = state.planes.filter((p) => p.status === 'available');
const sellPlane = state.planes.find((p) => p.id === sellPlaneId && p.status === 'available') ?? grounded[0] ?? null;
- const refuelable = grounded.filter((p) => p.fuel < p.maxFuel);
- const refuelPlane =
- state.planes.find((p) => p.id === refuelPlaneId && p.status === 'available' && p.fuel < p.maxFuel) ??
- refuelable[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;
- const refuelCost = refuelPlane ? (refuelPlane.maxFuel - refuelPlane.fuel) * state.fuelPricePerL : null;
return (
{actionError &&
{actionError}
}
diff --git a/e2e/panes.spec.ts b/e2e/panes.spec.ts
index 34413c7..efe108a 100644
--- a/e2e/panes.spec.ts
+++ b/e2e/panes.spec.ts
@@ -75,6 +75,14 @@ test('left pane renders wallet, reputation and the starter fleet at Frankfurt',
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);
+
+ // ISS-035 (v0.23.0): the Refuel-to-full row moved to the left pane next to Relocate — its section
+ // heading and button render here…
+ await expect(page.getByTestId('left-pane').getByRole('heading', { name: 'Refuel', exact: true })).toBeVisible();
+ await expect(page.getByTestId('left-pane').getByTestId('market-refuel')).toBeVisible();
+ // …and neither the heading nor the row remains in the right pane.
+ await expect(page.getByTestId('right-pane').getByRole('heading', { name: 'Refuel', exact: true })).toHaveCount(0);
+ await expect(page.getByTestId('right-pane').getByTestId('market-refuel')).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 9014339..6a19213 100644
--- a/e2e/right-pane.spec.ts
+++ b/e2e/right-pane.spec.ts
@@ -5,8 +5,9 @@ import { expect, test } from '@playwright/test';
// 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
+// formula as the server); flying planes show their current leg; market buy/sell previews match
+// the catalog + economy constants exposed in /api/state, and the Refuel-to-full row (moved to the
+// left pane in v0.23.0, ISS-035) deducts exactly its cost. Packages are created via
// POST /api/test/create-package (FLIGHT_TEST_HOOKS=1), same pattern as e2e/actions.spec.ts.
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time
@@ -300,7 +301,7 @@ test('on-board list: fly-to-destination + abandon with penalty preview; flying p
.toBe(true);
});
-test('market buy/sell/refuel render with previews from the catalog in /api/state; refuel deducts exactly', async ({ page }) => {
+test('market buy/sell render with previews from the catalog in /api/state; left-pane Refuel deducts exactly', async ({ page }) => {
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
await flyHomeViaApi(page); // grounded starter at EDDF (tank partial after the previous test's flight)
@@ -325,6 +326,9 @@ test('market buy/sell/refuel render with previews from the catalog in /api/state
const payout = Math.round(entry.price * body.sellFraction * 100) / 100;
await expect(page.getByTestId('market-sell-payout')).toContainText(eur(payout));
+ // ISS-035 (v0.23.0): the Refuel-to-full row now lives in the LEFT pane next to Relocate — same global testids.
+ await expect(page.getByTestId('left-pane').getByTestId('market-refuel')).toBeVisible({ timeout: 15_000 });
+
// Refuel through the pane: the flight genuinely burned fuel…
if (starter.fuel < starter.maxFuel) {
const expectedCost = Math.round((starter.maxFuel - starter.fuel) * body.fuelPricePerL * 100) / 100;
@@ -348,7 +352,7 @@ test('market buy/sell/refuel render with previews from the catalog in /api/state
}
});
-test('on-board row Refuel tops up just enough for the package leg; market button says "Refuel to full"', async ({ page }) => {
+test('on-board row Refuel tops up just enough for the package leg; left-pane button says "Refuel to full"', async ({ page }) => {
test.setTimeout(300_000); // self-heal + one long flight each way at scale 1000
await flyHomeViaApi(page);
@@ -403,7 +407,7 @@ test('on-board row Refuel tops up just enough for the package leg; market button
expect(post.account.cash).toBeCloseTo(cashBefore - expectedCost, 6);
expect(post.planes.find((p) => p.id === 'plane-starter')!.fuel).toBeCloseTo(fuelBefore + liters, 6);
- // Market row: the fill-up button is labeled "Refuel to full" (testid unchanged).
+ // Left pane (moved from the market in v0.23.0, ISS-035): the fill-up button is labeled "Refuel to full" (testid unchanged).
await expect(page.getByTestId('market-refuel')).toHaveText('Refuel to full');
// Restore: fly home — pkg2's destination IS the hub, so it delivers on arrival. The tank holds
diff --git a/package.json b/package.json
index 87a9af6..6327150 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "flight-ops",
- "version": "0.22.2",
+ "version": "0.23.0",
"private": true,
"scripts": {
"dev": "next dev -p 3000",