'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(null); const [relocateDestIcao, setRelocateDestIcao] = 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(() => {}); }, []); // 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(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 ( ); }