Compare commits

...
2 Commits
Author SHA1 Message Date
kamilkrzywda e6c88b10c2 Merge pull request 'v0.9.6 — keep active tab between page refresh (#27)' (#29) from keep-active-tab into master
CI / build + unit + e2e (push) Successful in 2m46s
2026-09-29 11:43:21 +02:00
kamilkrzywda f4690bc1a2 feat(ui): keep the active operations tab across page refreshes (#27)
CI / build + unit + e2e (pull_request) Successful in 2m15s
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.
2026-09-29 09:42:18 +00:00
4 changed files with 38 additions and 2 deletions
+4
View File
@@ -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.
+21 -1
View File
@@ -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<SystemState | null>(null)
const [techs, setTechs] = useState<TechDto[] | null>(null)
const [crewCount, setCrewCount] = useState<number | null>(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<string | null>(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() {
</PanelHeader>
<PanelContent style={{ padding: 0, flex: 1, minHeight: 0, overflowY: 'auto' }}>
<Tabs defaultValue="world-map">
<Tabs
value={activeTab ?? 'world-map'}
onValueChange={(v) => {
setActiveTab(v)
window.localStorage.setItem(ACTIVE_TAB_KEY, v) // #27 — keep the tab across refreshes
}}
>
<TabsList
style={{
padding: '0 1rem',
+12
View File
@@ -70,6 +70,18 @@ test('tabs switch panels client-side (event log terminal)', async ({ page }) =>
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;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "space-wanderer",
"version": "0.9.5",
"version": "0.9.6",
"private": true,
"scripts": {
"dev": "next dev -p 3000",