diff --git a/components/mission-control.tsx b/components/mission-control.tsx index 18e2944..a579712 100644 --- a/components/mission-control.tsx +++ b/components/mission-control.tsx @@ -13,6 +13,7 @@ import { CrewPanel } from '@/components/crew-panel' import { EventLogPanel } from '@/components/event-log' import { LocalSystemPanel } from '@/components/local-system' import { MissionPanel } from '@/components/missions' +import { NextTurnButton } from '@/components/next-turn-button' import { ShipSystemsPanel } from '@/components/ship-systems' import { SkillTreePanel } from '@/components/skill-tree' import { WorldMapPanel } from '@/components/world-map' @@ -62,7 +63,12 @@ export function MissionControl() { // #38 — admin DB CRUD overlay, opened from the header. const [adminOpen, setAdminOpen] = useState(false) - useEffect(() => { + // #44 — the four mount fetches, extracted into a callback so the NEXT TURN button (5b) can + // re-run them after /api/turn: a tick can change all four snapshots (the turn always; inventory + // on an explore resolve, system on a jump resolve, the roster on a recruit, tech on a research + // completion) — a strict superset of refreshSystem, which stays for the jump callback. Same + // failure policy as the mount fetch — keep the old state on error. + const refreshAll = useCallback(() => { let cancelled = false fetch('/api/ship') .then(async (res) => { @@ -105,6 +111,9 @@ export function MissionControl() { } }, []) + // The effect returns refreshAll's cleanup, so unmount still cancels the in-flight setStates. + useEffect(() => refreshAll(), [refreshAll]) + useEffect(() => { const id = setInterval(() => setTick((t) => t + 1), 1000) return () => clearInterval(id) @@ -548,6 +557,11 @@ export function MissionControl() { SYS ONLINE + + {/* #44 — the manual tick control (5b): fixed bottom-right, outside the tab grid so it + stays visible on every tab (Radix unmounts inactive tabs — the button only refreshes + the snapshots this shell owns) */} + ) } diff --git a/components/next-turn-button.tsx b/components/next-turn-button.tsx new file mode 100644 index 0000000..9d5d10e --- /dev/null +++ b/components/next-turn-button.tsx @@ -0,0 +1,54 @@ +'use client' + +import { useState } from 'react' +import { Button } from '@/components/ui/button' + +// NEXT TURN — the manual tick control (5b): a fixed bottom-right button + the current turn from +// the ship snapshot the shell holds. One click = POST /api/turn (no body); on success the shell +// re-fetches its snapshots. Review Focus 4: the button is NEVER disabled by game state — an +// empty queue is a FREE SKIP (the route always returns 200) — and the only transient is an +// in-flight request, which the handler ignores. `disabled` is never set, so the cva +// disabled:opacity-40 styling never applies either. + +export function NextTurnButton({ turn, onTicked }: { turn: number | null; onTicked: () => void }) { + const [inFlight, setInFlight] = useState(false) + const [error, setError] = useState(null) + + const tick = () => { + if (inFlight) return // the only guard — a transient, never a game-state disable + setInFlight(true) + setError(null) + fetch('/api/turn', { method: 'POST' }) + .then(async (res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`) + onTicked() // the same app-wide snapshot refresh the shell uses after every other action + }) + .catch((err: unknown) => setError(err instanceof Error ? err.message : String(err))) + .finally(() => setInFlight(false)) + } + + return ( +
+ {error && ( + {error.toUpperCase()} + )} + + TURN {turn ?? '—'} + + +
+ ) +}