diff --git a/CLAUDE.md b/CLAUDE.md index 8ec6da7..6c0fbe8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -600,6 +600,13 @@ anyone else, gone on another machine. The one server-side layout was published layout with no owner. That route is gone; the column remains, migrated and read by nothing. +**Grand totals ride in the layout under `pf_totals`** — `{ row, col }`, the +toolbar's *Total row* / *Total column* toggles. The engine has no rollup mode +for "subtotals but no grand total", so both are hidden in the grid's DOM rather +than in `ViewConfig`; `captureConfig()` adds the key and `cleanLayout()` strips +it before `restore()`. A layout without it gets the old behaviour: row shown, +column hidden. + `cleanLayout()` guards `aggregates` along with the axes — including the weight column of the multi-arg form — and drops the offending *entry* rather than the layout, since `restore()` is all-or-nothing. diff --git a/ui/src/views/Forecast.jsx b/ui/src/views/Forecast.jsx index 3e73393..a36904b 100644 --- a/ui/src/views/Forecast.jsx +++ b/ui/src/views/Forecast.jsx @@ -36,6 +36,9 @@ function cleanLayout(cfg, validCols) { // 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 + // Ours, not the engine's: read off the layout with totalsOf() before this + // runs, and kept out of restore(), which knows nothing of it. + delete c[PF_TOTALS] // 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)) { @@ -76,6 +79,20 @@ function cleanLayout(cfg, validCols) { // names a column that no longer exists. const DEAD_ORDER_EXPRS = ['pf_bucket_ord', 'pf_segment_ord', 'Bucket', 'Segment'] +// Whether the grand-total row and column are shown. Neither has a ViewConfig +// field -- the engine's rollup modes are rollup, flat and total, none of them +// "subtotals but no grand total" -- so both are hidden in the grid's DOM, and +// the choice travels beside the engine's config under this key: added by +// captureConfig, stripped by cleanLayout. A layout saved before the key existed +// gets the defaults, which are what the grid showed then: the row, and not the +// column. +const PF_TOTALS = 'pf_totals' +const DEFAULT_TOTALS = { row: true, col: false } +const totalsOf = (cfg) => ({ + row: cfg?.[PF_TOTALS]?.row ?? DEFAULT_TOTALS.row, + col: cfg?.[PF_TOTALS]?.col ?? DEFAULT_TOTALS.col, +}) + export default function Forecast({ sources = [], sourceId, versions = [], versionId, refreshSources }) { const { dark } = useTheme() const { user } = useAuth() @@ -180,15 +197,11 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio useEffect(() => { localStorage.setItem('pf_scale_plug_v2', scalePlug) }, [scalePlug]) useEffect(() => { localStorage.setItem('pf_panel_open', panelOpen ? 'open' : 'closed') }, [panelOpen]) - // Whether the grand-total row is drawn. Per-browser, like the panel state: it - // is how you like to read the grid, not a property of the layout. - const [showTotalRow, setShowTotalRow] = useState(() => localStorage.getItem('pf_total_row') !== 'hidden') - const showTotalRowRef = useRef(showTotalRow) - useEffect(() => { - localStorage.setItem('pf_total_row', showTotalRow ? 'shown' : 'hidden') - showTotalRowRef.current = showTotalRow - gridRef.current?.regular_table?.draw() - }, [showTotalRow]) + // Whether the grand-total row and grand-total column are drawn. Part of the + // layout (see PF_TOTALS), so the ref is what captureConfig reads -- it has to + // be current the moment a toggle persists, before React has re-rendered. + const [totals, setTotals] = useState(DEFAULT_TOTALS) + const totalsRef = useRef(DEFAULT_TOTALS) // Esc closes the panel — the usual way out of the floating window useEffect(() => { @@ -1002,9 +1015,12 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio // localStorage and comes back when there are rows again. const saved = rowCount > 0 ? localStorage.getItem(LAYOUT_KEY(vid)) : null if (rowCount === 0) { + adoptTotals(DEFAULT_TOTALS) await viewer.restore({ settings: false, plugin_config: { edit_mode: 'SELECT_REGION' } }) } else if (saved) { - const { table: _t, ...rest } = cleanLayout(JSON.parse(saved), validCols) + const parsed = JSON.parse(saved) + adoptTotals(totalsOf(parsed)) + const { table: _t, ...rest } = cleanLayout(parsed, validCols) const cfg = { ...rest, plugin_config: { ...(rest.plugin_config || {}), edit_mode: 'SELECT_REGION' } } await viewer.restore(cfg) // split_full is the legacy key, from when collapsing truncated split_by @@ -1027,6 +1043,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio const defaultRow = defaults.find(l => l.version_id != null) || defaults[0] const sourceDefault = defaultRow?.config let cfg + adoptTotals(totalsOf(sourceDefault)) if (sourceDefault && Object.keys(sourceDefault).length > 0) { const { table: _t, ...rest } = cleanLayout(sourceDefault, validCols) cfg = { ...rest, plugin_config: { ...(rest.plugin_config || {}), edit_mode: 'SELECT_REGION' } } @@ -1046,7 +1063,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio // dirty dot has something to compare against. Read back rather than // reusing cfg: restore() normalises, so the live config is what a later // save() will be compared with. - if (defaultRow) markClean(await viewer.save(), defaultRow.id) + if (defaultRow) markClean(await captureConfig(), defaultRow.id) } // Its own try: the restore above has one, but this sits outside it, and a @@ -1250,8 +1267,11 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio const head = meta?.row_header const isTotal = meta?.y === 0 && Array.isArray(head) && String(head[0]) === 'TOTAL' && !head.slice(1).some(notBlank) - tr.classList.toggle('pf-total-row', isTotal && !showTotalRowRef.current) + tr.classList.toggle('pf-total-row', isTotal && !totalsRef.current.row) } + // The column's switch, for GRID_CSS. Re-asserted per draw because the + // datagrid can hand us a fresh table element. + table.toggleAttribute('data-pf-col-total', totalsRef.current.col) const body = table.querySelectorAll('tbody td') // The deepest column path is a leaf; anything shorter is an aggregate of @@ -1271,6 +1291,15 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio if (Array.isArray(path)) depth = Math.max(depth, path.filter(notBlank).length) } + // A rule only means something when a group spans several columns. A path + // ends with the measure, so depth counts split levels plus one: with a + // single split level (or the axis collapsed to one) and a single measure, + // every group is one column and the rule would sit on every column, + // separating nothing. Measures are counted, not read off the config, + // because two of them under one split value do make a group worth ruling. + const measures = new Set(metas.map(([, m]) => m?.column_header?.at?.(-1)).filter(notBlank)) + const ruled = depth > 2 || (depth === 2 && measures.size > 1) + let prevGroup = null for (const [td, meta] of metas) { td.classList.remove('pf-group-start', 'pf-subtotal') @@ -1278,7 +1307,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio if (!Array.isArray(path) || !path.length) { prevGroup = null; continue } const named = path.filter(notBlank) const group = named[0] - if (group !== prevGroup) { td.classList.add('pf-group-start'); prevGroup = group } + if (group !== prevGroup) { if (ruled) td.classList.add('pf-group-start'); prevGroup = group } if (named.length < depth) td.classList.add('pf-subtotal') } }) @@ -1471,8 +1500,10 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio white-space: nowrap !important; } - th.psp-split-total:not(.psp-split-subtotal), - td.psp-split-total:not(.psp-split-subtotal) { + /* The grand-total column, hidden unless the layout asks for it + * (data-pf-col-total, set by adoptTotals and the style listener). */ + regular-table:not([data-pf-col-total]) th.psp-split-total:not(.psp-split-subtotal), + regular-table:not([data-pf-col-total]) td.psp-split-total:not(.psp-split-subtotal) { visibility: hidden !important; width: 0 !important; min-width: 0 !important; @@ -1613,10 +1644,40 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio const viewer = viewerRef.current if (!viewer) return null const cfg = await viewer.save() - // Both depths are already in cfg, straight from save(). Nothing of ours needs - // to travel beside it any more -- split_full existed only because collapsing - // truncated split_by and the discarded levels had to be remembered. - return cfg + // Both depths are already in cfg, straight from save(). The one thing of + // ours that travels beside it is the totals choice, which the engine has no + // field for (see PF_TOTALS). split_full used to as well, only because + // collapsing truncated split_by and the discarded levels had to be + // remembered. + return { ...cfg, [PF_TOTALS]: { ...totalsRef.current } } + } + + // Show or hide the grand totals. Both are DOM-side, so this is a redraw, not + // a rebuild: the row is tagged in the style listener, the column hidden by + // GRID_CSS unless the table carries data-pf-col-total. The attribute is set + // here as well as per draw so the draw that follows already sizes the column. + function adoptTotals(t) { + totalsRef.current = t + setTotals(t) + const table = gridRef.current?.regular_table + if (!table) return + table.toggleAttribute('data-pf-col-total', t.col) + table.draw() + } + + // A toolbar toggle. It changes the layout without touching the engine, so no + // perspective-config-update follows -- the persist and the dirty dot that + // handler would have done are done here. + async function toggleTotal(axis, on) { + adoptTotals({ ...totalsRef.current, [axis]: on }) + try { + const cfg = await captureConfig() + if (!cfg) return + await persistLayout(versionId, cfg) + setLayoutDirty(!!activeConfigRef.current && !sameConfig(cfg, activeConfigRef.current)) + } catch (err) { + console.error('[toggleTotal]', err) + } } async function persistLayout(vid, cfg) { @@ -1693,6 +1754,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio cfg.plugin_config = { ...(cfg.plugin_config || {}), edit_mode: 'SELECT_REGION' } pivotBusy(true) try { + adoptTotals(totalsOf(layout.config)) await viewer.restore(cfg) await viewer.flush() adoptSplit(cfg.split_full?.length ? cfg.split_full : cfg.split_by, @@ -1726,6 +1788,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio function resetLayout() { localStorage.removeItem(LAYOUT_KEY(versionId)) markClean(null, null) + adoptTotals(DEFAULT_TOTALS) const viewer = viewerRef.current if (viewer) viewer.restore({ settings: true }) } @@ -2151,8 +2214,8 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio onPick={applyDepth} totalLabel="Total" /> @@ -2163,6 +2226,12 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
+ )}