Files
Flight-Ops/components/LeftPane.tsx
T
2026-09-19 14:52:36 +00:00

309 lines
14 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 { PlaneState, StateSnapshot } from '@/lib/game/state';
import { PLANE_TYPES } from '@/lib/game/catalog';
import { formatCash, formatFuel, formatSimClock } from '@/lib/format';
import { TIME_SCALES, scaleToSlider, sliderToScale } from '@/lib/timeScale';
import { useSimClock } from '@/lib/useSimClock';
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-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 "n/capacity pkgs" — expired packages still occupy their slot.
function loadoutFor(state: StateSnapshot, p: PlaneState): string {
const used = state.packages.filter(
(pkg) => pkg.planeId === p.id && (pkg.status === 'loaded' || pkg.status === 'expired'),
).length;
const capacity = state.catalog.find((e) => e.model === p.model)?.capacity ?? PLANE_TYPES[p.model]?.capacity ?? 0;
return `${used}/${capacity} pkgs`;
}
// 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;
}
export default function LeftPane({ state, loading, error, onRefresh, onHover, onError }: LeftPaneProps) {
const [busy, setBusy] = useState(false);
// POST /api/sim, then refetch the full snapshot immediately so the pane reflects the new
// controls without waiting for the next poll tick. On failure keep the stale UI — the
// running poll retries on its own and surfaces the error state. A commit that lands while
// another POST is in flight is held in a single slot (last intent wins) and flushed by the
// in-flight call, 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 res = await fetch('/api/sim', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(current),
cache: 'no-store',
});
if (!res.ok) throw new Error(`POST /api/sim → HTTP ${res.status}`);
onRefresh();
if (current === body) sentOwn = true; // object identity — queued bodies are fresh literals
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;
// 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>
<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>
)}
</>
) : null}
</aside>
);
}