Move Refuel-to-full from market into left pane next to Relocate (#35)
CI / build + unit + e2e (pull_request) Successful in 6m52s

This commit is contained in:
2026-09-20 08:25:57 +00:00
parent 8eeef401ae
commit a7150a1062
6 changed files with 85 additions and 50 deletions
+59
View File
@@ -102,6 +102,11 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
const [relocatePlaneId, setRelocatePlaneId] = useState<string | null>(null);
const [relocateDestIcao, setRelocateDestIcao] = useState<string | null>(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<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
@@ -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
</div>
</section>
{/* 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). */}
<section className="pane-section" data-testid="refuel-section">
<h3 className="pane-heading">Refuel</h3>
{/* 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. */}
<div className="market-row">
{refuelable.length === 0 ? (
<span data-testid="market-refuel-none" className="pane-muted">
All tanks full.
</span>
) : (
<>
<select
data-testid="market-refuel-plane"
aria-label="Plane to refuel"
value={refuelPlane?.id ?? ''}
onChange={(e) => setRefuelPlaneId(e.target.value || null)}
>
{refuelable.map((p) => (
<option key={p.id} value={p.id}>
{PLANE_TYPES[p.model]?.name ?? p.model} — {Math.round(p.fuel)} / {p.maxFuel} L
</option>
))}
</select>
<span data-testid="market-refuel-cost" className="market-preview">
{refuelCost !== null ? formatCash(refuelCost) : ''}
</span>
</>
)}
<button
type="button"
data-testid="market-refuel"
className="pane-btn"
disabled={actionsBusy || refuelPlane === null}
onClick={() => {
if (refuelPlane) void runAction(`/api/planes/${refuelPlane.id}/refuel`);
}}
>
Refuel to full
</button>
</div>
</section>
<section className="pane-section">
<h3 className="pane-heading">Time</h3>
<p data-testid="sim-clock" className="sim-clock">
+3 -44
View File
@@ -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<string | null>(null);
const [sellPlaneId, setSellPlaneId] = useState<string | null>(null);
const [refuelPlaneId, setRefuelPlaneId] = useState<string | null>(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 (
<aside data-testid="right-pane" className="pane right-pane">
@@ -262,44 +257,8 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
</button>
</div>
<div className="market-row">
{refuelable.length === 0 ? (
<span data-testid="market-refuel-none" className="pane-muted">
All tanks full.
</span>
) : (
<>
<select
data-testid="market-refuel-plane"
aria-label="Plane to refuel"
value={refuelPlane?.id ?? ''}
onChange={(e) => setRefuelPlaneId(e.target.value || null)}
>
{refuelable.map((p) => (
<option key={p.id} value={p.id}>
{planeName(p)} — {Math.round(p.fuel)} / {p.maxFuel} L
</option>
))}
</select>
<span data-testid="market-refuel-cost" className="market-preview">
{refuelCost !== null ? formatCash(refuelCost) : ''}
</span>
</>
)}
<button
type="button"
data-testid="market-refuel"
className="pane-btn"
disabled={busy || refuelPlane === null}
onClick={() => {
if (refuelPlane) void runAction(`/api/planes/${refuelPlane.id}/refuel`);
}}
>
Refuel to full
</button>
</div>
{/* ISS-023 (v0.21.0): the Relocate row moved to the left pane's "Relocate" section. */}
{/* ISS-035 (v0.23.0): the Refuel-to-full row moved to the left pane's "Refuel" section, next to Relocate. */}
</section>
{actionError && <p data-testid="right-error" className="pane-error">{actionError}</p>}