Files
Flight-Ops/components/LeftPane.tsx
T
kamilkrzywda 1e3992f872
CI / build + unit + e2e (pull_request) Failing after 8m13s
Two-plane load fix + e2e coverage (ISS-055, v0.31.0)
- LoadControls preselects the first eligible plane so Load is one-click with N planes
- LeftPane queue testids scoped by plane id (queue-leg/cancel/add[-dest]-{plane.id})
  — unscoped ids duplicated in the DOM with two on-board blocks
- new POST /api/test/add-cash hook (FLIGHT_TEST_HOOKS=1, default €1M) so e2e can
  afford a second plane; returns the full state snapshot
- new serial spec e2e/two-planes.spec.ts: load, parallel fly + ISS-057 slow/resume,
  refuel, relocate, per-plane queues with auto-chain, sell (6 tests)
- e2e/queue.spec.ts locators updated to the scoped testids
2026-09-20 21:26:16 +00:00

685 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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';
import { formatCash, formatDeadlineRelative, formatFuel, formatSimClock } from '@/lib/format';
import { TIME_SCALES, scaleToSlider, sliderToScale } from '@/lib/timeScale';
import { useGameAction } from '@/lib/useGameAction';
import { useSimClock } from '@/lib/useSimClock';
import { cargoWeightFor } from './RightPane';
import type { HoverTarget } from './hoverTypes';
interface LeftPaneProps {
state: StateSnapshot | null;
loading: boolean;
error: string | null;
// The shell's single-poll refresh — called after a sim-control POST so the pane updates immediately.
onRefresh: () => void;
// ISS-021 (v0.20.0): push the full snapshot returned by an action endpoint into the shared shell
// state — needed now that the On board section's refuel/fly/abandon actions live in this pane.
applySnapshot: (snapshot: StateSnapshot) => void;
// ISS-002 (v0.15.0): report fleet-row hovers to the shell so the map can highlight the plane.
onHover: (target: HoverTarget | null) => void;
// ISS-008 (v0.17.0): report a failed sim-control POST to the shell as an error toast.
onError?: (message: string) => void;
}
// Discrete ladder slider (v0.9.2, values reworked in v0.14.0): position t ∈ [0,1] → nearest
// step of the ladder 1×/2×/5×/10×/25×/50×/100×/250×/500×/1000× — each of the 10 positions
// roughly doubles the speed.
const SCALE_SLIDER_STEP = 1 / (TIME_SCALES.length - 1); // one ladder step per keyboard-arrow step
const SCALE_COMMIT_DEBOUNCE_MS = 250;
// Flying planes show their current leg from the snapshot's dest fields (v0.12.0).
function planeLocation(p: PlaneState): string {
if (p.status === 'flying' && p.destName) return `${p.locationName} → ${p.destName}`;
return p.locationName;
}
// Cargo loadout "X.Xt / Y.Yt" (v0.27.0) — used/max payload in tonnes, one decimal;
// expired packages still occupy their payload.
function loadoutFor(state: StateSnapshot, p: PlaneState): string {
const used = cargoWeightFor(state, p.id);
const maxPayloadKg = state.catalog.find((e) => e.model === p.model)?.maxPayloadKg ?? PLANE_TYPES[p.model]?.maxPayloadKg ?? 0;
return `${(used / 1000).toFixed(1)}t / ${(maxPayloadKg / 1000).toFixed(1)}t`;
}
// ISS-001 — an optimistic slider value is cleared exactly when a rendered snapshot CONFIRMS it
// (server scale === pending), never earlier: clearing at commit time would snap the pointer back
// to the stale scale for one poll cycle, then jump forward again on the confirming snapshot.
export function pendingScaleConfirmed(pendingScale: number | null, serverTimeScale: number): boolean {
return pendingScale !== null && serverTimeScale === pendingScale;
}
// ISS-008 fix (v0.17.0, CI runs #44/#47): bounded retry for POST /api/sim — a commit dropped by
// the runner's flaky uplink used to leave the UI optimistic while the server kept the old scale.
// Up to maxAttempts tries with delayMs between them; stops early once superseded() flips (a newer
// intent arrived and the caller's loop sends it instead). post is idempotent set-semantics, so
// re-sending a body whose response was lost cannot double-apply. Pure core — unit-tested in node.
export const SIM_POST_MAX_ATTEMPTS = 3;
export const SIM_POST_RETRY_DELAY_MS = 500;
export type SimPostRetryResult =
| { ok: true }
| { ok: false; reason: 'exhausted'; error: unknown }
| { ok: false; reason: 'superseded' };
export async function retrySimPost(
post: () => Promise<void>,
superseded: () => boolean,
sleep: (ms: number) => Promise<void> = (ms) => new Promise((r) => setTimeout(r, ms)),
maxAttempts: number = SIM_POST_MAX_ATTEMPTS,
delayMs: number = SIM_POST_RETRY_DELAY_MS,
): Promise<SimPostRetryResult> {
let lastError: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
if (superseded()) return { ok: false, reason: 'superseded' }; // a newer intent takes over — don't even send this body
try {
await post();
return { ok: true };
} catch (err) {
lastError = err;
if (superseded()) return { ok: false, reason: 'superseded' }; // a newer intent arrived during the attempt — don't wait out the backoff
if (attempt < maxAttempts) await sleep(delayMs);
}
}
return { ok: false, reason: 'exhausted', error: lastError };
}
export default function LeftPane({ state, loading, error, onRefresh, applySnapshot, onHover, onError }: LeftPaneProps) {
const [busy, setBusy] = useState(false);
// 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-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-043 (v0.26.0): flight-queue "add" destination — one shared pick across every on-board
// block's queue-add select (the e2e fleet is a single plane; per-block state would be dead weight).
const [queueAddDestIcao, setQueueAddDestIcao] = 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
// toast (the running poll retries on its own). A commit that lands while another POST is in
// flight — including mid-retry — is held in a single slot (last intent wins) and supersedes
// the in-flight body, so every user intent reaches the server (the [1,1000] clamp stays as
// backstop). The gate is a REF, not React state: closures never see a stale "busy" value,
// which used to let a flush re-queue instead of send and silently drop a commit.
const queuedRef = useRef<{ timeScale?: number; paused?: boolean } | null>(null);
const busyRef = useRef(false);
type SimControlsOutcome = 'sent' | 'queued' | 'failed';
// 'sent': this body reached the server (a confirming snapshot follows via onRefresh).
// 'queued': an in-flight call holds it — its flush will send it.
// 'failed': the POST for this body never succeeded; callers drop optimistic values only then.
const applySimControls = useCallback(
async (body: { timeScale?: number; paused?: boolean }): Promise<SimControlsOutcome> => {
if (busyRef.current) {
queuedRef.current = body; // last intent wins — the in-flight call's flush sends it
return 'queued';
}
busyRef.current = true;
setBusy(true);
let sentOwn = false;
try {
let current: { timeScale?: number; paused?: boolean } | null = body;
while (current !== null) {
const target = current; // stable across this retry round
const result = await retrySimPost(
async () => {
const res = await fetch('/api/sim', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(target),
cache: 'no-store',
});
if (!res.ok) throw new Error(`POST /api/sim → HTTP ${res.status}`);
},
() => queuedRef.current !== null, // a newer intent supersedes the in-flight body mid-retry
);
if (result.ok) {
onRefresh();
if (target === body) sentOwn = true; // object identity — queued bodies are fresh literals
} else if (result.reason === 'exhausted') {
throw result.error; // only after the final attempt fails → error toast below
}
current = queuedRef.current; // pick up anything that arrived while we were in flight
queuedRef.current = null;
}
} catch (err) {
console.error('failed to apply sim controls', err);
// ISS-008: surface the failure as an error toast too — previously console-only.
onError?.(`Failed to update time controls (${err instanceof Error ? err.message : String(err)})`);
queuedRef.current = null; // don't replay a stale intent after a failed round-trip
} finally {
busyRef.current = false;
setBusy(false);
}
return sentOwn ? 'sent' : 'failed';
},
[onRefresh, onError],
);
const paused = state?.simState.paused ?? false;
const timeScale = state?.simState.timeScale ?? 1;
// On board (v0.12.0, moved from the right pane in ISS-021 v0.20.0): every plane that carries
// cargo or is mid-flight gets a block — its loaded/expired packages with fly-to-destination +
// abandon controls, and flying planes show their current leg.
const clockMs = state?.simState.clockMs ?? 0;
// ISS-043 (v0.26.0): a plane with queued legs always shows its block too — the queue is the
// reason it's here even when grounded with nothing on board.
const onboard = state
? state.planes.filter((p) => p.status === 'flying' || cargoWeightFor(state, p.id) > 0 || (p.queue?.length ?? 0) > 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;
// 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
// [1,1000] clamp stays as the backstop.
const [pendingScale, setPendingScaleState] = useState<number | null>(null);
const pendingRef = useRef<number | null>(null);
const commitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const setPending = useCallback((s: number | null) => {
pendingRef.current = s;
setPendingScaleState(s);
}, []);
const commitNow = useCallback(() => {
if (commitTimerRef.current !== null) {
clearTimeout(commitTimerRef.current);
commitTimerRef.current = null;
}
const s = pendingRef.current;
if (s === null) return;
// ISS-001: keep the chosen value on screen until a snapshot CONFIRMS it — clearing at
// commit time would snap the slider back to the stale scale for one poll cycle, then jump
// forward again when the confirming snapshot lands. 'queued' also keeps it (the in-flight
// call flushes it); only a genuine failure drops it so the display falls back to truth.
void applySimControls({ timeScale: s }).then((outcome) => {
if (outcome === 'failed') setPending(null);
});
}, [applySimControls, setPending]);
// Confirmation: once a rendered snapshot carries the pending scale AND no commit is in
// flight, the optimistic value has been superseded by server truth — clear it (display is
// unchanged either way). The busy guard matters: while a commit POST is still out, an OLD
// snapshot that happens to match the pending value is coincidence, not confirmation (e.g. a
// quick drag back to the previous scale must not be swallowed before its own commit fires).
useEffect(() => {
if (!busy && state && pendingScaleConfirmed(pendingScale, state.simState.timeScale)) setPending(null);
}, [state, pendingScale, busy, setPending]);
const onSliderInput = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
setPending(sliderToScale(Number(e.target.value)));
if (commitTimerRef.current !== null) clearTimeout(commitTimerRef.current);
commitTimerRef.current = setTimeout(commitNow, SCALE_COMMIT_DEBOUNCE_MS);
},
[commitNow, setPending],
);
const sliderRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const el = sliderRef.current;
if (!el) return;
el.addEventListener('change', commitNow); // flush on pointer-up / keyboard release
return () => el.removeEventListener('change', commitNow);
}, [commitNow]);
// Drop any pending debounce timer on unmount.
useEffect(
() => () => {
if (commitTimerRef.current !== null) clearTimeout(commitTimerRef.current);
},
[],
);
// ISS-008: browser-notification opt-in. Starts null (SSR-safe — the real permission is read
// in an effect after hydration, so server and client render identically) and follows the
// result of Notification.requestPermission(). 'unsupported' = no Notification API at all.
const [notifPermission, setNotifPermission] = useState<string | null>(null);
useEffect(() => {
if ('Notification' in window) setNotifPermission(Notification.permission);
else setNotifPermission('unsupported');
}, []);
// null (pre-effect) and 'default' both read as the opt-in prompt.
const notifLabel: string =
notifPermission === 'granted'
? 'Notifications enabled'
: notifPermission === 'denied'
? 'Notifications blocked'
: notifPermission === 'unsupported'
? 'Notifications unavailable'
: 'Enable notifications';
const displayedScale = pendingScale ?? timeScale;
// ISS-007: the clock ticks client-side between polls, re-syncing to server truth on every
// snapshot. Fed with EFFECTIVE controls so a scale drag / pause click takes effect immediately,
// not when the confirming snapshot lands. Only the sim-clock paragraph uses this — deadline and
// penalty previews stay snapshot-based (deterministic against the server clock).
const simClockMs = useSimClock(state, { timeScale: displayedScale, paused });
return (
<aside data-testid="left-pane" className="pane left-pane">
<h2 className="pane-title">Assets</h2>
{loading && !state ? (
<p data-testid="left-loading" className="pane-muted">Loading…</p>
) : state ? (
<>
<section className="pane-section">
<div className="stat-row">
<span>Cash</span>
<strong data-testid="wallet-cash">{formatCash(state.account.cash)}</strong>
</div>
<div className="stat-row">
<span>Reputation</span>
<strong data-testid="reputation">{state.account.reputation}</strong>
</div>
</section>
<section className="pane-section">
<h3 className="pane-heading">Fleet</h3>
{state.planes.length === 0 ? (
<p className="pane-muted">No planes.</p>
) : (
<ul data-testid="fleet-list" className="fleet-list">
{state.planes.map((p) => (
// ISS-002: hovering a fleet row highlights the plane on the map.
<li
key={p.id}
data-testid={`plane-${p.id}`}
className="plane-row"
onMouseEnter={() => onHover({ kind: 'plane', id: p.id })}
onMouseLeave={() => onHover(null)}
>
<div className="plane-main">
<span className="plane-model">{PLANE_TYPES[p.model]?.name ?? p.model}</span>
<span className={`plane-status status-${p.status}`}>{p.status}</span>
</div>
<div className="fuel-bar" aria-hidden="true">
<div style={{ width: `${Math.max(0, Math.min(100, (p.fuel / p.maxFuel) * 100))}%` }} />
</div>
<div className="plane-sub">
<span data-testid={`plane-fuel-${p.id}`}>{formatFuel(p.fuel, p.maxFuel)}</span>
<span data-testid={`plane-loadout-${p.id}`}>{loadoutFor(state, p)}</span>
<span className="plane-loc">{planeLocation(p)}</span>
</div>
</li>
))}
</ul>
)}
</section>
{/* ISS-021 (v0.20.0): moved here from the right pane — between Fleet and Time. */}
<section className="pane-section">
<h3 className="pane-heading">On board</h3>
{onboard.length === 0 ? (
<p className="pane-muted">Nothing on board.</p>
) : (
onboard.map((plane) => {
const cargo = state.packages.filter(
(pkg) => pkg.planeId === plane.id && (pkg.status === 'loaded' || pkg.status === 'expired'),
);
return (
// ISS-002: hovering the on-board block (including its cargo rows) highlights the plane.
// Handlers live only on the outer div — React's enter/leave don't fire when moving to a
// child, so this covers the whole block without flicker between sub-elements.
<div
key={plane.id}
data-testid={`onboard-${plane.id}`}
className="onboard-block"
onMouseEnter={() => onHover({ kind: 'plane', id: plane.id })}
onMouseLeave={() => onHover(null)}
>
<div className="contract-main">
<span className="plane-model">{PLANE_TYPES[plane.model]?.name ?? plane.model}</span>
{plane.status === 'flying' ? (
<>
<span className="plane-status status-flying">in flight</span>
{plane.destName && (
<span data-testid={`plane-leg-${plane.id}`} className="contract-route">
{plane.locationName} → {plane.destName}
</span>
)}
</>
) : (
<span className="plane-status status-available">{plane.locationIcao}</span>
)}
</div>
{cargo.map((pkg) => {
const penalty = abandonPenalty(pkg, clockMs);
return (
// ISS-025 (v0.22.0): hovering a loaded package draws its full origin→dest trace on the map —
// same as job-list rows — and keeps this plane's halo lit via planeId. Leaving the row restores
// the block's plain plane highlight; leaving the whole block still ends at null (the child's
// mouseleave fires before the parent's).
<div
key={pkg.id}
data-testid={`onboard-package-${pkg.id}`}
className="contract-row"
onMouseEnter={() => onHover({ kind: 'package', originIcao: pkg.originIcao, destIcao: pkg.destIcao, planeId: plane.id })}
onMouseLeave={() => onHover({ kind: 'plane', id: plane.id })}
>
<button
type="button"
data-testid={`fly-to-${pkg.id}`}
className="pane-btn onboard-fly-btn"
disabled={actionsBusy || plane.status !== 'available'}
onClick={() => void runAction(`/api/planes/${plane.id}/fly`, { destIcao: pkg.destIcao })}
>
<span className="onboard-fly-label" title={`Fly to ${pkg.destName}`}>
Fly to {pkg.destName}
</span>
</button>
<div className="onboard-actions">
<button
type="button"
data-testid={`refuel-for-${pkg.id}`}
className="pane-btn"
disabled={actionsBusy || plane.status !== 'available'}
onClick={() => void runAction(`/api/planes/${plane.id}/refuel`, { mode: 'forFlight', packageId: pkg.id })}
>
Refuel
</button>
<button
type="button"
data-testid={`abandon-${pkg.id}`}
className="pane-btn"
disabled={actionsBusy || plane.status !== 'available'}
onClick={() => void runAction(`/api/packages/${pkg.id}/abandon`)}
>
Abandon{' '}
<span data-testid={`abandon-penalty-${pkg.id}`}>−{formatCash(penalty)}</span>
</button>
</div>
<div className="contract-sub">
<span className="contract-route">{pkg.originName} → {pkg.destName}</span>
<span>{formatCash(pkg.reward)}</span>
{pkg.status === 'expired' ? (
<span data-testid={`onboard-expired-${pkg.id}`}>expired</span>
) : (
<span>{formatDeadlineRelative(pkg.deadlineSimTime, clockMs)}</span>
)}
</div>
</div>
);
})}
{/* ISS-043 (v0.26.0): flight queue — the not-yet-started legs with cancel, plus an
add control reusing this pane's already-fetched airport options (no second fetch). */}
<div className="queue-subsection">
<span className="queue-heading">Queue</span>
{/* ISS-055 (v0.31.0): testids scoped by plane id — with two on-board blocks the
unscoped ids duplicated in the DOM and broke Playwright strict mode. */}
{plane.queue.map((icao, i) => (
<div key={`${icao}-${i}`} data-testid={`queue-leg-${plane.id}-${i}`} className="queue-row">
<span className="contract-route">
{airportOptions?.find((a) => a.icao === icao)?.name ?? icao} ({icao})
</span>
<button
type="button"
data-testid={`queue-cancel-${plane.id}-${i}`}
className="pane-btn"
disabled={actionsBusy}
onClick={() => void runAction(`/api/planes/${plane.id}/queue/cancel`, { index: i })}
>
Cancel
</button>
</div>
))}
<div className="market-row">
<select
data-testid={`queue-add-dest-${plane.id}`}
aria-label="Airport to add to the flight queue"
value={queueAddDestIcao ?? ''}
onChange={(e) => setQueueAddDestIcao(e.target.value || null)}
>
<option value="">Select an airport…</option>
{airportOptions?.map((a) => (
<option key={a.icao} value={a.icao}>
{a.name}
</option>
))}
</select>
<button
type="button"
data-testid={`queue-add-${plane.id}`}
className="pane-btn"
disabled={actionsBusy || queueAddDestIcao === null}
onClick={() => {
if (queueAddDestIcao) void runAction(`/api/planes/${plane.id}/queue`, { destIcao: queueAddDestIcao });
}}
>
Add to queue
</button>
</div>
</div>
</div>
);
})
)}
</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. */}
{/* ISS-027 (v0.22.0): hovering the section previews the planned relocation on the map —
dashed trace from the selected plane's airport to the destination + a ring on the target.
With no grounded plane or no loaded airport options there is nothing to preview → null. */}
<section
className="pane-section"
data-testid="relocate-section"
onMouseEnter={() =>
onHover(
relocatePlane && relocateDest
? { kind: 'route', fromIcao: relocatePlane.locationIcao, toIcao: relocateDest.icao }
: null,
)
}
onMouseLeave={() => onHover(null)}
>
<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>
{/* 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">
{simClockMs !== null && formatSimClock(simClockMs)}
{paused ? ' (paused)' : ''}
</p>
<div className="time-controls">
<button
type="button"
data-testid="pause-toggle"
disabled={busy}
onClick={() => void applySimControls({ paused: !paused })}
>
{paused ? 'Resume' : 'Pause'}
</button>
<input
ref={sliderRef}
type="range"
data-testid="time-scale-slider"
aria-label="Time scale"
min={0}
max={1}
step={SCALE_SLIDER_STEP}
value={scaleToSlider(displayedScale)}
onChange={onSliderInput}
/>
<span data-testid="time-scale-value">{displayedScale}×</span>
</div>
{/* ISS-008: explicit opt-in for browser notifications (fired when the window is hidden). */}
<button
type="button"
data-testid="enable-notifications"
className="notif-toggle"
disabled={notifPermission === 'unsupported'}
title={notifPermission === 'unsupported' ? 'This browser does not support notifications' : undefined}
onClick={() => {
// A rejection (rare — e.g. some headless setups) reads as "blocked".
if ('Notification' in window) {
void Notification.requestPermission()
.then(setNotifPermission)
.catch(() => setNotifPermission('denied'));
}
}}
>
{notifLabel}
</button>
</section>
{error && (
<p data-testid="left-error" className="pane-error">
{error}
</p>
)}
{/* ISS-021 (v0.20.0): inline error from a failed On board action — mirrors right-error. */}
{actionError && <p data-testid="left-action-error" className="pane-error">{actionError}</p>}
</>
) : null}
</aside>
);
}