From f4690bc1a2532c1594c92b1a08e830cdb5f47c6e Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Tue, 29 Sep 2026 09:42:18 +0000 Subject: [PATCH] feat(ui): keep the active operations tab across page refreshes (#27) Persist the selected tab ID to localStorage (sw-active-tab) on every change and restore it on mount when it matches one of the seven valid tab IDs. The Radix Tabs become controlled for this; SSR and first client render still agree on the default, so there is no hydration mismatch. --- CHANGELOG.md | 4 ++++ components/mission-control.tsx | 22 +++++++++++++++++++++- e2e/smoke.spec.ts | 12 ++++++++++++ package.json | 2 +- 4 files changed, 38 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc2d125..d601c37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## v0.9.6 — 2026-09-29 09:41 +- The active operations tab now survives a page refresh (#27): the selected tab ID is persisted to `localStorage` (`sw-active-tab`) on every change and restored on mount when it matches one of the seven valid tab IDs (unknown/stale values fall back to WORLD MAP). The Radix Tabs are controlled for this; SSR and first client render still agree on the default, so there is no hydration mismatch. +- e2e: new smoke spec — click CREW, reload, assert `aria-selected=true` again (fresh context per run/retry, no API state touched, idempotent). + ## v0.9.5 — 2026-09-29 09:13 - Fixed the production freeze reported after the v0.9.3 (#21) deploy ("entire UI system is now fucked"). Root cause was NOT the #21 layout change (verified healthy across all 7 tabs and multiple viewports — no clipping, middle pane scrolls internally, side panes fixed): it was world name generation. `makeName` draws from a finite pool of 16×8=128 system names with an unbounded rejection loop, so once a save grew past ~128 systems (turns of play / e2e jump loops), any jump into an undiscovered system spun forever on Node's single thread — sql.js runs synchronously, so the whole process blocked and EVERY API route hung, leaving all panels stuck at LOADING. - `makeName` (lib/game/world.ts) now falls back to numbered variants (`Kepler Drift 2`) once the pool is exhausted, so it always terminates; `makeCrewName` (lib/game/crew.ts) gets the same guard — the roster has no cap and its 16×22=352 pool would eventually exhaust too. diff --git a/components/mission-control.tsx b/components/mission-control.tsx index 237f8cc..21ffe38 100644 --- a/components/mission-control.tsx +++ b/components/mission-control.tsx @@ -38,6 +38,11 @@ function useNarrow(threshold = 900): boolean { return narrow } +// Must match the TabsTrigger values in the render below — used to validate the tab persisted for +// #27 (an unknown stored value falls back to the default). +const TAB_IDS = ['world-map', 'local-system', 'crew', 'ship-systems', 'skill-tree', 'missions', 'event-log'] +const ACTIVE_TAB_KEY = 'sw-active-tab' + export function MissionControl() { const narrow = useNarrow() const [tick, setTick] = useState(0) @@ -49,6 +54,9 @@ export function MissionControl() { const [system, setSystem] = useState(null) const [techs, setTechs] = useState(null) const [crewCount, setCrewCount] = useState(null) + // #27 — the active tab survives a page refresh. Restored from localStorage on mount; null until + // then so SSR and the first client render agree on the default tab (no hydration mismatch). + const [activeTab, setActiveTab] = useState(null) useEffect(() => { let cancelled = false @@ -98,6 +106,12 @@ export function MissionControl() { return () => clearInterval(id) }, []) + // #27 — restore the persisted tab once on mount (validated against TAB_IDS). + useEffect(() => { + const stored = window.localStorage.getItem(ACTIVE_TAB_KEY) + if (stored && TAB_IDS.includes(stored)) setActiveTab(stored) + }, []) + const timeStr = new Date().toLocaleTimeString('en-GB', { hour12: false }) // Slugs of the researched techs — drives the effective caps + build gates in the SHIP SYSTEMS @@ -356,7 +370,13 @@ export function MissionControl() { - + { + setActiveTab(v) + window.localStorage.setItem(ACTIVE_TAB_KEY, v) // #27 — keep the tab across refreshes + }} + > await expect(page.getByText(/T\d+ \/\/ /).first()).toBeVisible(); }); +test('active tab survives a page reload (#27)', async ({ page }) => { + await page.goto('/'); + // Fresh context → nothing stored yet, so CREW starts unselected (the default is WORLD MAP). + await expect(page.getByRole('tab', { name: 'CREW' })).toHaveAttribute('aria-selected', 'false'); + await page.getByRole('tab', { name: 'CREW' }).click(); + await expect(page.getByRole('tab', { name: 'CREW' })).toHaveAttribute('aria-selected', 'true'); + + await page.reload(); + // The tab is restored from localStorage on mount — no API state involved, so this holds across retries. + await expect(page.getByRole('tab', { name: 'CREW' })).toHaveAttribute('aria-selected', 'true'); +}); + test('world map shows the generated starting system and its neighbours', async ({ page }) => { const world = (await (await page.request.get('/api/world')).json()) as WorldState; diff --git a/package.json b/package.json index 278651e..4a914e8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "space-wanderer", - "version": "0.9.5", + "version": "0.9.6", "private": true, "scripts": { "dev": "next dev -p 3000", -- 2.54.0