Merge On board into Fleet list — single per-plane block (ISS-054, v0.33.0)
CI / build + unit + e2e (pull_request) Successful in 10m18s
CI / build + unit + e2e (pull_request) Successful in 10m18s
This commit is contained in:
+136
-161
@@ -19,7 +19,7 @@ interface LeftPaneProps {
|
||||
// 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.
|
||||
// state — needed now that the per-plane cargo rows' 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;
|
||||
@@ -92,7 +92,7 @@ export async function retrySimPost(
|
||||
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).
|
||||
// ISS-021 (v0.20.0): shared action runner for the per-plane cargo/queue actions (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
|
||||
@@ -195,15 +195,8 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
||||
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.
|
||||
// Snapshot clock — feeds the deadline/penalty previews in the per-plane cargo rows.
|
||||
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
|
||||
@@ -340,161 +333,143 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
||||
<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>
|
||||
))}
|
||||
{state.planes.map((p) => {
|
||||
// ISS-054 (v0.33.0): the merged per-plane block — status/fuel/loadout plus the former
|
||||
// On board section's cargo rows and queue legs, so all plane info lives in one place.
|
||||
const cargo = state.packages.filter(
|
||||
(pkg) => pkg.planeId === p.id && (pkg.status === 'loaded' || pkg.status === 'expired'),
|
||||
);
|
||||
return (
|
||||
// ISS-002: hovering a fleet row highlights the plane on the map. Handlers live only on
|
||||
// the outer <li> — React's enter/leave don't fire when moving to a child, so this covers
|
||||
// the whole block without flicker between sub-elements; cargo rows override with their
|
||||
// own package hover (below).
|
||||
<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>
|
||||
{p.status === 'flying' ? (
|
||||
<>
|
||||
<span className="plane-status status-flying">in flight</span>
|
||||
{p.destName && (
|
||||
<span data-testid={`plane-leg-${p.id}`} className="contract-route">
|
||||
{p.locationName} → {p.destName}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="plane-status status-available">{p.locationIcao}</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>
|
||||
{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: p.id })}
|
||||
onMouseLeave={() => onHover({ kind: 'plane', id: p.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/${p.id}/${p.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 || p.status !== 'available'}
|
||||
onClick={() => void runAction(`/api/planes/${p.id}/refuel`, { mode: 'forFlight', packageId: pkg.id })}
|
||||
>
|
||||
Refuel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`abandon-${pkg.id}`}
|
||||
className="pane-btn"
|
||||
disabled={actionsBusy || p.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.
|
||||
ISS-054 (v0.33.0): rendered only when non-empty. */}
|
||||
{p.queue.length > 0 && (
|
||||
<div className="queue-subsection">
|
||||
<span className="queue-heading">Queue</span>
|
||||
{/* ISS-055 (v0.31.0): testids scoped by plane id — with two blocks the unscoped ids
|
||||
duplicated in the DOM and broke Playwright strict mode. */}
|
||||
{p.queue.map((icao, i) => (
|
||||
<div key={`${icao}-${i}`} data-testid={`queue-leg-${p.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-${p.id}-${i}`}
|
||||
className="pane-btn"
|
||||
disabled={actionsBusy}
|
||||
onClick={() => void runAction(`/api/planes/${p.id}/queue/cancel`, { index: i })}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</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. */}
|
||||
everything else about our planes, between Fleet 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. */}
|
||||
@@ -562,7 +537,7 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
||||
<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. */}
|
||||
"Refuel" button in the fleet rows (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">
|
||||
|
||||
Reference in New Issue
Block a user