import { useState, useEffect, useCallback } from 'react' import Sidebar from './components/Sidebar.jsx' import StatusBar from './components/StatusBar.jsx' import Setup from './views/Setup.jsx' import Baseline from './views/Baseline.jsx' import Forecast from './views/Forecast.jsx' export default function App() { const [view, setView] = useState(() => localStorage.getItem('pf_view') || 'forecast') 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 [versionsLoaded, setVersionsLoaded] = useState(false) const [versionId, setVersionId] = useState(() => localStorage.getItem('pf_versionId') || '') useEffect(() => { localStorage.setItem('pf_view', view) }, [view]) useEffect(() => { localStorage.setItem('pf_sidebar', sidebarExpanded ? 'expanded' : 'collapsed') }, [sidebarExpanded]) useEffect(() => { localStorage.setItem('pf_sourceId', sourceId || '') }, [sourceId]) useEffect(() => { localStorage.setItem('pf_versionId', versionId || '') }, [versionId]) const refreshSources = useCallback(async () => { const data = await fetch('/api/sources').then(r => r.json()) const list = Array.isArray(data) ? data : [] setSources(list) setSourcesLoaded(true) return list }, []) const refreshVersions = useCallback(async (sid) => { const id = sid ?? sourceId if (!id) { setVersions([]); setVersionsLoaded(true); return [] } const data = await fetch(`/api/sources/${id}/versions`).then(r => r.json()) const list = Array.isArray(data) ? data : [] setVersions(list) setVersionsLoaded(true) 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(() => { 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(''); setVersionsLoaded(true); return } // The list belongs to the previous source until the fetch lands. setVersionsLoaded(false) refreshVersions(sourceId) }, [sourceId]) // Same reasoning as sources: a deleted version must not stay selected. // // versionsLoaded matters more than it looks: without it, the empty initial // state reads as "this source has no versions", so a versionId restored from // localStorage is cleared and then set straight back when the fetch lands. // Forecast's load effect is keyed on that id, so the round trip made every // page load fetch and aggregate the whole version twice. useEffect(() => { if (!sourceId || !versionsLoaded) return if (versions.length === 0) { setVersionId(''); return } if (!versionId || !versions.some(v => String(v.id) === String(versionId))) { setVersionId(String(versions[0].id)) } }, [versions, versionsLoaded, sourceId, versionId]) const ctx = { sources, sourceId, setSourceId, versions, versionId, setVersionId, refreshSources, refreshVersions, setVersions, } return (
{view === 'setup' && } {view === 'baseline' && } {view === 'forecast' && }
) }