diff --git a/CLAUDE.md b/CLAUDE.md index ae7f2bf..96e4d74 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,8 +51,9 @@ ui/src/ 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) - Sidebar.jsx 3-step collapsible nav - StatusBar.jsx Source · version · write target · row counts · theme + Sidebar.jsx Forecast list + the 3 steps; collapsible + StatusBar.jsx Forecast name · row count · calculating · theme; source on Setup only + ReadOnlyNotice.jsx The banner over a page a non-admin may read but not change Timeline.jsx Date-range preview bar for baseline segments ``` @@ -429,6 +430,38 @@ and `last_used` still come back from the route; nothing reads them. --- +## Choosing a forecast + +The forecasts are listed in the **sidebar**, above the three steps, each row +carrying its status and a star for the default. They were two dropdowns in the +status bar, which is otherwise read-only — so the one pair of controls in it did +not read as controls at all. + +**Not nested under the `Forecast` step**, which is where the idea started: +Baseline works on the same version, so hanging the list off one step would +either misrepresent the scope or duplicate it under two. It reads *what am I +working on*, then *what step am I on*. + +**The source is not in the nav.** `gs.osm_skinny` means nothing to someone +editing a forecast. The sidebar lists every source's versions and sets +`sourceId` from the one picked, since `pf.version.source_id` comes with it — so +choosing a forecast settles the source, and the picker is only offered on Setup, +the one page where the raw table name is the right label. + +`versions` stays scoped to the selected source, because Baseline and Forecast +are; the sidebar gets its own `allVersions` instead, so widening it does not +change what `versions` means everywhere else. It is a client-side fan-out over +`/sources/:id/versions` rather than a `GET /versions` — one request, at one +registered source. + +The status bar keeps what follows *from* the selection: the forecast's name and +its row count. The table written to is in the hover panel, still click-to-copy, +because pasting it into psql is the reason it was ever on screen. + +**Known collision:** "forecast" is both the scenario and the third step. The +scenario should own the word — it is what people name and say aloud — which +leaves the step wanting a different one (`Adjust`). Undecided. + ## Where a browser lands `App.jsx` restores `pf_sourceId` / `pf_versionId` from `localStorage` and @@ -447,7 +480,7 @@ forecasting work around. else lands, so it follows the `pf.log` rule rather than being the version's own setting like its name. The checkbox on the Baseline page's **This forecast** panel is greyed out for other accounts rather than offering a click that -answers 403. The `StatusBar` picker marks the default `(default)`. +answers 403. The sidebar's forecast list marks the default with a star. **A stored selection still wins.** The default is the fallback, not an override — someone who has been working in another version stays there. The @@ -569,17 +602,48 @@ 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. +Left, on its own tinted ground: **view** — `Layout ▾` and the `Rows` and +`Columns` depth buttons. Nothing there reaches the server or changes the +forecast; it is all `ViewConfig` 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. +Right, in a bordered panel of its own: **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. +version's history. Three weights do the separating now, and they were all once +the same: `.pf-section-label` names a group (`Layout`, `Rows`, `Columns`) and is +heavier and darker than the controls under it, `.pf-group-rule` separates groups +*within* a zone, and the zone borders separate the zones. A label that matched +its buttons, and a group rule that matched a zone border, is what made the row +read as one undifferentiated thing. + +**`Fit widths` is gone**, button and `fitColumns` both, for three independent +reasons. Auto-size measures the leaf header over the rows *currently in the +viewport*, so anything below the fold never counted; `draw({invalid_columns})` +passes an option `DrawOptions` does not have, and `draw()` is throttled, so +clicking during a pending draw cleared the widths without re-measuring and the +columns fell to their 52/60px floors; and the `persistLayout` after it saved +nothing, since `saveColumnSizes()` returns only overrides, which `resetAutoSize()` +had just emptied — and this fork discards `column_size_override` whenever +`split_by` is non-empty, which here is always. Recoverable from git if it is ever +worth fixing rather than dropping. + +**A `Calculating…` indicator** sits in the app's status bar while the pivot +rebuilds. Perspective has its own beaker, but it is rendered only while the +settings panel is open, so it vanishes exactly when nothing else tells you to +wait. `Forecast.jsx` raises a counter — a counter, because rebuilds overlap — +in `applyDepth`, `applySplitDepth` and `applyLayout`, lowers it after +`viewer.flush()`, and broadcasts `pf-pivot-busy` on the window for `StatusBar` +to render, the same way `pf-data-changed` already travels between them. + +Deliberately **not** driven from `perspective-config-update`, which is the +obvious hook and is the wrong one: 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. The cost is that +sorting or filtering from the viewer's own chrome shows nothing, because the app +is never told those began. --- @@ -714,6 +778,44 @@ effects call the API immediately. `pf_user: 'admin'`, which any client could have set to anything. The audit log now names the account that made the change. +## Setup and Baseline are admin-only to write + +`requireAdmin` sat in `lib/auth.js`, exported, and applied to **nothing** — so +every non-admin account (26 of the 29) could call every Setup and Baseline +endpoint directly. It is now on the writes those two pages lead to: + +- **sources.js** — `POST /sources`, `PUT /sources/:id/cols`, `POST + /sources/:id/generate-sql`, `POST /sources/:id/dim/:group/refresh`, + `DELETE /sources/:id` +- **versions.js** — `POST /sources/:id/versions`, `PUT /versions/:id`, + `POST /versions/:id/{close,reopen}`, `DELETE /versions/:id` +- **operations.js** — the loads: `POST /versions/:id/{baseline,reference}`, + `PUT /versions/:id/baseline/:logid`, `DELETE /versions/:id/baseline` +- **plans.js** — every write + +**Not gated, and each for a reason.** No read is, including the table browser — +the pages stay visible, and gating what a page needs in order to *render* turns +it into a 403 (see below). The forecast operations are not, because scale, +recode, clone and undo carry their own owner and territory rules. Nor is +`PATCH /versions/:id/tags`: its permission is ownership, and a rep may rename +the initiatives they named themselves. + +**The pages stay visible and go read-only.** One `
` per page +rather than a flag threaded through every control — Baseline alone has +forty-five, and the one that got missed would be the one that mattered — plus a +`ReadOnlyNotice` banner saying why. Setup wraps a real box because that fieldset +*is* its flex row; Baseline uses `display: contents` so the page's layout is +untouched, and `disabled` applies either way, being a DOM property. Baseline's +Tags panel sits outside the fieldset, for the ownership reason above. **None of +this is the permission** — it only stops an account being offered a button that +would answer 403. + +**A 403 used to blank the whole app.** Setup fed responses straight into state +and rendered them with `.map`; an error body is `{error}`, and throwing during +render unmounts React. Its list fetches now coerce to an array, so an unexpected +response costs an empty panel rather than the session. That fragility predated +the gating — the gating only made it reachable. + ## Source columns come from pg_catalog `information_schema.columns` omits **materialized views** — they are not in the