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",