diff --git a/CLAUDE.md b/CLAUDE.md index 9591095..fe773dc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -112,7 +112,10 @@ Theme state lives in `ui/src/theme.jsx` — a React context (`ThemeContext`) wit - Operation panel (Scale/Recode/Clone) SQL generation and dim_period JOIN are complete; UI wiring to API still needs completion - Load progress bar is jittery — needs throttle (~10 updates/sec) - Default pivot layout should be configurable per source (currently hardcodes first 2 dimensions) -- Source/version selection doesn't persist across page reload +- Source/version selection persists in `localStorage` (`pf_sourceId` / `pf_versionId`, + `App.jsx`). It is re-validated against the live list whenever that list changes, so a + deregistered source or deleted version re-points at the first remaining one instead of + leaving a dead id that 404s every call - Col_meta / version schema drift: if col_meta roles change after a version's forecast table is created, SQL and DDL go out of sync — workaround is to delete and recreate the version ## Deferred (not in v1) diff --git a/ui/src/App.jsx b/ui/src/App.jsx index 1ed8ba1..64021e6 100644 --- a/ui/src/App.jsx +++ b/ui/src/App.jsx @@ -10,6 +10,7 @@ export default function App() { const [sidebarExpanded, setSidebarExpanded] = useState(() => localStorage.getItem('pf_sidebar') !== 'collapsed') const [sources, setSources] = useState([]) + const [sourcesLoaded, setSourcesLoaded] = useState(false) const [sourceId, setSourceId] = useState(() => localStorage.getItem('pf_sourceId') || '') const [versions, setVersions] = useState([]) const [versionId, setVersionId] = useState(() => localStorage.getItem('pf_versionId') || '') @@ -21,37 +22,49 @@ export default function App() { const refreshSources = useCallback(async () => { const data = await fetch('/api/sources').then(r => r.json()) - setSources(data) - return data + const list = Array.isArray(data) ? data : [] + setSources(list) + setSourcesLoaded(true) + return list }, []) const refreshVersions = useCallback(async (sid) => { const id = sid ?? sourceId if (!id) { setVersions([]); return [] } const data = await fetch(`/api/sources/${id}/versions`).then(r => r.json()) - setVersions(data) - return data + const list = Array.isArray(data) ? data : [] + setVersions(list) + return list }, [sourceId]) + useEffect(() => { refreshSources() }, []) + + // The selection is restored from localStorage and survives a deregister, so it + // has to be re-checked against the list itself rather than only at mount: + // deleting the selected source otherwise leaves a dead id behind and every + // call 404s "Source not found" until the page is reloaded. useEffect(() => { - refreshSources().then(data => { - if (data.length === 0) { setSourceId(''); return } - if (!sourceId || !data.some(s => String(s.id) === String(sourceId))) { - setSourceId(String(data[0].id)) - } - }) - }, []) + if (!sourcesLoaded) return + if (sources.length === 0) { setSourceId(''); return } + if (!sourceId || !sources.some(s => String(s.id) === String(sourceId))) { + setSourceId(String(sources[0].id)) + } + }, [sources, sourcesLoaded, sourceId]) useEffect(() => { if (!sourceId) { setVersions([]); setVersionId(''); return } - refreshVersions(sourceId).then(data => { - if (data.length === 0) { setVersionId(''); return } - if (!versionId || !data.some(v => String(v.id) === String(versionId))) { - setVersionId(String(data[0].id)) - } - }) + refreshVersions(sourceId) }, [sourceId]) + // Same reasoning as sources: a deleted version must not stay selected. + useEffect(() => { + if (!sourceId) return + if (versions.length === 0) { setVersionId(''); return } + if (!versionId || !versions.some(v => String(v.id) === String(versionId))) { + setVersionId(String(versions[0].id)) + } + }, [versions, sourceId, versionId]) + const ctx = { sources, sourceId, setSourceId, versions, versionId, setVersionId,