Document the nav, the admin gating, and what left the toolbar

Three sections were describing an app that no longer exists: the
project layout still called Sidebar a 3-step nav and StatusBar a source
and version picker, and the toolbar section still listed Fit widths.

Adds "Choosing a forecast" (the sidebar list, why it is not nested under
the Forecast step, why the source is absent and inferred, and the
forecast/step naming collision that is still undecided) and "Setup and
Baseline are admin-only to write" (which routes carry requireAdmin,
which deliberately do not and why, the fieldset approach, and the note
that a 403 used to blank the app because an error body reached .map
during render).

The toolbar section gains why Fit widths was removed rather than fixed,
and how the Calculating indicator is driven -- including that
perspective-config-update is the obvious hook and reports the end of
the work rather than the start.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-23 02:02:16 -04:00
parent 3426f3525b
commit 7d09ab2245

122
CLAUDE.md
View File

@ -51,8 +51,9 @@ ui/src/
LayoutMenu.jsx The Layout ▾ control — Published / Mine, with the write actions LayoutMenu.jsx The Layout ▾ control — Published / Mine, with the write actions
OperationPanel.jsx The adjustment workbench — ledger + scale/recode/clone forms OperationPanel.jsx The adjustment workbench — ledger + scale/recode/clone forms
BridgeView.jsx Baseline → current waterfall by tag (exports buildSteps/layoutSteps) BridgeView.jsx Baseline → current waterfall by tag (exports buildSteps/layoutSteps)
Sidebar.jsx 3-step collapsible nav Sidebar.jsx Forecast list + the 3 steps; collapsible
StatusBar.jsx Source · version · write target · row counts · theme 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 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 ## Where a browser lands
`App.jsx` restores `pf_sourceId` / `pf_versionId` from `localStorage` and `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 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** 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 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 **A stored selection still wins.** The default is the fallback, not an
override — someone who has been working in another version stays there. The 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 ## The Forecast toolbar is two zones
Left, on its own tinted ground: **view** — `Layout ▾`, the `Rows` and `Columns` Left, on its own tinted ground: **view** — `Layout ▾` and the `Rows` and
depth buttons, `Fit widths`. Nothing there reaches the server or changes the `Columns` depth buttons. Nothing there reaches the server or changes the
forecast; it is all `ViewConfig` and `plugin_config` against rows already loaded. forecast; it is all `ViewConfig` against rows already loaded.
Right: **forecast** — `Refresh data`, `Change log`, `Bridge`, `Operations`. These Right, in a bordered panel of its own: **forecast** — `Refresh data`,
re-run the load, open something over the pivot, or write to the version. `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 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 `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 version's history. Three weights do the separating now, and they were all once
visible. 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 `pf_user: 'admin'`, which any client could have set to anything. The audit log
now names the account that made the change. 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 `<fieldset disabled>` 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 ## Source columns come from pg_catalog
`information_schema.columns` omits **materialized views** — they are not in the `information_schema.columns` omits **materialized views** — they are not in the