Show when the pivot is rebuilding, and stop layouts overriding the theme

Two unrelated changes, in one commit because both touch Forecast.jsx in
ways that cannot be staged apart without an interactive add.

Calculating indicator. Perspective shows a beaker while the view
rebuilds, but it lives in the viewer's status bar, which is only
rendered while the settings panel is open -- so the signal disappears
exactly when the panel is closed and you have nothing else to go on.
Forecast.jsx now raises a counter around the calls that *start* a
rebuild and lowers it after viewer.flush(), broadcasting `pf-pivot-busy`
on the window; StatusBar listens and shows a spinner, following the
`pf-data-changed` pattern already used between the two.

Deliberately not driven from `perspective-config-update`, which was the
obvious hook and is wrong: measured on a depth change over a large
grain, the event arrives *after* the rebuild -- seconds late -- and the
flush that follows resolves in 6ms. It reports the end of the work, not
the start. This therefore covers what the toolbar drives (depth on both
axes, applying a layout) and not sorting or filtering from the viewer's
own chrome, which the app is never told has begun.

Theme. viewer.save() includes `theme`, so every persisted layout had one
baked in, and cleanLayout passed it through -- each restore reasserted
the stored theme and silently undid the light/dark toggle. It is the
reader's choice, not a property of the layout, so it is dropped on the
way in; a published layout can no longer impose its author's theme
either. sameConfig ignores it too, or toggling would light the dirty dot
and offer to save a change nobody made.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-23 01:15:35 -04:00
parent 8ab6dd91e0
commit a651c58dc3
2 changed files with 82 additions and 2 deletions

View File

@ -11,6 +11,10 @@ export default function StatusBar({ view, sources = [], sourceId, setSourceId, v
const [info, setInfo] = useState(null) const [info, setInfo] = useState(null)
const [showInfo, setShow] = useState(false) const [showInfo, setShow] = useState(false)
const [copied, setCopied] = 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 () => { const refreshInfo = useCallback(async () => {
if (!versionId || !showVersion) { setInfo(null); return } 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) return () => window.removeEventListener('pf-data-changed', onChange)
}, [refreshInfo]) }, [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() { async function copyTable() {
if (!info?.fc_table) return if (!info?.fc_table) return
try { 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' && (
<span
className="flex items-center gap-1.5 text-blue-600"
title="The pivot is rebuilding its view — sorting, filtering, or changing depth on a large grain takes a moment"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="3" strokeLinecap="round" className="animate-spin">
<path d="M12 3a9 9 0 1 0 9 9" />
</svg>
Calculating…
</span>
)}
<div className="ml-auto flex items-center gap-2"> <div className="ml-auto flex items-center gap-2">
{user && ( {user && (
<> <>

View File

@ -29,6 +29,13 @@ const LAYOUTS_KEY = (vid) => `pf_layouts_v${vid}` // legacy named list, migrate
function cleanLayout(cfg, validCols) { function cleanLayout(cfg, validCols) {
if (!cfg) return cfg if (!cfg) return cfg
const c = { ...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 // Dead expressions go before the axis filter, not after: dropping them from
// `expressions` is what makes `ok()` reject them everywhere else. // `expressions` is what makes `ok()` reject them everywhere else.
if (DEAD_ORDER_EXPRS.some(n => c.expressions?.[n] !== undefined)) { 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 [undoingId, setUndoingId] = useState(null)
const viewerRef = useRef(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 workerRef = useRef(null)
const tableRef = useRef(null) const tableRef = useRef(null)
const colMetaRef = useRef([]) 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. // differs on every refresh, so it is never a real difference.
function sameConfig(a, b) { function sameConfig(a, b) {
if (!a || !b) return false 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) return strip(a) === strip(b)
} }
@ -1099,16 +1113,18 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
const levels = (cfg.split_by || []).length const levels = (cfg.split_by || []).length
if (!levels) return if (!levels) return
const depth = Math.max(0, Math.min(n, levels)) const depth = Math.max(0, Math.min(n, levels))
pivotBusy(true)
try { try {
// A count of levels to show, exactly as group_by_depth is -- the engine // 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. // 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.restore({ ...cfg, split_by_depth: Math.max(1, depth) })
await viewer.flush()
setSplitDepth(depth) setSplitDepth(depth)
} catch (err) { } catch (err) {
console.error('[applySplitDepth]', err) console.error('[applySplitDepth]', err)
flash(err.message || String(err), 'error') flash(err.message || String(err), 'error')
return return
} } finally { pivotBusy(false) }
try { try {
const cfg2 = await captureConfig() const cfg2 = await captureConfig()
if (cfg2) await persistLayout(versionId, cfg2) 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 -- // 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 // 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 // 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 // effect on its own. table is dropped: it is loaded by reference, and a stale
// name in a restored config fails the lookup. // name in a restored config fails the lookup.
const target = Math.max(1, d) const target = Math.max(1, d)
pivotBusy(true)
try {
const { table: _t, ...cfg } = await viewer.save() const { table: _t, ...cfg } = await viewer.save()
await viewer.restore({ ...cfg, group_by_depth: target }) 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() const plugin = await viewer.getPlugin()
await plugin.draw(view) await plugin.draw(view)
} }
await viewer.flush()
setExpandDepth(d) setExpandDepth(d)
} finally { pivotBusy(false) }
} }
async function captureConfig() { 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 validCols = new Set(tableRef.current ? Object.keys(await tableRef.current.schema()) : [])
const cfg = cleanLayout(layout.config, validCols) const cfg = cleanLayout(layout.config, validCols)
cfg.plugin_config = { ...(cfg.plugin_config || {}), edit_mode: 'SELECT_REGION' } cfg.plugin_config = { ...(cfg.plugin_config || {}), edit_mode: 'SELECT_REGION' }
pivotBusy(true)
try {
await viewer.restore(cfg) await viewer.restore(cfg)
await viewer.flush()
adoptSplit(cfg.split_full?.length ? cfg.split_full : cfg.split_by, adoptSplit(cfg.split_full?.length ? cfg.split_full : cfg.split_by,
cfg.split_by_depth != null ? cfg.split_by_depth : null) cfg.split_by_depth != null ? cfg.split_by_depth : null)
setGroupFull(cfg.group_by || []) setGroupFull(cfg.group_by || [])
@ -1631,6 +1683,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
const merged = await captureConfig() const merged = await captureConfig()
markClean(merged || cfg, layout.id) markClean(merged || cfg, layout.id)
await persistLayout(versionId, merged || cfg) await persistLayout(versionId, merged || cfg)
} finally { pivotBusy(false) }
} }
async function deleteLayout(layout) { async function deleteLayout(layout) {