feat: v0.21.0 — ISS-023 Relocate panel moves to left pane
CI / build + unit + e2e (pull_request) Successful in 5m54s
CI / build + unit + e2e (pull_request) Successful in 5m54s
This commit is contained in:
@@ -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<string | null>(null);
|
||||
const [relocateDestIcao, setRelocateDestIcao] = useState<string | null>(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<Array<{ name: string; icao: string }> | null>(null);
|
||||
const airportsPromiseRef = useRef<Promise<Array<{ name: string; icao: string }>> | 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<GeoJsonFeatureCollection>;
|
||||
})
|
||||
.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
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 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. */}
|
||||
<section className="pane-section">
|
||||
<h3 className="pane-heading">Relocate</h3>
|
||||
{/* 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. */}
|
||||
<div className="market-row market-row-relocate">
|
||||
<select
|
||||
data-testid="relocate-plane"
|
||||
aria-label="Plane to relocate"
|
||||
value={relocatePlane?.id ?? ''}
|
||||
onChange={(e) => setRelocatePlaneId(e.target.value || null)}
|
||||
>
|
||||
{grounded.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{PLANE_TYPES[p.model]?.name ?? p.model} — {p.locationIcao}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
data-testid="relocate-dest"
|
||||
aria-label="Relocation destination airport"
|
||||
value={relocateDest?.icao ?? ''}
|
||||
onChange={(e) => setRelocateDestIcao(e.target.value || null)}
|
||||
>
|
||||
{airportOptions?.map((a) => (
|
||||
<option key={a.icao} value={a.icao}>
|
||||
{a.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="relocate-go"
|
||||
className="pane-btn"
|
||||
disabled={actionsBusy || relocatePlane === null || relocateDest === null}
|
||||
onClick={() => {
|
||||
if (relocatePlane && relocateDest) {
|
||||
void runAction(`/api/planes/${relocatePlane.id}/fly`, { destIcao: relocateDest.icao });
|
||||
}
|
||||
}}
|
||||
>
|
||||
Relocate
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="pane-section">
|
||||
<h3 className="pane-heading">Time</h3>
|
||||
<p data-testid="sim-clock" className="sim-clock">
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [refuelPlaneId, setRefuelPlaneId] = useState<string | null>(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<string | null>(null);
|
||||
const [relocateDestIcao, setRelocateDestIcao] = useState<string | null>(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<Array<{ name: string; icao: string }> | null>(null);
|
||||
const airportsPromiseRef = useRef<Promise<Array<{ name: string; icao: string }>> | 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<GeoJsonFeatureCollection>;
|
||||
})
|
||||
.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
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 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. */}
|
||||
<div className="market-row market-row-relocate">
|
||||
<select
|
||||
data-testid="relocate-plane"
|
||||
aria-label="Plane to relocate"
|
||||
value={relocatePlane?.id ?? ''}
|
||||
onChange={(e) => setRelocatePlaneId(e.target.value || null)}
|
||||
>
|
||||
{grounded.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{planeName(p)} — {p.locationIcao}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
data-testid="relocate-dest"
|
||||
aria-label="Relocation destination airport"
|
||||
value={relocateDest?.icao ?? ''}
|
||||
onChange={(e) => setRelocateDestIcao(e.target.value || null)}
|
||||
>
|
||||
{airportOptions?.map((a) => (
|
||||
<option key={a.icao} value={a.icao}>
|
||||
{a.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="relocate-go"
|
||||
className="pane-btn"
|
||||
disabled={busy || relocatePlane === null || relocateDest === null}
|
||||
onClick={() => {
|
||||
if (relocatePlane && relocateDest) {
|
||||
void runAction(`/api/planes/${relocatePlane.id}/fly`, { destIcao: relocateDest.icao });
|
||||
}
|
||||
}}
|
||||
>
|
||||
Relocate
|
||||
</button>
|
||||
</div>
|
||||
{/* ISS-023 (v0.21.0): the Relocate row moved to the left pane's "Relocate" section. */}
|
||||
</section>
|
||||
|
||||
{actionError && <p data-testid="right-error" className="pane-error">{actionError}</p>}
|
||||
|
||||
Reference in New Issue
Block a user