CI / build + unit + e2e (pull_request) Successful in 10m18s
- LeftPane fly-to button enabled for any plane state; posts to /queue when the plane is flying, else /fly - Relocate lists ALL planes (relocatable); relocate-go posts to /queue for a flying selected plane, else /fly - Delete queue-add select + Add-to-queue button and shared queueAddDestIcao state; leg rows/cancel kept - e2e/queue.spec.ts test 1 adds its queued leg via the Relocate section (starter is flying) - e2e/two-planes.spec.ts per-plane-queues test adds the starter's second leg via API POST /queue
664 lines
33 KiB
TypeScript
664 lines
33 KiB
TypeScript
'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-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 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.
|
||
// ISS-053 (v0.32.0): widened from grounded-only to ALL planes — a flying plane's relocation is
|
||
// queued instead of started immediately (see relocate-go below). `grounded` stays for Refuel.
|
||
const relocatable = state ? state.planes : [];
|
||
const relocatePlane = relocatable.find((p) => p.id === relocatePlaneId) ?? relocatable[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; grounded-only, and with state null falls back to null.
|
||
const grounded = relocatable.filter((p) => p.status === 'available');
|
||
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}
|
||
// ISS-053 (v0.32.0): a flying plane can't start another leg — the click queues it instead,
|
||
// so "Fly to …" works from any state and the queue needs no dedicated input.
|
||
onClick={() => void runAction(`/api/planes/${plane.id}/${plane.status === 'flying' ? 'queue' : '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.
|
||
ISS-053 (v0.32.0): the dedicated add input is gone — fly-to and Relocate post to
|
||
/queue while a plane is flying, so no separate entry point is needed. */}
|
||
<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>
|
||
</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 planes 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 plane — the fly endpoint already allows pure
|
||
repositioning, this row just exposes it for planes with nothing on board.
|
||
ISS-053 (v0.32.0): lists ALL planes; a flying one's relocation is queued below. */}
|
||
<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)}
|
||
>
|
||
{relocatable.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}
|
||
// ISS-053 (v0.32.0): a flying plane can't start another leg — queue it instead, so the
|
||
// relocation takes off when the current one lands.
|
||
onClick={() => {
|
||
if (relocatePlane && relocateDest) {
|
||
void runAction(`/api/planes/${relocatePlane.id}/${relocatePlane.status === 'flying' ? 'queue' : '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>
|
||
);
|
||
}
|