'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 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; // 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, superseded: () => boolean, sleep: (ms: number) => Promise = (ms) => new Promise((r) => setTimeout(r, ms)), maxAttempts: number = SIM_POST_MAX_ATTEMPTS, delayMs: number = SIM_POST_RETRY_DELAY_MS, ): Promise { 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 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(null); const [relocateDestIcao, setRelocateDestIcao] = useState(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(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 | null>(null); const airportsPromiseRef = useRef> | 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; }) .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 => { 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; // 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). `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(null); const pendingRef = useRef(null); const commitTimerRef = useRef | 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) => { 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(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(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 ( ); }