420 lines
23 KiB
TypeScript
420 lines
23 KiB
TypeScript
'use client';
|
||
|
||
import { useEffect, useRef, useState } 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, formatKm, formatSimClock, formatSpecialLabel } from '@/lib/format';
|
||
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;
|
||
// ISS-021 (v0.20.0): push the full snapshot returned by an action endpoint into the shared shell
|
||
// state — needed now that the per-plane cargo rows' 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;
|
||
}
|
||
|
||
// 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`;
|
||
}
|
||
|
||
export default function LeftPane({ state, loading, error, applySnapshot, onHover, onError }: LeftPaneProps) {
|
||
// 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
|
||
// "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(() => {});
|
||
}, []);
|
||
|
||
// Snapshot clock — feeds the deadline/penalty previews in the per-plane cargo rows.
|
||
const clockMs = state?.simState.clockMs ?? 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).
|
||
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-077 (v0.41.0): while flying, the Relocate click would POST /queue — block it when the selected
|
||
// destination is already active or queued (the server rejects that with 409). Grounded planes are unaffected.
|
||
const relocateBlocked =
|
||
relocatePlane?.status === 'flying' && relocateDest !== null &&
|
||
(relocatePlane.destIcao === relocateDest.icao || relocatePlane.queue.includes(relocateDest.icao));
|
||
|
||
// 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';
|
||
|
||
// ISS-007: the clock ticks client-side between polls at the fixed world rate, re-syncing to
|
||
// server truth on every snapshot (and freezing while nothing is happening). Only the sim-clock
|
||
// paragraph uses this — deadline and penalty previews stay snapshot-based (deterministic against
|
||
// the server clock).
|
||
const simClockMs = useSimClock(state);
|
||
|
||
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-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>
|
||
{/* v1.4.0 (ISS-112): the snapshot carries ALL planes — badge names the owner. */}
|
||
<span data-testid={`plane-owner-${p.id}`} className="owner-badge" title={`Owned by ${p.ownerName}`}>
|
||
{p.ownerName}
|
||
</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>
|
||
{/* v0.39.0 (ISS-069): maintenance pills — "in repair" while the gate runs, "broken" at zero health. */}
|
||
{p.status === 'repairing' && (
|
||
<span data-testid={`plane-repairing-${p.id}`} className="plane-status status-repairing">in repair</span>
|
||
)}
|
||
{p.health <= 0 && (
|
||
<span data-testid={`plane-broken-${p.id}`} className="plane-status status-broken">broken</span>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
<div className="fuel-bar" aria-hidden="true">
|
||
<div style={{ width: `${Math.max(0, Math.min(100, (p.fuel / p.maxFuel) * 100))}%` }} />
|
||
</div>
|
||
{/* v0.39.0 (ISS-069): health bar — sibling of the fuel bar, amber fill. */}
|
||
<div className="health-bar" aria-hidden="true">
|
||
<div style={{ width: `${Math.max(0, Math.min(100, p.health))}%` }} />
|
||
</div>
|
||
<div className="plane-sub">
|
||
<span data-testid={`plane-fuel-${p.id}`}>{formatFuel(p.fuel, p.maxFuel)}</span>
|
||
{/* v0.39.0 (ISS-069): health percentage next to the fuel readout. */}
|
||
<span data-testid={`plane-health-${p.id}`}>{Math.round(p.health)}%</span>
|
||
<span data-testid={`plane-loadout-${p.id}`}>{loadoutFor(state, p)}</span>
|
||
<span className="plane-loc">{planeLocation(p)}</span>
|
||
</div>
|
||
{/* v0.39.0 (ISS-069): repair — grounded plane with worn health; flat cost is server-side only (no preview). */}
|
||
{p.status === 'available' && p.health < 100 && (
|
||
<button
|
||
type="button"
|
||
data-testid={`repair-${p.id}`}
|
||
className="pane-btn repair-btn"
|
||
disabled={actionsBusy}
|
||
onClick={() => void runAction(`/api/planes/${p.id}/repair`)}
|
||
>
|
||
Repair
|
||
</button>
|
||
)}
|
||
{cargo.map((pkg) => {
|
||
const penalty = abandonPenalty(pkg, clockMs);
|
||
// ISS-077 (v0.41.0): while flying the click would POST /queue — disable it when the
|
||
// destination is already active or queued (the server rejects that with 409). Grounded
|
||
// clicks go to /fly, which has no such conflict.
|
||
const alreadyQueued = p.status === 'flying' && (p.destIcao === pkg.destIcao || p.queue.includes(pkg.destIcao));
|
||
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 || alreadyQueued}
|
||
title={alreadyQueued ? 'Already in flight queue' : undefined}
|
||
// 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 })}
|
||
>
|
||
{/* ISS-081 (v0.40.0): ICAO tags instead of full airport names. */}
|
||
<span className="onboard-fly-label" title={`Fly to ${pkg.destIcao}`}>
|
||
Fly to {pkg.destIcao}
|
||
</span>
|
||
</button>
|
||
{/* ISS-082 (v0.42.0): the per-leg Refuel button is gone — dispatch auto-refuels. */}
|
||
<div className="onboard-actions">
|
||
<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">
|
||
{/* ISS-081 (v0.40.0): ICAO tags instead of full airport names. */}
|
||
<span data-testid={`onboard-route-${pkg.id}`} className="contract-route">
|
||
{pkg.originIcao} → {pkg.destIcao}
|
||
</span>
|
||
{/* v0.48.0 (ISS-095): leg distance after the route, e.g. "EGCC → GMME 554km". */}
|
||
<span data-testid={`onboard-distance-${pkg.id}`}>{formatKm(pkg.distanceKm)}</span>
|
||
{/* v0.37.0 (ISS-073): special-status badge — rendered only when the package has one. */}
|
||
{pkg.special && (
|
||
<span data-testid={`package-special-${pkg.id}`} className="special-badge">
|
||
{formatSpecialLabel(pkg.special)}
|
||
</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-023 (v0.21.0): moved here from the right-pane market row — it belongs with
|
||
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. */}
|
||
<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) => (
|
||
// ISS-077 (v0.41.0): a flying plane's active/queued airports can't be re-selected — the
|
||
// server would reject them with 409; native disabled options, no text change.
|
||
<option key={a.icao} value={a.icao} disabled={relocatePlane?.status === 'flying' && (relocatePlane.destIcao === a.icao || relocatePlane.queue.includes(a.icao))}>
|
||
{a.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<button
|
||
type="button"
|
||
data-testid="relocate-go"
|
||
className="pane-btn"
|
||
disabled={actionsBusy || relocatePlane === null || relocateDest === null || relocateBlocked}
|
||
// 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-082 (v0.42.0): the "Refuel to full" section is gone — dispatch auto-refuels instead. */}
|
||
|
||
<section className="pane-section">
|
||
<h3 className="pane-heading">Time</h3>
|
||
{/* ISS-117 (v0.52.0): no player-facing time controls — the world runs at a fixed rate;
|
||
only the clock display remains here. */}
|
||
<p data-testid="sim-clock" className="sim-clock">{simClockMs !== null && formatSimClock(simClockMs)}</p>
|
||
{/* 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>
|
||
);
|
||
}
|