Files
2026-09-26 18:37:11 +00:00

420 lines
23 KiB
TypeScript
Raw Permalink 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 { 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>
);
}