From 62f9929b51526abcd3270e6c2c2563c8fb2fb776 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Thu, 17 Sep 2026 16:13:28 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20v0.9.1=20=E2=80=94=20left-pane=20time?= =?UTF-8?q?=20controls=20refined:=20log-scale=20slider=20(t=E2=86=92round(?= =?UTF-8?q?60^t),=20250=20ms=20debounced=20commit=20with=20release=20flush?= =?UTF-8?q?=20+=20in-flight=20queue)=20replaces=20the=20scale=20select;=20?= =?UTF-8?q?game=20clock=20now=20shows=20seconds=20(HH:MM:SS=20UTC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 9 +++ app/globals.css | 17 +++-- components/LeftPane.tsx | 135 ++++++++++++++++++++++++++++++---------- e2e/panes.spec.ts | 20 ++++-- lib/format.test.ts | 7 ++- lib/format.ts | 6 +- lib/timeScale.test.ts | 65 +++++++++++++++++++ lib/timeScale.ts | 22 +++++++ package.json | 2 +- 9 files changed, 232 insertions(+), 51 deletions(-) create mode 100644 lib/timeScale.test.ts create mode 100644 lib/timeScale.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2026f9c..4a6b00b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## v0.9.1 — 2026-09-17 16:03 +- **Log-scale time slider** (left-pane UI refinement): the 1x–60x `` — position t ∈ [0,1] maps to `scale = round(60^t)` (t=0→1×, mid≈8×, t=1→60×) so equal slider distances are equal multiplicative steps; 60 discrete positions (`step = 1/59`) keep keyboard arrows usable (~7% per step). The current value is shown beside the slider as "N×" (`data-testid="time-scale-value"`); pause/resume toggle unchanged +- **Debounced commit**: dragging updates the displayed value optimistically; `POST /api/sim {timeScale}` fires after a 250 ms debounce and flushes immediately on release (native `change` event); a commit landing while another POST is in flight is held in a single slot and flushed right after, so rapid slider moves never drop the last intent — server's [1,60] clamp kept as backstop, no new dependencies +- **Pure mapping helpers** in new `lib/timeScale.ts`: `sliderToScale(t)` / `scaleToSlider(scale)`, both clamped; exact at endpoints and round-tripping every integer 1..60 +- **Game clock shows seconds**: `formatSimClock` (in `lib/format.ts`) now renders the sim clock as `YYYY-MM-DD HH:MM:SS UTC` — same deterministic UTC wall-clock, seconds added +- No backend changes: `POST /api/sim` signature and clamp unchanged; no OpenAPI change +- Tests: unit — new `lib/timeScale.test.ts` (endpoint exactness 0→1/1→60, mid-slider ≈8×, monotonic non-decreasing over a 2000-step grid, full 1..60 coverage, clamping of out-of-range/non-finite input, round-trip for all integers 1..60) and `lib/format.test.ts` clock expectations updated to include seconds; e2e — `e2e/panes.spec.ts` time-controls test re-driven through the slider (keyboard End → max asserts label "60×" + `/api/state` timeScale=60, Home → min asserts "1×" + timeScale=1) and now also asserts the clock text matches `HH:MM:SS`; live instance restored to scale 1 + unpaused in-test and in `afterEach` as before +- Verified: unit suite 150 → 159 green; `tsc --noEmit` clean; e2e re-run against the rebuilt compose instance + ## 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 diff --git a/app/globals.css b/app/globals.css index 4eb251a..98bf0fc 100644 --- a/app/globals.css +++ b/app/globals.css @@ -218,20 +218,25 @@ body { gap: 8px; } -.time-controls button, -.time-controls select { +.time-controls button { 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 { +.time-controls input[type='range'] { flex: 1; + accent-color: #38bdf8; +} + +.time-scale-value { + font-size: 12px; + color: #e2e8f0; + min-width: 34px; + text-align: right; + font-variant-numeric: tabular-nums; } diff --git a/components/LeftPane.tsx b/components/LeftPane.tsx index e4f74ec..5283760 100644 --- a/components/LeftPane.tsx +++ b/components/LeftPane.tsx @@ -1,9 +1,10 @@ 'use client'; -import { useState, type ChangeEvent } from 'react'; +import { useCallback, useEffect, useRef, 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'; +import { scaleToSlider, sliderToScale } from '@/lib/timeScale'; interface LeftPaneProps { state: StateSnapshot | null; @@ -13,7 +14,10 @@ interface LeftPaneProps { onRefresh: () => void; } -const SCALE_OPTIONS = Array.from({ length: 60 }, (_, i) => i + 1); // 1x–60x, matching POST /api/sim's clamp range +// Log-scale slider (v0.9.1): position t ∈ [0,1] → scale = round(60^t), so equal distances +// are equal multiplicative steps. 60 discrete positions keep keyboard arrows usable (~7% per step). +const SCALE_SLIDER_STEP = 1 / 59; +const SCALE_COMMIT_DEBOUNCE_MS = 250; function planeLocation(p: PlaneState, contracts: ContractState[]): string { if (p.status === 'flying' && p.contractId) { @@ -28,29 +32,95 @@ export default function LeftPane({ state, loading, error, onRefresh }: LeftPaneP // 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); - } - }; + // running poll retries on its own and surfaces the error state. A commit that lands while + // another POST is in flight is held in a single slot and flushed right after, so the last + // user intent always reaches the server (the server's [1,60] clamp stays as backstop). + const queuedRef = useRef<{ timeScale?: number; paused?: boolean } | null>(null); + + const applySimControls = useCallback( + async (body: { timeScale?: number; paused?: boolean }) => { + if (busy) { + queuedRef.current = body; + 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 queued = queuedRef.current; + if (queued !== null) { + queuedRef.current = null; + void applySimControls(queued); + } + } + }, + [busy, onRefresh], + ); const paused = state?.simState.paused ?? false; const timeScale = state?.simState.timeScale ?? 1; + // 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,60] 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; + setPending(null); + void applySimControls({ timeScale: s }); + }, [applySimControls, 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); + }, + [], + ); + + const displayedScale = pendingScale ?? timeScale; + return (