From cc5de46b9e2f53106b8862be12792d8bb504f899 Mon Sep 17 00:00:00 2001 From: Paul Trowbridge Date: Sat, 19 Sep 2026 15:17:13 -0400 Subject: [PATCH] Say what each toolbar control actually does The toolbar was one row of buttons separated by identical hairlines, so a column width and the version's change history read as peers. Split it: a view zone on its own ground -- layout, the two depth controls, fit widths, none of which reach the server -- and the forecast zone that re-loads, opens a window, or writes. The row depth buttons were a fixed 0 1 2 3. A number means nothing without already knowing what group_by holds, and a fixed range offers levels a two-field pivot does not have while hiding the fifth of one that does. They are now built from the live group_by and named after the level they reveal, which is what the column axis already did -- so both are drawn by one DepthButtons component, since both are the same thing: a depth in ViewConfig. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 27 ++++++- ui/src/components/DepthButtons.jsx | 26 ++++++ ui/src/views/Forecast.jsx | 125 +++++++++++++++-------------- 3 files changed, 114 insertions(+), 64 deletions(-) create mode 100644 ui/src/components/DepthButtons.jsx diff --git a/CLAUDE.md b/CLAUDE.md index e0f7d33..5fd6504 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,6 +46,7 @@ ui/src/ Baseline.jsx Version management, baseline workbench, reference load Forecast.jsx Perspective pivot, selection handling, operation dispatch components/ + DepthButtons.jsx One axis's collapse control; both axes are drawn from it LayoutMenu.jsx The Layout ▾ control — Published / Mine, with the write actions OperationPanel.jsx The adjustment workbench — ledger + scale/recode/clone forms BridgeView.jsx Baseline → current waterfall by tag (exports buildSteps/layoutSteps) @@ -231,11 +232,15 @@ gets written. Both axes collapse the same way: a **depth in `ViewConfig`**, set through `restore()`. -- **Rows** — `group_by_depth`, driven by the `EXPAND 0 1 2 3` buttons via - `applyDepth()` -- **Columns** — `split_by_depth`, driven by the `COLUMNS` buttons via +- **Rows** — `group_by_depth`, driven by the `Rows` buttons via `applyDepth()` +- **Columns** — `split_by_depth`, driven by the `Columns` buttons via `applySplitDepth()` +Both sets are drawn by `components/DepthButtons.jsx` from the axis's own field +list (`groupFull` / `splitFull`), so each button is named after the level it +reveals. The row axis used to be a fixed `0 1 2 3`, which named nothing and +offered levels a two-field `group_by` does not have. + Both are **1-based**: they count the levels to show, where the imperative `view.set_depth()` counts the boundary below them. `server.cpp` does `ctx1->set_depth(row_pivot_depth - 1)` and @@ -367,6 +372,22 @@ layout, since `restore()` is all-or-nothing. --- +## The Forecast toolbar is two zones + +Left, on its own tinted ground: **view** — `Layout ▾`, the `Rows` and `Columns` +depth buttons, `Fit widths`. Nothing there reaches the server or changes the +forecast; it is all `ViewConfig` and `plugin_config` against rows already loaded. + +Right: **forecast** — `Refresh data`, `Change log`, `Bridge`, `Operations`. These +re-run the load, open something over the pivot, or write to the version. + +They were one undifferentiated row separated by identical hairlines, which made +`Fit` and `Bridge` read as peers when one is a column width and the other is the +version's history. The split is the point; the tint is just what makes it +visible. + +--- + ## Slice mechanics When the user clicks a pivot cell, `perspective-click` fires. The handler in `Forecast.jsx` extracts `[col, '==', value]` filters from `detail.config.filter` — only `role = dimension` and `role = date` columns are kept as the slice. A plain click replaces the selection; ctrl/⌘/shift-click toggles a slice in or out of it, so the panel holds a **list** of slices sent as `slices` in operation POST bodies (the single `slice` object is still accepted server-side). diff --git a/ui/src/components/DepthButtons.jsx b/ui/src/components/DepthButtons.jsx new file mode 100644 index 0000000..c6fbd2b --- /dev/null +++ b/ui/src/components/DepthButtons.jsx @@ -0,0 +1,26 @@ +// One axis's collapse control. Rows and columns are the same thing — a depth in +// ViewConfig, counting the levels to show — so they are drawn by one component +// rather than two that happen to look alike. +// +// The buttons are named after the level they reveal. The row axis used to be a +// fixed 0 1 2 3: a number means nothing without already knowing what group_by +// holds, and a fixed range offers levels that do not exist while hiding ones +// that do. +export default function DepthButtons({ label, levels, depth, onPick, totalLabel = 'Total' }) { + return ( +
+ {label} + {Array.from({ length: levels.length + 1 }, (_, n) => ( + + ))} +
+ ) +} diff --git a/ui/src/views/Forecast.jsx b/ui/src/views/Forecast.jsx index ca9d015..318f828 100644 --- a/ui/src/views/Forecast.jsx +++ b/ui/src/views/Forecast.jsx @@ -1,6 +1,7 @@ import { useState, useEffect, useRef } from 'react' import useTheme from '../theme.jsx' import LayoutMenu from '../components/LayoutMenu.jsx' +import DepthButtons from '../components/DepthButtons.jsx' import useAuth from '../auth.jsx' import OperationPanel from '../components/OperationPanel.jsx' import BridgeView from '../components/BridgeView.jsx' @@ -115,6 +116,9 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio // re-renders when either changes. const [splitFull, setSplitFull] = useState([]) const [splitDepth, setSplitDepth] = useState(null) + // The row hierarchy, for the same reason: the depth buttons name the level + // they show rather than counting to a number nobody can map back to a field. + const [groupFull, setGroupFull] = useState([]) const [slices, setSlices] = useState([]) const [applyMode, setApplyMode] = useState('prorate') // 'prorate' | 'each' @@ -920,6 +924,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio // split_full is the legacy key, from when collapsing truncated split_by adoptSplit(cfg.split_full?.length ? cfg.split_full : cfg.split_by, cfg.split_by_depth != null ? cfg.split_by_depth - 1 : null) + setGroupFull(cfg.group_by || []) // restore() has already applied group_by_depth; this only syncs the // toolbar. expand_depth is the legacy key, from when depth was imperative // and had to be stored beside the config rather than in it. @@ -947,6 +952,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio } await viewer.restore(cfg) adoptSplit(cfg.split_full?.length ? cfg.split_full : cfg.split_by, (cfg.split_by || []).length) + setGroupFull(cfg.group_by || []) // Name what we landed on, so the menu says which layout this is and the // dirty dot has something to compare against. Read back rather than // reusing cfg: restore() normalises, so the live config is what a later @@ -971,6 +977,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio const live = await viewer.save() adoptSplit(live.split_by || [], live.split_by_depth != null ? live.split_by_depth - 1 : null) + setGroupFull(live.group_by || []) // the plugin element is replaced when the plugin changes, so re-assert hideSplitTotal() const cfg = await captureConfig() @@ -1515,6 +1522,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio await viewer.restore(cfg) adoptSplit(cfg.split_full?.length ? cfg.split_full : cfg.split_by, cfg.split_by_depth != null ? cfg.split_by_depth - 1 : null) + setGroupFull(cfg.group_by || []) if (cfg.group_by_depth != null) setExpandDepth(cfg.group_by_depth - 1) else if (cfg.expand_depth != null) await applyDepth(cfg.expand_depth) @@ -1899,73 +1907,68 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio {/* Toolbar */}
- patchLayout(l, { visibility: v }, - v === 'published' ? `Published “${l.name}”` : `“${l.name}” is private again`)} - onSetDefault={l => patchLayout(l, { is_default: true }, - `“${l.name}” opens this forecast`)} - onRename={(l, name) => patchLayout(l, { name }, 'Renamed')} - onDelete={deleteLayout} - onReset={resetLayout} /> + {/* ── View: everything here changes how the loaded rows are displayed. + Nothing in this zone talks to the server or alters the forecast, + which is the whole reason it is fenced off from the zone on the + right — the two used to sit in one undifferentiated row of + buttons separated by identical rules, so "Fit" and "Bridge" + read as peers when one is a column width and the other opens + the version's history. ── */} +
-
+ patchLayout(l, { visibility: v }, + v === 'published' ? `Published “${l.name}”` : `“${l.name}” is private again`)} + onSetDefault={l => patchLayout(l, { is_default: true }, + `“${l.name}” opens this forecast`)} + onRename={(l, name) => patchLayout(l, { name }, 'Renamed')} + onDelete={deleteLayout} + onReset={resetLayout} /> - + {/* Rows and Columns are the same control on the two axes — a depth in + ViewConfig — so they are built from one renderer and read alike. + The buttons name the level they reveal: "2" meant nothing without + already knowing what group_by held, and the old fixed 0–3 range + offered levels that did not exist and hid ones that did. */} + {groupFull.length > 0 && ( + <> +
+ + + )} -
+ {splitFull.length > 0 && ( + <> +
+ + + )} - {/* Expand group */} -
- Expand - {[0, 1, 2, 3].map(d => ( - - ))} +
+ +
- {splitFull.length > 0 && ( - <> -
- - {/* Column hierarchy group — the split_by equivalent of Expand */} -
- Columns - {Array.from({ length: splitFull.length + 1 }, (_, n) => { - const label = n === 0 ? 'Total' : splitFull[n - 1] - return ( - - ) - })} -
- - )} - -
- - {/* Data group */} -
-