diff --git a/CHANGELOG.md b/CHANGELOG.md index 898205d..2026f9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## v0.9.0 — 2026-09-17 14:24 +- **Single-screen 3-pane shell** (PLAN.md "Layout" increment): `app/page.tsx` is now a CSS grid (`grid-template-columns: 300px minmax(0, 1fr) 320px`) — left Assets pane, center = the existing `` **untouched** (its `.map-wrap` fills the middle column, header included), right placeholder `data-testid="right-pane"` with a subtle "Jobs & Market" heading so the next increment has a home +- **Single polling source**: new hook `lib/useGameState.ts` polls `GET /api/state` every 1000 ms (configurable arg) and returns `{state, loading, error, refresh}` — overlap guard skips a tick while the previous request is still in flight, interval cleaned up on unmount. Called ONCE in `app/page.tsx`; the snapshot flows down as props so later panes share one poll loop instead of running their own +- **Left Assets pane** (`components/LeftPane.tsx`, `data-testid="left-pane"`): wallet cash (formatted EUR), reputation, and a fleet list — per plane: catalog display name (ATR 72-600), status badge, fuel bar + `x / y L`, location (grounded → airport name; flying → "origin → destination" from its bound contract). Time controls: pause/resume toggle + 1x–60x scale select wired to `POST /api/sim`; after each action the pane calls the hook's `refresh()` so it refetches immediately instead of waiting for the next poll tick. Sim clock shown as a UTC wall-clock (`data-testid="sim-clock"`) +- Pure display formatters in new `lib/format.ts` — `formatCash` (EUR, 2 dp), `formatFuel` ("x / y L"), `formatSimClock` (epoch ms → deterministic UTC string) +- No backend changes: all endpoints pre-existed (`GET /api/state`, `POST /api/sim`); no OpenAPI change; Map.tsx and its header untouched per the increment constraint +- Tests: unit — new `lib/format.test.ts` (currency/fuel/clock formatting incl. fractional cash + rounding) and `lib/useGameState.test.ts` (`fetchState` success shape + exact fetch args, non-2xx error with status, network failure via mocked global fetch); e2e — new `e2e/panes.spec.ts`: left pane renders wallet (cash > 0)/reputation/starter ATR 72-600 at Frankfurt + sim clock, time controls pause the sim and set scale 60 through the UI (asserted against authoritative `/api/state` AND in-pane button/select state; restored to scale 1 + unpaused in-test and again in `afterEach`, same pattern as sim.spec.ts), right pane placeholder present +- **Known interaction with v0.4.x map e2e**: the three-pane layout narrows the map column to ~660 px at the default 1280×720 viewport, which puts UKBB Boryspil (~475 px east of the [10,52] center at zoom 4) outside the initial view — `e2e/map.spec.ts` marker-click tests re-pointed from UKBB to **LFRS Nantes Atlantique**, now the most isolated large_airport marker inside the new initial viewport (nearest neighbour LFRB ~78 px away), same pattern as the v0.2.7/v0.2.8 dataset re-points +- **Pre-existing e2e race fixed** (found while landing this increment, reproduced 5/5 on a clean v0.8.0 worktree deploy — NOT caused by the layout): MapLibre's diff-based `setStyle` removes our custom airports source/layer before `ensureAirportsLayer` re-adds it on the next `styledata` event (~20–40 ms window), and the style-switcher test's post-switch wait (`queryRenderedFeatures > 0`) can be satisfied by stale pre-switch worker data, so its single marker click could land in that gap. The test now retries the click until the popup opens (marker is clickable again within milliseconds; the `toHaveCount(1)` assertion still guards against duplicate listeners) +- Verified: unit suite 143 → 150 green; `tsc --noEmit` clean; e2e 18 → 21 green against the rebuilt compose instance (marker-click, zoom clamp and style switch all pass in the narrower middle column) + ## v0.8.0 — 2026-09-17 13:54 - **Player-actions API** (PLAN.md "Core loop" increment, server-side only — NO frontend/UI changes): the core loop now works end-to-end through five new endpoints in `lib/game/actions.ts` + `app/api/`. Every action endpoint follows one convention: tick the sim clock FIRST (so validation/mutation see current game time), validate, mutate, persist atomically under the same promise lock as ticks (`engine.runAction()` — no request can interleave between tick and mutation), then return the **FULL state snapshot** (same shape as GET /api/state) with 200 so clients never need a follow-up poll; errors are proper 4xx `{error}` bodies - **Accept**: `POST /api/contracts/:id/accept` — open → accepted only. 404 unknown id, 409 if the contract is not 'open' (accepted/completed/expired). Single-player, so no ownership tracking beyond the status flip; dispatch binds the plane diff --git a/app/globals.css b/app/globals.css index dd51e60..4eb251a 100644 --- a/app/globals.css +++ b/app/globals.css @@ -67,3 +67,171 @@ body { font-size: 12px; color: #cbd5e1; } + +/* v0.9.0 — single-screen 3-pane shell: left Assets / center map / right Jobs & Market */ +.app-shell { + display: grid; + grid-template-columns: 300px minmax(0, 1fr) 320px; + height: 100%; +} + +.pane { + background: #0f172a; + color: #e2e8f0; + padding: 14px; + font-size: 13px; + overflow-y: auto; +} + +.left-pane { + border-right: 1px solid rgba(148, 163, 184, 0.25); +} + +.right-pane { + border-left: 1px solid rgba(148, 163, 184, 0.25); +} + +.pane-title { + margin: 0 0 12px; + font-size: 14px; + font-weight: 600; + color: #f8fafc; +} + +.pane-heading { + margin: 0 0 8px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #94a3b8; +} + +.pane-section { + margin-bottom: 16px; +} + +.pane-muted { + color: #94a3b8; + font-size: 12px; +} + +.pane-error { + color: #fca5a5; + font-size: 12px; +} + +.stat-row { + display: flex; + justify-content: space-between; + align-items: baseline; + padding: 2px 0; +} + +.stat-row span { + color: #94a3b8; + font-size: 12px; +} + +.stat-row strong { + font-size: 13px; + color: #f8fafc; +} + +.fleet-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.plane-row { + background: rgba(30, 41, 59, 0.6); + border-radius: 6px; + padding: 8px; +} + +.plane-main { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 8px; +} + +.plane-model { + font-weight: 600; + color: #f1f5f9; +} + +.plane-status { + font-size: 11px; + padding: 1px 6px; + border-radius: 4px; + background: rgba(148, 163, 184, 0.2); + color: #cbd5e1; +} + +.status-flying { + background: rgba(59, 130, 246, 0.25); + color: #bfdbfe; +} + +.fuel-bar { + height: 4px; + border-radius: 2px; + background: rgba(148, 163, 184, 0.25); + margin: 6px 0 4px; + overflow: hidden; +} + +.fuel-bar > div { + height: 100%; + border-radius: 2px; + background: #34d399; +} + +.plane-sub { + display: flex; + justify-content: space-between; + gap: 8px; + font-size: 11px; + color: #cbd5e1; +} + +.plane-loc { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sim-clock { + margin: 0 0 8px; + font-size: 12px; + color: #cbd5e1; + font-variant-numeric: tabular-nums; +} + +.time-controls { + display: flex; + gap: 8px; +} + +.time-controls button, +.time-controls select { + font-size: 12px; + padding: 4px 10px; + border-radius: 4px; + border: 1px solid rgba(148, 163, 184, 0.4); + background: rgba(30, 41, 59, 0.9); + color: #e2e8f0; +} + +.time-controls button { + cursor: pointer; +} + +.time-controls select { + flex: 1; +} diff --git a/app/page.tsx b/app/page.tsx index 8a6d19d..ffe534e 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,9 +1,23 @@ 'use client'; import dynamic from 'next/dynamic'; +import LeftPane from '@/components/LeftPane'; +import { useGameState } from '@/lib/useGameState'; const Map = dynamic(() => import('@/components/Map'), { ssr: false }); export default function Home() { - return ; + // Single polling source for the whole shell (v0.9.0): one /api/state loop, snapshot passed + // down as props so later panes can share it without extra poll loops. + const { state, loading, error, refresh } = useGameState(1000); + + return ( +
+ + + +
+ ); } diff --git a/components/LeftPane.tsx b/components/LeftPane.tsx new file mode 100644 index 0000000..e4f74ec --- /dev/null +++ b/components/LeftPane.tsx @@ -0,0 +1,137 @@ +'use client'; + +import { useState, type ChangeEvent } from 'react'; +import type { ContractState, PlaneState, StateSnapshot } from '@/lib/game/state'; +import { PLANE_TYPES } from '@/lib/game/catalog'; +import { formatCash, formatFuel, formatSimClock } from '@/lib/format'; + +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; +} + +const SCALE_OPTIONS = Array.from({ length: 60 }, (_, i) => i + 1); // 1x–60x, matching POST /api/sim's clamp range + +function planeLocation(p: PlaneState, contracts: ContractState[]): string { + if (p.status === 'flying' && p.contractId) { + const c = contracts.find((c) => c.id === p.contractId); + if (c) return `${p.locationName} → ${c.destName}`; + } + return p.locationName; +} + +export default function LeftPane({ state, loading, error, onRefresh }: 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. + const applySimControls = async (body: { timeScale?: number; paused?: boolean }) => { + if (busy) return; + setBusy(true); + try { + const res = await fetch('/api/sim', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + cache: 'no-store', + }); + if (!res.ok) throw new Error(`POST /api/sim → HTTP ${res.status}`); + onRefresh(); + } catch (err) { + console.error('failed to apply sim controls', err); + } finally { + setBusy(false); + } + }; + + const paused = state?.simState.paused ?? false; + const timeScale = state?.simState.timeScale ?? 1; + + return ( + + ); +} diff --git a/e2e/map.spec.ts b/e2e/map.spec.ts index 4b394d0..257bb5c 100644 --- a/e2e/map.spec.ts +++ b/e2e/map.spec.ts @@ -51,15 +51,15 @@ test('clicking an airport marker opens a popup with name and ICAO', async ({ pag { timeout: 15_000 }, ).toBeGreaterThan(0); - // Project UKBB (Boryspil) to screen space via the dev hook, then click it. - // Boryspil is used because with only large_airport rows remaining it is the - // most isolated marker inside the initial zoom-4 viewport: its nearest - // surviving airport is Chişinău (LUKK), ~126 px away at the initial zoom - // (Kyiv Zhuliany, geographically closer, was a medium_airport and got cut), - // so its marker cannot be overlapped by a neighbouring circle. + // Project LFRS (Nantes Atlantique) to screen space via the dev hook, then click it. + // Nantes is used because the v0.9.0 three-pane layout narrows the map column to ~660 px at + // the default 1280×720 viewport, which puts UKBB Boryspil (~475 px east of the [10,52] center) + // outside the initial view; LFRS is now the most isolated large_airport marker inside it — its + // nearest neighbour is LFRB Nantes–Montoir ~78 px away at the initial zoom, so its marker cannot + // be overlapped by a neighbouring circle. const pt = await page.evaluate(() => { const map = (window as any).__flightMap; - const p = map.project([30.894699, 50.345001]); + const p = map.project([-1.61073, 47.153198]); // LFRS Nantes Atlantique — see the marker-click test above for why const rect = map.getContainer().getBoundingClientRect(); return { x: rect.left + p.x, y: rect.top + p.y }; }); @@ -67,8 +67,8 @@ test('clicking an airport marker opens a popup with name and ICAO', async ({ pag const popup = page.getByTestId('airport-popup'); await expect(popup).toBeVisible(); - await expect(popup).toContainText('Boryspil International Airport'); - await expect(popup).toContainText('UKBB'); + await expect(popup).toContainText('Nantes Atlantique Airport'); + await expect(popup).toContainText('LFRS'); }); test('wheel zoom is clamped between min and max zoom', async ({ page }) => { @@ -185,14 +185,21 @@ test('style switcher defaults to Fiord, switches styles, and keeps the airports await expect.poll(renderedCircles, { timeout: 15_000 }).toBeGreaterThan(0); // Clicking a marker after the switch opens exactly one popup (the click handler is - // re-wired idempotently — no duplicate listeners). - const pt = await page.evaluate(() => { - const map = (window as any).__flightMap; - const p = map.project([30.894699, 50.345001]); // UKBB Boryspil - const rect = map.getContainer().getBoundingClientRect(); - return { x: rect.left + p.x, y: rect.top + p.y }; - }); - await page.mouse.click(pt.x, pt.y); + // re-wired idempotently — no duplicate listeners). MapLibre's diff-based setStyle removes our + // custom source/layer before ensureAirportsLayer re-adds it on the next styledata event, so + // retry the click until the popup opens instead of asserting on a single shot (the marker is + // clickable again within milliseconds; toHaveCount(1) below still catches duplicate listeners). + await expect.poll(async () => { + const pt = await page.evaluate(() => { + const map = (window as any).__flightMap; + const p = map.project([-1.61073, 47.153198]); // LFRS Nantes Atlantique — see the marker-click test above for why + const rect = map.getContainer().getBoundingClientRect(); + return { x: rect.left + p.x, y: rect.top + p.y }; + }); + await page.mouse.click(pt.x, pt.y); + return (await page.getByTestId('airport-popup').count()) > 0; + }, { timeout: 15_000 }).toBe(true); + const popups = page.getByTestId('airport-popup'); await expect(popups.first()).toBeVisible(); await expect(popups).toHaveCount(1); @@ -248,7 +255,7 @@ test('falls back to remote OFM styles when the local asset pack is absent', asyn const pt = await page.evaluate(() => { const map = (window as any).__flightMap; - const p = map.project([30.894699, 50.345001]); // UKBB Boryspil + const p = map.project([-1.61073, 47.153198]); // LFRS Nantes Atlantique — see the marker-click test above for why const rect = map.getContainer().getBoundingClientRect(); return { x: rect.left + p.x, y: rect.top + p.y }; }); @@ -256,6 +263,6 @@ test('falls back to remote OFM styles when the local asset pack is absent', asyn const popup = page.getByTestId('airport-popup'); await expect(popup).toBeVisible(); - await expect(popup).toContainText('Boryspil International Airport'); - await expect(popup).toContainText('UKBB'); + await expect(popup).toContainText('Nantes Atlantique Airport'); + await expect(popup).toContainText('LFRS'); }); diff --git a/e2e/panes.spec.ts b/e2e/panes.spec.ts new file mode 100644 index 0000000..fdd7d5e --- /dev/null +++ b/e2e/panes.spec.ts @@ -0,0 +1,87 @@ +import { test, expect, type Page } from '@playwright/test'; + +// v0.9.0 — single-screen 3-pane shell + left Assets pane against the live origin. +// The panes render from the shell's single /api/state poll (lib/useGameState.ts); time +// controls POST to /api/sim and refetch immediately, so UI assertions settle within a tick. + +interface SimState { + clockMs: number; + timeScale: number; + paused: boolean; +} + +interface StateBody { + account: { cash: number; reputation: number }; + simState: SimState; + planes: Array>; + contracts: Array>; +} + +async function getState(page: Page): Promise { + const res = await page.request.get('/api/state'); + expect(res.status()).toBe(200); + return (await res.json()) as StateBody; +} + +// Leave the live instance in a sane state for the other specs (scale 1, running) — same +// pattern as e2e/sim.spec.ts. +test.afterEach(async ({ page }) => { + const res = await page.request.post('/api/sim', { data: { timeScale: 1, paused: false } }); + expect(res.status()).toBe(200); +}); + +test('left pane renders wallet, reputation and the starter fleet at Frankfurt', async ({ page }) => { + await page.goto('/'); + await expect(page.getByTestId('left-pane')).toBeVisible(); + + // Wallet: cash > 0 (formatted EUR) — poll until the first snapshot lands. + const cashEl = page.getByTestId('wallet-cash'); + await expect(cashEl).toBeVisible({ timeout: 15_000 }); + const cashText = (await cashEl.textContent()) ?? ''; + expect(Number(cashText.replace(/[^0-9.]/g, ''))).toBeGreaterThan(0); + + // Reputation present and numeric. + await expect(page.getByTestId('reputation')).toBeVisible(); + expect(Number((await page.getByTestId('reputation').textContent()) ?? 'NaN')).toBeGreaterThanOrEqual(0); + + // Fleet: the starter ATR 72-600 at Frankfurt (EDDF). + const row = page.getByTestId('fleet-list').locator('.plane-row').first(); + await expect(row, 'starter plane row appears once the first snapshot lands').toBeVisible({ timeout: 15_000 }); + await expect(row).toContainText('ATR 72-600'); + await expect(row).toContainText('Frankfurt'); + + // Sim clock renders (UTC wall-clock of the game time). + await expect(page.getByTestId('sim-clock')).toContainText(/UTC/); +}); + +test('time controls: pause freezes the sim and the scale selector sets timeScale=60', async ({ page }) => { + await page.goto('/'); + const pauseBtn = page.getByTestId('pause-toggle'); + await expect(pauseBtn).toBeVisible({ timeout: 15_000 }); + + // Pause via the UI button — assert against the authoritative /api/state… + await pauseBtn.click(); + await expect.poll(async () => (await getState(page)).simState.paused, { timeout: 10_000 }).toBe(true); + // …and in the pane itself (the refetch after POST flips the button to Resume). + await expect(pauseBtn).toHaveText('Resume'); + + // Scale via the UI select. + const scaleSelect = page.getByTestId('time-scale-select'); + await scaleSelect.selectOption('60'); + await expect.poll(async () => (await getState(page)).simState.timeScale, { timeout: 10_000 }).toBe(60); + await expect(scaleSelect).toHaveValue('60'); + + // Restore the live instance to scale 1 + unpaused (afterEach enforces this too). + const restore = await page.request.post('/api/sim', { data: { timeScale: 1, paused: false } }); + expect(restore.status()).toBe(200); + const after = await getState(page); + expect(after.simState.timeScale).toBe(1); + expect(after.simState.paused).toBe(false); +}); + +test('right pane placeholder exists with the Jobs & Market heading', async ({ page }) => { + await page.goto('/'); + const right = page.getByTestId('right-pane'); + await expect(right).toBeVisible(); + await expect(right).toContainText('Jobs & Market'); +}); diff --git a/lib/format.test.ts b/lib/format.test.ts new file mode 100644 index 0000000..b390cbe --- /dev/null +++ b/lib/format.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { formatCash, formatFuel, formatSimClock } from './format'; + +describe('formatCash', () => { + it('formats EUR with thousands separators and two decimals', () => { + expect(formatCash(20_000)).toBe('€20,000.00'); + expect(formatCash(1_234_567.891)).toBe('€1,234,567.89'); + }); + + it('handles zero and fractional values (refuel costs are fractional)', () => { + expect(formatCash(0)).toBe('€0.00'); + expect(formatCash(30.4)).toBe('€30.40'); + }); +}); + +describe('formatFuel', () => { + it('shows current / max liters with the current value rounded', () => { + expect(formatFuel(6300, 6300)).toBe('6300 / 6300 L'); + expect(formatFuel(1234.6, 6300)).toBe('1235 / 6300 L'); + expect(formatFuel(0, 6300)).toBe('0 / 6300 L'); + }); +}); + +describe('formatSimClock', () => { + it('formats epoch ms as a UTC wall-clock string (deterministic across timezones)', () => { + expect(formatSimClock(Date.UTC(2026, 8, 17, 14, 5))).toBe('2026-09-17 14:05 UTC'); + expect(formatSimClock(Date.UTC(2026, 0, 1, 0, 0))).toBe('2026-01-01 00:00 UTC'); + }); +}); diff --git a/lib/format.ts b/lib/format.ts new file mode 100644 index 0000000..39bc39a --- /dev/null +++ b/lib/format.ts @@ -0,0 +1,20 @@ +// Pure display formatters for the client panes (v0.9.0) — no game logic, unit-testable in node. + +const CURRENCY = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'EUR' }); + +export function formatCash(value: number): string { + return CURRENCY.format(value); +} + +// "6300 / 6300 L" — current value rounded (live fuel is fractional mid-flight). +export function formatFuel(fuel: number, maxFuel: number): string { + return `${Math.round(fuel)} / ${maxFuel} L`; +} + +// The sim clock is epoch ms (seeded from real time) — shown as a UTC wall-clock so the +// display is deterministic regardless of the viewer's timezone. +export function formatSimClock(clockMs: number): string { + const d = new Date(clockMs); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())} UTC`; +} diff --git a/lib/useGameState.test.ts b/lib/useGameState.test.ts new file mode 100644 index 0000000..9fb4707 --- /dev/null +++ b/lib/useGameState.test.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { fetchState } from './useGameState'; + +// The hook's fetch logic (lib/useGameState.ts) against a mocked global fetch — the polling +// loop itself is plain setInterval and covered by e2e (panes.spec.ts renders from it). + +const SNAPSHOT = { + account: { cash: 20_000, reputation: 10 }, + simState: { clockMs: 1_750_000_000_000, timeScale: 1, paused: false }, + planes: [], + contracts: [], +}; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('fetchState', () => { + it('GETs /api/state with no-store and returns the parsed snapshot on success', async () => { + const fetchMock = vi.fn(async () => ({ ok: true, status: 200, json: async () => SNAPSHOT })); + vi.stubGlobal('fetch', fetchMock); + + await expect(fetchState()).resolves.toEqual(SNAPSHOT); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith('/api/state', { cache: 'no-store' }); + }); + + it('throws with the HTTP status on a non-2xx response', async () => { + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 500, json: async () => ({}) }))); + await expect(fetchState()).rejects.toThrow('GET /api/state → HTTP 500'); + }); + + it('throws on a network failure (no response at all)', async () => { + vi.stubGlobal('fetch', vi.fn(async () => { + throw new Error('fetch failed'); + })); + await expect(fetchState()).rejects.toThrow('fetch failed'); + }); +}); diff --git a/lib/useGameState.ts b/lib/useGameState.ts new file mode 100644 index 0000000..9814a33 --- /dev/null +++ b/lib/useGameState.ts @@ -0,0 +1,55 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { StateSnapshot } from './game/state'; + +export interface GameStateResult { + state: StateSnapshot | null; + // True until the first successful fetch (stale-while-error afterwards). + loading: boolean; + error: string | null; + // Trigger an immediate refetch (used after POST /api/sim so the UI updates without waiting for the next tick). + refresh: () => void; +} + +// Single fetch of the full game-state snapshot. Exported separately from the hook so it can be unit-tested with a mocked fetch. +export async function fetchState(): Promise { + const res = await fetch('/api/state', { cache: 'no-store' }); + if (!res.ok) throw new Error(`GET /api/state → HTTP ${res.status}`); + return (await res.json()) as StateSnapshot; +} + +// The shell's single polling source (v0.9.0): one GET /api/state loop for the whole page, +// so every pane renders from the same snapshot instead of running its own poll loops. +export function useGameState(intervalMs = 1000): GameStateResult { + const [state, setState] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + // Overlap guard: if the previous request is still in flight (slow server), skip this tick. + const inFlightRef = useRef(false); + + const refresh = useCallback(() => { + if (inFlightRef.current) return; + inFlightRef.current = true; + fetchState() + .then((snapshot) => { + setState(snapshot); + setError(null); + setLoading(false); + }) + .catch((err: unknown) => { + setError(err instanceof Error ? err.message : String(err)); + }) + .finally(() => { + inFlightRef.current = false; + }); + }, []); + + useEffect(() => { + refresh(); // fetch immediately on mount, don't wait for the first interval tick + const id = setInterval(refresh, intervalMs); + return () => clearInterval(id); + }, [intervalMs, refresh]); + + return { state, loading, error, refresh }; +} diff --git a/package.json b/package.json index 10d9c1a..52ec511 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flight-ops", - "version": "0.8.0", + "version": "0.9.0", "private": true, "scripts": { "dev": "next dev -p 3000",