diff --git a/ui/src/components/StatusBar.jsx b/ui/src/components/StatusBar.jsx index b7440dd..284f2b7 100644 --- a/ui/src/components/StatusBar.jsx +++ b/ui/src/components/StatusBar.jsx @@ -11,6 +11,10 @@ export default function StatusBar({ view, sources = [], sourceId, setSourceId, v const [info, setInfo] = useState(null) const [showInfo, setShow] = useState(false) const [copied, setCopied] = useState(false) + // Forecast.jsx mirrors the pivot's own updating state onto this event. The + // viewer shows the same thing as a beaker in its settings panel, which is + // exactly where you cannot see it once the panel is closed. + const [busy, setBusy] = useState(false) const refreshInfo = useCallback(async () => { if (!versionId || !showVersion) { setInfo(null); return } @@ -29,6 +33,12 @@ export default function StatusBar({ view, sources = [], sourceId, setSourceId, v return () => window.removeEventListener('pf-data-changed', onChange) }, [refreshInfo]) + useEffect(() => { + const onBusy = (e) => setBusy(!!e.detail?.busy) + window.addEventListener('pf-pivot-busy', onBusy) + return () => window.removeEventListener('pf-pivot-busy', onBusy) + }, []) + async function copyTable() { if (!info?.fc_table) return try { @@ -127,6 +137,23 @@ export default function StatusBar({ view, sources = [], sourceId, setSourceId, v )} + {/* Left of the ml-auto block, so it sits beside the pivot's own figures + rather than out among the account controls. Occupies no space at all + when idle: a permanently reserved slot reads as a thing that is broken + when empty. */} + {busy && view === 'forecast' && ( + + + + + Calculating… + + )} +
{user && ( <> diff --git a/ui/src/views/Forecast.jsx b/ui/src/views/Forecast.jsx index 91e2318..0d81545 100644 --- a/ui/src/views/Forecast.jsx +++ b/ui/src/views/Forecast.jsx @@ -29,6 +29,13 @@ const LAYOUTS_KEY = (vid) => `pf_layouts_v${vid}` // legacy named list, migrate function cleanLayout(cfg, validCols) { if (!cfg) return cfg const c = { ...cfg } + // The theme is the reader's own light/dark choice, not a property of the + // layout. viewer.save() includes it, so it ends up baked into every stored + // config -- and restoring one then snapped the pivot back to whatever the + // author had, silently undoing the toggle. Dropped on the way in, which makes + // the app's `dark` state the only thing that sets it, and means a published + // layout cannot impose its author's theme on everyone who applies it. + delete c.theme // Dead expressions go before the axis filter, not after: dropping them from // `expressions` is what makes `ok()` reject them everywhere else. if (DEAD_ORDER_EXPRS.some(n => c.expressions?.[n] !== undefined)) { @@ -263,6 +270,9 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio const [undoingId, setUndoingId] = useState(null) const viewerRef = useRef(null) + // Depth, not a boolean: config updates overlap, and the first one to finish + // would otherwise clear the flag while the others are still rebuilding. + const busyRef = useRef(0) const workerRef = useRef(null) const tableRef = useRef(null) const colMetaRef = useRef([]) @@ -784,7 +794,11 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio // differs on every refresh, so it is never a real difference. function sameConfig(a, b) { if (!a || !b) return false - const strip = ({ table, ...rest }) => JSON.stringify(rest) + // table is the per-load table name, different on every refresh. theme is + // the reader's light/dark choice and is not part of the layout (see + // cleanLayout) -- without it here, toggling the theme would light the dirty + // dot and offer to save a layout nothing had edited. + const strip = ({ table, theme, ...rest }) => JSON.stringify(rest) return strip(a) === strip(b) } @@ -1099,16 +1113,18 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio const levels = (cfg.split_by || []).length if (!levels) return const depth = Math.max(0, Math.min(n, levels)) + pivotBusy(true) try { // A count of levels to show, exactly as group_by_depth is -- the engine // treats both axes identically. See applyDepth for why 0 is floored to 1. await viewer.restore({ ...cfg, split_by_depth: Math.max(1, depth) }) + await viewer.flush() setSplitDepth(depth) } catch (err) { console.error('[applySplitDepth]', err) flash(err.message || String(err), 'error') return - } + } finally { pivotBusy(false) } try { const cfg2 = await captureConfig() if (cfg2) await persistLayout(versionId, cfg2) @@ -1441,6 +1457,35 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio } ` + // Tell the app's status bar whether the pivot is rebuilding, so the wait is + // visible with the settings panel closed. + // + // Perspective shows this as the beaker in its own status bar, which is the + // honest signal -- it is driven by the session's update_count. It cannot be + // read from here, though: that bar lives in the viewer's shadow root, and the + // component carrying it is only *rendered* while settings are open once the + // table has loaded, so with the panel closed there is no element to watch. An + // earlier attempt did exactly that and only ever lit up when the panel was + // open, which is precisely when you do not need it. + // + // So it is raised by the callers that *start* a rebuild, and lowered once + // viewer.flush() says everything pending has rendered. + // + // Not from `perspective-config-update`, which was the obvious hook and is the + // wrong one: it is dispatched *after* the rebuild it describes. Measured on a + // depth change over a large grain, the event arrived seconds late and the + // flush that followed resolved in 6ms -- so the flag went up and down well + // after the wait it was meant to cover. + // + // Consequence: this covers what the toolbar drives -- depth on both axes, and + // applying a layout. Sorting or filtering from the viewer's own chrome still + // shows nothing, because the app never learns those have begun. + function pivotBusy(on) { + busyRef.current = Math.max(0, busyRef.current + (on ? 1 : -1)) + window.dispatchEvent( + new CustomEvent('pf-pivot-busy', { detail: { busy: busyRef.current > 0 } })) + } + // Find the grid wherever it is. Guessing the nesting was wrong once already -- // the plugin element's own shadow root is not where the cells live -- so locate // regular-table by walking through shadow roots, then inject into whatever root @@ -1506,6 +1551,8 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio // effect on its own. table is dropped: it is loaded by reference, and a stale // name in a restored config fails the lookup. const target = Math.max(1, d) + pivotBusy(true) + try { const { table: _t, ...cfg } = await viewer.save() await viewer.restore({ ...cfg, group_by_depth: target }) @@ -1530,7 +1577,9 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio const plugin = await viewer.getPlugin() await plugin.draw(view) } + await viewer.flush() setExpandDepth(d) + } finally { pivotBusy(false) } } async function captureConfig() { @@ -1615,7 +1664,10 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio const validCols = new Set(tableRef.current ? Object.keys(await tableRef.current.schema()) : []) const cfg = cleanLayout(layout.config, validCols) cfg.plugin_config = { ...(cfg.plugin_config || {}), edit_mode: 'SELECT_REGION' } + pivotBusy(true) + try { await viewer.restore(cfg) + await viewer.flush() adoptSplit(cfg.split_full?.length ? cfg.split_full : cfg.split_by, cfg.split_by_depth != null ? cfg.split_by_depth : null) setGroupFull(cfg.group_by || []) @@ -1631,6 +1683,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio const merged = await captureConfig() markClean(merged || cfg, layout.id) await persistLayout(versionId, merged || cfg) + } finally { pivotBusy(false) } } async function deleteLayout(layout) {