Files
Flight-Ops/components/RightPane.tsx
T

340 lines
13 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, useState } from 'react';
import type { ContractState, PlaneState, StateSnapshot } from '@/lib/game/state';
import { PLANE_TYPES } from '@/lib/game/catalog';
import { formatCash, formatDeadlineRelative } from '@/lib/format';
interface RightPaneProps {
state: StateSnapshot | null;
loading: boolean;
// Push the full snapshot returned by an action endpoint into the shared shell state (v0.10.0),
// so the pane updates on click without waiting for the next poll tick.
applySnapshot: (snapshot: StateSnapshot) => void;
}
const planeName = (p: PlaneState): string => PLANE_TYPES[p.model]?.name ?? p.model;
// Why a plane cannot be dispatched on `contract` — null when it can (grounded at the origin).
function dispatchReason(p: PlaneState, contract: ContractState): string | null {
if (p.status !== 'available') return 'in flight';
if (p.locationIcao !== contract.originIcao) return `at ${p.locationIcao}`;
return null;
}
// The plane a dispatch picker should act on for one accepted contract: the user's explicit pick
// while it stays eligible, else NOTHING. v0.10.1 removed the "auto-track first eligible" fallback —
// it made every row display the same plane by default (selections looked shared across contracts)
// and left no way to show "nothing chosen". null = placeholder shown + Dispatch disabled; a poll
// that makes the pick ineligible (plane flew / moved away) drops back to the placeholder too.
export function dispatchSelection(
planes: PlaneState[],
contract: ContractState,
selectedId: string | null,
): PlaneState | null {
if (selectedId === null) return null;
return planes.find((p) => p.id === selectedId && dispatchReason(p, contract) === null) ?? null;
}
// Dispatch controls for one accepted, not-yet-dispatched contract: a plane picker where only
// grounded planes at the origin are enabled (others disabled with the reason), plus a dispatch
// button bound to the selection. Each row owns its selection in local state and defaults to the
// "Select a plane…" placeholder — never preselecting — so rows stay visibly independent and a
// dispatch can't fire on the wrong contract by accident.
function DispatchControls({
contract,
planes,
busy,
onDispatch,
}: {
contract: ContractState;
planes: PlaneState[];
busy: boolean;
onDispatch: (planeId: string) => void;
}) {
const [selectedId, setSelectedId] = useState<string | null>(null);
const effective = dispatchSelection(planes, contract, selectedId);
return (
<div className="dispatch-controls">
<select
data-testid={`dispatch-plane-${contract.id}`}
aria-label={`Dispatch plane for ${contract.originName} to ${contract.destName}`}
value={effective?.id ?? ''}
onChange={(e) => setSelectedId(e.target.value || null)}
>
<option value="">Select a plane…</option>
{planes.map((p) => {
const reason = dispatchReason(p, contract);
return (
<option key={p.id} value={p.id} disabled={reason !== null}>
{planeName(p)}
{reason ? ` — ${reason}` : ''}
</option>
);
})}
</select>
<button
type="button"
data-testid={`dispatch-${contract.id}`}
className="pane-btn"
disabled={busy || effective === null}
onClick={() => {
if (effective) onDispatch(effective.id);
}}
>
Dispatch
</button>
</div>
);
}
export default function RightPane({ state, loading, applySnapshot }: RightPaneProps) {
const [busy, setBusy] = useState(false);
// Inline error from a failed action — auto-clears on the next successful poll (the shell's
// snapshot prop changes every tick) or the next successful action.
const [actionError, setActionError] = useState<string | null>(null);
useEffect(() => {
if (state !== null) setActionError(null);
}, [state]);
// Market picker selections — same "effective selection" pattern as DispatchControls: the
// user's pick while it stays valid, else a sensible default (first catalog entry / first plane).
const [buyModelId, setBuyModelId] = useState<string | null>(null);
const [sellPlaneId, setSellPlaneId] = useState<string | null>(null);
const [refuelPlaneId, setRefuelPlaneId] = useState<string | null>(null);
// POST an action endpoint. Every success returns the FULL post-action snapshot (v0.8.0
// convention) — pushed into the shared state immediately; a 4xx {error} body becomes the
// inline pane error and the stale UI stays until the next poll. Buttons are disabled while
// a request is in flight, so at most one action runs at a time.
const runAction = useCallback(
async (path: string, body?: unknown) => {
if (busy) return;
setBusy(true);
try {
const res = await fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
cache: 'no-store',
});
const data = (await res.json()) as StateSnapshot & { error?: string };
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
setActionError(null);
applySnapshot(data);
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
},
[applySnapshot, busy],
);
if (loading && !state) {
return (
<aside data-testid="right-pane" className="pane right-pane">
<h2 className="pane-title">Jobs &amp; Market</h2>
<p data-testid="right-loading" className="pane-muted">Loading…</p>
</aside>
);
}
if (!state) return null;
const clockMs = state.simState.clockMs;
const open = state.contracts.filter((c) => c.status === 'open');
const accepted = state.contracts.filter((c) => c.status === 'accepted');
const catalog = state.catalog;
const buyEntry = catalog.find((e) => e.model === buyModelId) ?? catalog[0] ?? null;
const grounded = state.planes.filter((p) => p.status === 'available');
const sellPlane = state.planes.find((p) => p.id === sellPlaneId && p.status === 'available') ?? grounded[0] ?? null;
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;
// Previews computed client-side from the snapshot's catalog + economy constants (v0.10.0).
const sellEntry = sellPlane ? catalog.find((e) => e.model === sellPlane.model) : undefined;
const sellPayout = sellEntry ? Math.round(sellEntry.price * state.sellFraction * 100) / 100 : null;
const refuelCost = refuelPlane ? (refuelPlane.maxFuel - refuelPlane.fuel) * state.fuelPricePerL : null;
return (
<aside data-testid="right-pane" className="pane right-pane">
<h2 className="pane-title">Jobs &amp; Market</h2>
<section className="pane-section">
<h3 className="pane-heading">Open contracts</h3>
{open.length === 0 ? (
<p className="pane-muted">No open contracts.</p>
) : (
<ul data-testid="open-contracts" className="contract-list">
{open.map((c) => (
<li key={c.id} data-testid={`open-contract-${c.id}`} className="contract-row">
<div className="contract-main">
<span className="contract-route">{c.originName} → {c.destName}</span>
<button
type="button"
data-testid={`accept-${c.id}`}
className="pane-btn"
disabled={busy}
onClick={() => void runAction(`/api/contracts/${c.id}/accept`)}
>
Accept
</button>
</div>
<div className="contract-sub">
<span data-testid={`open-contract-reward-${c.id}`}>{formatCash(c.reward)}</span>
<span>{c.cargoType} × {c.cargoCount}</span>
<span data-testid={`open-contract-deadline-${c.id}`}>
{formatDeadlineRelative(c.deadlineSimTime, clockMs)}
</span>
</div>
</li>
))}
</ul>
)}
</section>
<section className="pane-section">
<h3 className="pane-heading">Accepted</h3>
{accepted.length === 0 ? (
<p className="pane-muted">Nothing accepted yet.</p>
) : (
<ul data-testid="accepted-contracts" className="contract-list">
{accepted.map((c) => (
<li key={c.id} data-testid={`accepted-contract-${c.id}`} className="contract-row">
<div className="contract-main">
<span className="contract-route">{c.originName} → {c.destName}</span>
{c.planeId ? (
<span data-testid={`contract-inflight-${c.id}`} className="plane-status status-flying">
in flight
</span>
) : null}
</div>
<div className="contract-sub">
<span>{formatCash(c.reward)}</span>
<span>{c.cargoType} × {c.cargoCount}</span>
<span>{formatDeadlineRelative(c.deadlineSimTime, clockMs)}</span>
</div>
{!c.planeId && (
<DispatchControls
contract={c}
planes={state.planes}
busy={busy}
onDispatch={(planeId) => void runAction(`/api/planes/${planeId}/dispatch`, { contractId: c.id })}
/>
)}
</li>
))}
</ul>
)}
</section>
<section className="pane-section">
<h3 className="pane-heading">Market</h3>
<div className="market-row">
<select
data-testid="market-buy-model"
aria-label="Plane to buy"
value={buyEntry?.model ?? ''}
onChange={(e) => setBuyModelId(e.target.value || null)}
>
{catalog.map((e) => (
<option key={e.model} value={e.model}>
{e.label}
</option>
))}
</select>
<span data-testid="market-buy-price" className="market-preview">
{buyEntry ? formatCash(buyEntry.price) : ''}
</span>
<button
type="button"
data-testid="market-buy"
className="pane-btn"
disabled={busy || buyEntry === null}
onClick={() => {
if (buyEntry) void runAction('/api/market/buy-plane', { model: buyEntry.model });
}}
>
Buy
</button>
</div>
<div className="market-row">
<select
data-testid="market-sell-plane"
aria-label="Plane to sell"
value={sellPlane?.id ?? ''}
onChange={(e) => setSellPlaneId(e.target.value || null)}
>
{grounded.map((p) => (
<option key={p.id} value={p.id}>
{planeName(p)} — {p.locationIcao}
</option>
))}
</select>
<span data-testid="market-sell-payout" className="market-preview">
{sellPayout !== null ? formatCash(sellPayout) : ''}
</span>
<button
type="button"
data-testid="market-sell"
className="pane-btn"
disabled={busy || sellPlane === null}
onClick={() => {
if (sellPlane) void runAction('/api/market/sell-plane', { planeId: sellPlane.id });
}}
>
Sell
</button>
</div>
<div className="market-row">
{refuelable.length === 0 ? (
<span data-testid="market-refuel-none" className="pane-muted">
All tanks full.
</span>
) : (
<>
<select
data-testid="market-refuel-plane"
aria-label="Plane to refuel"
value={refuelPlane?.id ?? ''}
onChange={(e) => setRefuelPlaneId(e.target.value || null)}
>
{refuelable.map((p) => (
<option key={p.id} value={p.id}>
{planeName(p)} — {Math.round(p.fuel)} / {p.maxFuel} L
</option>
))}
</select>
<span data-testid="market-refuel-cost" className="market-preview">
{refuelCost !== null ? formatCash(refuelCost) : ''}
</span>
</>
)}
<button
type="button"
data-testid="market-refuel"
className="pane-btn"
disabled={busy || refuelPlane === null}
onClick={() => {
if (refuelPlane) void runAction(`/api/planes/${refuelPlane.id}/refuel`);
}}
>
Refuel
</button>
</div>
</section>
{actionError && <p data-testid="right-error" className="pane-error">{actionError}</p>}
</aside>
);
}