Compare commits

..

No commits in common. "master" and "op-panel-layout" have entirely different histories.

22 changed files with 2578 additions and 3088 deletions

1110
CLAUDE.md

File diff suppressed because it is too large Load Diff

362
PERSPECTIVE.md Normal file
View File

@ -0,0 +1,362 @@
# Perspective — configuration & deployment reference
Canonical guide for how Perspective should be configured, fed, and shipped across our
apps (**pf_app**, **dataflow**). Both embed the same `<perspective-viewer>` web component
but made different early choices; this doc defines the target state and the rationale, so
the two converge instead of drifting.
> **Distribution note:** we use the **`@perspective-dev/*`** packages
> (repo: <https://github.com/perspective-dev/perspective>, home:
> <https://perspective-dev.github.io>) — **not** the FINOS/OpenJS `@finos/perspective`
> packages from <https://perspective.finos.org/>. Same engine lineage, but a separate
> npm scope, release cadence, and "Pro" theme set (`Pro Dark`/`Pro Light`). This is why
> `viewer-d3fc` versions on a different schedule than `viewer`/`client` (§2), and why the
> client's bundled `apache-arrow` (17.x) can lag the server's (§3). Don't mix the two
> scopes.
---
## Core principle: Perspective is one locked unit
These four things must move together and be pinned together. Bumping one without the
others is the source of nearly every Perspective bug we've hit:
1. **Loader** — how the JS/WASM gets into the page (npm-inline vs CDN)
2. **Package version** — `client` / `viewer` / `viewer-datagrid` / `viewer-d3fc`
3. **Data format** — Arrow IPC vs JSON rows
4. **`apache-arrow` version** (server-side, only if using Arrow) — must speak an IPC
format the client WASM understands
Treat a Perspective upgrade as a coordinated change to all four, gated by the smoke test
at the bottom of this doc. Never let viewer/client drift ahead of `viewer-d3fc`.
---
## 1. Loading — npm `/inline`, pinned exact (not CDN)
```js
import perspective from '@perspective-dev/client/inline'
import '@perspective-dev/viewer/inline'
import '@perspective-dev/viewer-datagrid'
import '@perspective-dev/viewer-d3fc'
import '@perspective-dev/viewer/themes'
```
- The `/inline` entrypoints bundle the WASM into the Vite build — **no runtime network
fetch, works offline / behind a firewall, reproducible from the lockfile.**
- **Do not load from a CDN at runtime.** It's convenient for a prototype (smaller build,
one-line version bumps) but in production it means: app breaks if the CDN is
unreachable, version isn't captured in `package-lock.json`, slower cold start, and you
pull executable WASM from a third party on every load.
> **This is not hypothetical — it took pf_app down on 2026-08-10.** The 4.x CDN bundle
> resolves its server WASM with
> `new URL("../../../server/dist/wasm/perspective-server.wasm", import.meta.url)`,
> which from `.../client@4.4.0/dist/cdn/` resolves to
> `.../npm/@perspective-dev/server/dist/wasm/perspective-server.wasm` — **no version**.
> jsdelivr serves `@latest`. The moment `@perspective-dev/server@5.2.0` published, every
> pf_app page load linked a 5.2.0 WASM against a 4.4.0 client and threw
> `LinkError: Import #8 "env" "psp_opfs_load": function import requires a callable`.
> Nobody changed anything. 4.4.1 and 4.5.2 have the identical unversioned pattern.
>
> The security framing matters as much as the outage: an unversioned URL means your
> users execute whatever that package publishes next, automatically, unreviewed.
>
> 5.2.0 fixes it by carrying the client's version across
> (`/client@X/dist/cdn/… → /server@X/dist/wasm/…`), but the durable fix is `/inline`:
> the WASM is embedded in the bundle and there is no runtime fetch to hijack
> (verified: zero `new URL(...perspective-server...)` in `perspective.inline.js`).
pf_app migrated off CDN to npm `/inline` at 5.2.0 on 2026-08-17.
- The themes CSS is imported in JS (`@perspective-dev/viewer/themes`), **not** via a
`<link>` in `index.html` — so it's bundled and versioned too.
---
## 2. Version policy — a real trilemma (read carefully)
The version choice is constrained by two hard facts about the `@perspective-dev` packages
**(verified against installed metadata, 2026-06)**:
- **`viewer-d3fc` caps at 4.4.1** — npm publishes no 4.5.x. The d3fc charts (Bar / Line /
Treemap / Heatmap / etc.) live only in this package. **Still true as of 2026-08**, even
though `client`/`viewer`/`viewer-datagrid` now publish through **5.2.0** — so the
trilemma below has widened, not closed: taking 5.x costs the d3fc charts outright.
- **The `/inline` and `/themes` entrypoints are 4.5.x-only** — `@perspective-dev/client/inline`,
`@perspective-dev/viewer/inline`, and `@perspective-dev/viewer/themes` do **not** exist
in 4.4.1's `exports` map. Bundling inline WASM requires 4.5.x.
So you can have at most **two** of these three:
| Want | Requires |
|---|---|
| Inline WASM bundling (`/inline`, `/themes`) | **4.5.x** viewer/client |
| One coherent single-version suite | **4.4.1** everything (d3fc ceiling) |
| d3fc chart plugins | **4.4.1** viewer-d3fc — *and a 4.4.1 viewer to match* (see correction) |
There is **no** version where all three hold. Pick by what the app needs:
> ### ⚠️ CORRECTION (2026-08): the mixed pair does NOT deliver charts
>
> This section previously recommended `^4.5.1` viewer/client/datagrid + `^4.4.1`
> viewer-d3fc as "the only combo that keeps both." **That recommendation was wrong.**
>
> - **Confirmed by the app owner:** in the deployed dataflow install (`/opt/dataflow`,
> running exactly that pair), *every chart type other than Datagrid fails.*
> - **Mechanism, reproduced in isolation:** loading 4.4.1 `viewer-d3fc` against a 4.5.1
> `viewer` throws `get_static_config is not a function` — once per chart plugin. The
> 4.5.x viewer calls a registration method the 4.4.1 plugins don't implement. The same
> load against a coherent 4.4.1 viewer produces no such error.
>
> So the "Inline-bundled + charts" row below is **not achievable**. The trilemma is
> really a **dilemma**: inline WASM bundling **XOR** d3fc charts — pick one.
>
> Consequence: dataflow currently has the worst of both worlds. It carries the
> mixed-version complexity *specifically* to keep charts, and does not have charts.
> Both directions are strictly better than standing still: down to a coherent **4.4.1**
> suite (if charts matter) or up to **5.x** (if they don't, and you want the newer
> engine — §3a, `split_rollup_mode`, `edit_mode` persistence).
>
> **Still unverified:** whether a coherent 4.4.1 suite actually *renders* charts in a
> real bundled build. It is the documented-and-untested assumption this whole policy
> rests on — establish it before betting a version choice on it.
- ~~**Inline-bundled + charts** (dataflow's case) → `^4.5.1` viewer/client/datagrid **+
`^4.4.1` viewer-d3fc`.~~ **Withdrawn — see correction above.** This pair yields a
working Datagrid and no charts. If you are on it today, you are choosing inline
bundling, not charts; be explicit about which one you actually want.
- **Coherent single suite, no inline** (e.g. CDN or `.`-entry loading) → pin all four to
**4.4.1 exact**. Charts are *believed* to work here (unverified — see above); you give
up `/inline` bundling.
Whatever you pick, **commit the lockfile** so the resolved set can't drift on
`npm install`. Re-evaluate the whole policy only when `viewer-d3fc` ships a 4.5.x or
later (then a fully-coherent inline-capable suite becomes possible — and only then does
"both" come back on the table).
---
## 3. Data delivery — match the format to the workload
| Workload | Format | Why |
|---|---|---|
| Large (100k+ rows), numeric-heavy, writes/incremental updates | **Arrow IPC** | Compact columnar binary, near-zero-copy ingest, carries types (no string coercion). Powers pf_app's 500k-row path. |
| Small (≤100k), read-only, click-to-inspect | **JSON rows** | Simpler, no encoding step, no dictionary pitfalls. dataflow's model. |
### Arrow constraints (read before choosing it)
If you deliver Arrow, three pieces are coupled and must stay aligned:
- **Numeric type parsers, server-side.** pg returns `bigint`/`numeric` as *strings*; you
must coerce them to JS numbers before encoding, or `apache-arrow` infers
`Dictionary<Utf8>` instead of `Int`/`Float64`. See `server.js` type parsers (oid 20,
1700).
- **Single record batch.** Per-batch Arrow builds independent dictionaries; the
Perspective WASM crashes on dictionary-replacement messages. The server must
accumulate all rows and emit **one** batch (`tableToIPC(tableFromJSON(allRows),
'stream')`). Consequence: the client "stream" is just a chunked *download* of one
batch — nothing renders progressively, and the server holds the full result set in
memory per request.
- **`apache-arrow` pinned to match the client WASM.** Pin it exact in the server
`package.json` and treat it as part of the locked unit (principle above). Note the
`@perspective-dev/client` build is tested against **`apache-arrow@17.0.0`**, while
pf_app's server currently pins **`^21.1.0`** — a real IPC version gap. Prefer aligning
the server toward the arrow major the client was built against, or at minimum make
Arrow ingestion (smoke test §7) the gate on any arrow bump.
JSON avoids all three but pays in payload size and parse cost, and pushes type handling
to client-side heuristics — acceptable only at small scale.
---
## 3a. Expression columns and aggregation order (ratio correctness)
**Expression columns are row-level.** Perspective evaluates every expression against
each *raw row* first, then feeds the result into the column's aggregate. It has no
post-aggregate expression stage. So a ratio written the obvious way:
```js
expressions: { price: '"revenue" / "qty"' } // default aggregate: sum
```
...computes `revenue/qty` per row and then **sums the per-row ratios** — the classic
sum-then-divide error. Verified on 4.4.0/4.4.1/4.5.2/5.2.0 (all identical): for a group
whose true `sum(revenue)/sum(qty)` is `16.15`, the pivot shows `42`.
This is **not** a `split_by` bug. It is equally wrong with only `group_by` — column
grouping just makes it visible by putting several wrong numbers side by side. No
scalar aggregate fixes it: `avg`/`mean` give the average *of ratios* (`14`), and
`high`/`low`/`median`/`dominant` are all wrong for the same reason.
### The fix: a weighted-mean aggregate
Weight the ratio by its own denominator. `sum(price_i × qty_i) / sum(qty_i)` is
algebraically `sum(revenue)/sum(qty)` — the correct answer at *every* level of both axes:
```js
expressions: { price: '"revenue" / "qty"' },
aggregates: { price: ['weighted mean', ['qty']] } // note the NESTED array
```
**The nested array matters.** The type is
`Aggregate = string | [string, Array<string>]` (`ts-rs/Aggregate.d.ts`), so the weight
column goes in *its own array*. The flat form `['weighted mean', 'qty']` is rejected
with the unhelpful `data did not match any variant of untagged enum Aggregate` — which
reads like "no such aggregate" and is easy to misread as the feature being absent.
Available since **4.4.0** — no version bump needed, and because the aggregate is
evaluated inside the engine, incremental `table.update()` stays correct (verified: a
`table.update()` on a live view re-derives the weighted mean from the merged rows
without a reload).
### Rules of thumb
- Column is a **sum of a measure** (incl. `if(...)` column-subtotal expressions, §below)
→ leave the default `sum`. Those are unaffected by any of this.
- Column is a **ratio, rate, price, or per-unit figure** → it *must* carry a
`['weighted mean', ['<denominator>']]` aggregate, or it is wrong under any pivot.
- Mixing both in one view is fine and was verified.
### Type inference can silently break expressions
`if("Year" == '2026', "Amount", 0)` returns 0 for every row if `Year` was **inferred**
as `integer` — which happens to numeric-looking strings when the table is created from
inferred JSON. With an explicit `string` schema the same expression is correct. Neither
literal form (`'2026'` or `2026`) works against a mis-inferred column, and there is no
error. Give period/year columns an explicit `string` type at table creation.
### Not fixed by any of this: column-axis expand/collapse
`view.expand()` / `view.collapse()` / `set_depth()` take a **row index** and act on the
row axis only; there is no column-axis equivalent in 4.4.0 **or** 5.2.0, contrary to the
docs' claim that both axes support it. Confirmed directly against the API. 5.2.0 adds
`split_rollup_mode: 'rollup'`, which *emits* subtotal and grand-total column groups
statically (no interactivity) — the nearest thing to Excel-style column subtotals, and
it would retire the `if(...)`-expression workaround. It costs the d3fc charts, though
(§2: `viewer-d3fc` still caps at 4.4.1).
---
## 4. Theming
- One toggle drives both app CSS and the viewer:
`viewer.setAttribute('theme', dark ? 'Pro Dark' : 'Pro Light')`.
- Apply it on initial load **and** in an effect keyed on the dark flag, so the viewer
re-themes when the toggle fires (not just on mount).
- App-level dark mode is plain CSS custom properties + a `.dark` class on `<html>`; the
viewer theme name is the only Perspective-specific piece.
---
## 5. Layout persistence
- Persist the full viewer config (`await viewer.save()`, incl. `plugin_config`) to
`localStorage`, keyed per source/version. Restore with `viewer.restore(cfg)`.
- **Guard restores against schema drift.** Before restoring, filter the saved config's
`columns`/`group_by`/`split_by`/`sort`/`filter` against the columns that actually
exist in the current dataset (plus any `expressions`). dataflow's `cleanLayout()` is
the reference implementation; a stale layout referencing a dropped column otherwise
throws on restore.
- **`aggregates` needs the same guard.** pf_app's `cleanLayout()` has it as of 2026-09;
**dataflow's does not** and should adopt it. Filtering
`columns`/`group_by`/`split_by`/`sort`/`filter` and stopping there is harmless only
while `viewer.save()` emits `aggregates: {}`, which it does until someone sets an
aggregate explicitly. The moment a layout adopts the weighted-mean pattern (§3a), a
dropped column aborts the entire restore — both `table.view()` and `viewer.restore()`
throw `Could not get dtype for column 'X' as it does not exist in the schema`. An
aggregate entry references a *target* column and, in the multi-arg form, a *weight*
column; both need validating, and the entry is what gets dropped, never the layout.
Add this guard as part of adopting §3a, not after.
### Auto-pause deletes the view — turn it off for a static pivot
`<perspective-viewer>` auto-pauses by default: an `IntersectionObserver` on itself
(scrolled out of the viewport, `display: none`) combined with the document's
`visibilitychange` (backgrounded tab, minimized window). "Pause" is not a paint
optimisation — `session.set_pause(true)` runs `view_sub.take().delete()`, so the
**view object is destroyed**. Becoming visible again calls
`restore_and_render(…, ViewerConfigUpdate::default())`: a new view and a full
traversal, every time.
For a viewer streaming live updates nobody is watching, that is the right trade.
For a pivot over a large static table it is the wrong one — on pf_app's
`fc_osm_skinny_29` grain it is a multi-second stall on every tab switch, and it
silently discards per-node expand/collapse (§"Not fixed by any of this"), which
has no config representation and so cannot be restored.
```js
await viewer.load(table)
try { if (viewer.setAutoPause) await viewer.setAutoPause(false) } catch {}
```
Guard the call: it is a method on the custom element and absent on older builds.
Leave auto-pause **on** where the table is fed by a live stream the user does not
need to have kept up with while away. This is long-standing viewer behaviour, not
a 5.x regression — worth knowing before blaming a rebuild on your own code.
---
## 6. Build & deploy (target)
- **Build:** `vite build` emits a static bundle (pf_app → `public/app`, via
`outDir: '../public/app'`). The API server serves it statically (`express.static`).
- **Process:** run the Node API under **systemd** with `Restart=always` and an
`EnvironmentFile=.env`; front it with **nginx** (reverse proxy + TLS via certbot).
dataflow's `dataflow.service` + `deploy.sh` are the reference; **pf_app has no deploy
automation yet** and should adopt the same pattern.
- **`deploy.sh`** should be idempotent: first run installs (db/schema, UI build, nginx,
systemd unit); later runs rebuild the UI and restart the service.
- Keep secrets in `.env` (db creds), loaded by both the app (`dotenv`) and the systemd
unit — never commit it.
---
## 7. Upgrade checklist / smoke test
Run this whenever bumping **any** Perspective package or `apache-arrow`:
1. Confirm `viewer-d3fc` publishes the target version
(`npm view @perspective-dev/viewer-d3fc versions`). If not, **don't bump** the others.
2. Pin all four packages + `apache-arrow` to exact, matching versions; `npm install`;
commit the lockfile.
- **Pin `@perspective-dev/server` explicitly too.** `@perspective-dev/client` declares
it as `"@perspective-dev/server": ""` — an *empty* range, which npm resolves to
`latest`. A fresh `npm i @perspective-dev/client@4.4.0` today pulls **server 5.2.0**
and the WASM fails to link:
`Import #8 "env" "psp_opfs_load": function import requires a callable`.
Five packages, not four. This is invisible while pf_app loads from the CDN, and
will bite on the "move off CDN" open item below.
3. `vite build` — no unresolved imports.
4. **Arrow apps:** load a real dataset and confirm `worker.table(buffer)` ingests
without a WASM dictionary error; verify a numeric column is `Float64`/`Int`, not a
string/dictionary.
5. Open a d3fc **chart** plugin (not just datagrid) and confirm it renders.
6. Toggle dark/light; confirm the viewer re-themes.
7. Save a layout, reload, confirm it restores; then drop a column and confirm the
cleaned restore doesn't throw.
---
## Per-project state (2026-06)
| | pf_app | dataflow | Target |
|---|---|---|---|
| Loader | **npm `/inline`** (was CDN until 2026-08-17) | npm `/inline` | **npm `/inline`** |
| Version | **5.2.0 exact, all four** (incl. `server`) | 4.5.1 viewer/client + 4.4.1 d3fc | **5.2.0 exact** |
| Charts | none — d3fc import dropped (unused) | d3fc imported but **broken** (§2 correction) | decide per app |
| Data | Arrow IPC (single batch) | JSON (≤100k) | per workload (§3) |
| `apache-arrow` | `^21.1.0` — **verified OK against 5.2.0 WASM** | n/a | pin exact; verify by test |
| Deploy | none | systemd + nginx + `deploy.sh` | **systemd + nginx + `deploy.sh`** |
**dataflow's 4.5.1/4.4.1 pair is correct** — it's the only combo giving both inline
bundling and d3fc charts (§2). Leave it; just keep the lockfile committed.
**Open items:**
- ~~pf_app → move off CDN.~~ **Done 2026-08-17** — npm `/inline`, all four packages
pinned exact at 5.2.0, lockfile committed. Verified end-to-end with every external host
blocked: viewer + datagrid register, the real Arrow stream ingests, the pivot renders.
- **dataflow → same migration.** It is on the withdrawn 4.5.1/4.4.1 pair (§2 correction):
its d3fc charts do not work, so it is paying mixed-version complexity for nothing.
Either drop d3fc and go to 5.2.0, or go coherent 4.4.1 — but verify charts actually
render before choosing the latter, because nobody has confirmed they do.
- pf_app still has no deploy automation (systemd + nginx + `deploy.sh`).

View File

@ -1,187 +0,0 @@
# Perspective — lessons and rules
What we have learned the hard way about configuring, feeding and upgrading
Perspective, written for any app that embeds `<perspective-viewer>` (**pf_app**,
**dataflow**). The pf_app specifics — the vendored 5.4.0 fork, its patch, and how
to rebuild it — live in `ui/vendor/README.md`; how pf_app uses the viewer day to
day is in `CLAUDE.md` and `pf_spec.md` → §The pivot.
> **Distribution:** we use the **`@perspective-dev/*`** packages
> (<https://github.com/perspective-dev/perspective>) — **not** FINOS/OpenJS
> `@finos/perspective`. Same engine lineage, but a separate npm scope, release
> cadence and "Pro" theme set (`Pro Dark` / `Pro Light`). Don't mix the scopes.
---
## Perspective is one locked unit
These move together and are pinned together. Bumping one without the others is
the source of nearly every Perspective bug we have hit:
1. **Loader** — how the JS/WASM gets into the page (npm `/inline`, never a CDN)
2. **Package versions** — `client`, `server`, `viewer`, `viewer-datagrid` (and
`viewer-d3fc` where charts are used)
3. **Data format** — Arrow IPC vs JSON rows
4. **`apache-arrow` on the server**, when delivering Arrow — it must write an IPC
format the client WASM reads
**Pin `@perspective-dev/server` explicitly.** `@perspective-dev/client` declares
it as `"@perspective-dev/server": ""` — an empty range, which npm resolves to
`latest`. Installing an older client pulls whatever server is newest and the WASM
fails to link (`Import #8 "env" "psp_opfs_load": function import requires a
callable`). Five packages, not four.
---
## Load inline, never from a CDN
```js
import perspective from '@perspective-dev/client/inline'
import '@perspective-dev/viewer/inline'
import '@perspective-dev/viewer-datagrid'
import '@perspective-dev/viewer/themes'
```
The `/inline` entrypoints bundle the WASM into the Vite build: no runtime fetch,
works behind a firewall, reproducible from the lockfile. The themes CSS is
imported in JS for the same reason, not `<link>`ed from `index.html`.
**This is not hypothetical — a CDN load took pf_app down on 2026-08-10.** The 4.x
CDN bundle resolves its server WASM with
`new URL("../../../server/dist/wasm/perspective-server.wasm", import.meta.url)`,
which from `.../client@4.4.0/dist/cdn/` lands on
`.../npm/@perspective-dev/server/dist/wasm/perspective-server.wasm` — **no
version**, so jsdelivr serves `@latest`. The moment a 5.x server published, every
page load linked a 5.x WASM against a 4.4.0 client and threw the `LinkError`
above. Nobody changed anything. The security framing matters as much as the
outage: an unversioned URL means users execute whatever that package publishes
next, unreviewed. `/inline` has no runtime fetch to hijack.
---
## Charts versus inline bundling (apps that need d3fc)
pf_app uses the datagrid only and does not carry `viewer-d3fc`. For an app that
does, the constraint as of 2026-08: `viewer-d3fc` published nothing past 4.4.1,
while `/inline` and `/themes` exist only from 4.5.x. **A 4.5.x viewer with a 4.4.1
d3fc does not work** — every chart plugin throws `get_static_config is not a
function` (reproduced in isolation, and seen in dataflow's install, where every
chart type but the datagrid fails). So it is inline bundling **or** d3fc charts,
not both: go coherent 4.4.1 for charts (still unverified that a bundled 4.4.1
suite renders them — check before relying on it), or 5.x without them. Re-check
when d3fc publishes a newer version.
---
## Arrow delivery: three coupled constraints
Arrow IPC is the right format for large, numeric, incrementally-updated tables;
JSON rows are simpler and fine for small read-only ones. Delivering Arrow couples
three things:
- **Numeric type parsers, server-side.** pg returns `bigint` / `numeric` as
strings; coerce them to numbers before encoding, or `apache-arrow` infers
`Dictionary<Utf8>` instead of `Int` / `Float64`. pf_app: the type parsers in
`server.js` (oids 20 and 1700).
- **One record batch.** Per-batch encoding builds independent dictionaries, and
the WASM crashes on dictionary-replacement messages. Accumulate all rows and emit
a single batch — pf_app does `tableToIPC(tableFromArrays(columns), 'stream')`.
The client's "stream" is therefore a chunked download of one batch; nothing
renders progressively.
- **`apache-arrow` versus the client WASM.** The client is built against an older
arrow major than pf_app's server pins; the pair was verified working, but any
arrow bump has to be gated on the ingest smoke test below.
---
## Expression columns are row-level: ratios need a weighted mean
Perspective evaluates every expression against each **raw row**, then feeds the
result into the column's aggregate; there is no post-aggregate expression stage.
So `price: '"revenue" / "qty"'` under the default `sum` **sums the per-row
ratios** — wrong at every grouped level, with or without `split_by`. `avg` gives
the average of ratios, which is also wrong.
Weight the ratio by its own denominator — `sum(price_i × qty_i) / sum(qty_i)` is
exactly `sum(revenue) / sum(qty)`, at every level of both axes:
```js
expressions: { price: '"revenue" / "qty"' },
aggregates: { price: ['weighted mean', ['qty']] } // note the NESTED array
```
**The nested array matters.** `Aggregate = string | [string, Array<string>]`, so
the weight column goes in its own array. The flat `['weighted mean', 'qty']` is
rejected with `data did not match any variant of untagged enum Aggregate`, which
reads like the feature is missing. Being engine-side, it stays correct across
incremental `table.update()`.
- A **sum of a measure** (including `if(...)` subtotal expressions) → default
`sum`.
- A **ratio, rate, price or per-unit figure** → must carry
`['weighted mean', ['<denominator>']]`.
**Type inference can silently break expressions.** `if("Year" == '2026',
"Amount", 0)` returns 0 on every row if `Year` was *inferred* as integer from
numeric-looking strings, with no error, and neither literal form fixes it. Give
period and year columns an explicit `string` type at table creation.
---
## Theming
The viewer theme is a `ViewerConfig` field: `viewer.restore({ theme: 'Pro Dark' })`.
**`setAttribute('theme', …)` does nothing in 5.x** — the element observes no such
attribute. Apply it on load and whenever the app toggle changes. `viewer.save()`
includes `theme`, so strip it from stored layouts and ignore it when comparing
configs — otherwise restoring a layout reasserts its author's theme.
---
## Layouts: guard every restore against schema drift
Before `viewer.restore(cfg)`, filter the saved `columns` / `group_by` /
`split_by` / `sort` / `filter` against the columns the current table actually has
(plus its `expressions`); a stale layout naming a dropped column otherwise throws.
**`aggregates` needs the same guard** — once a layout carries a weighted mean, a
dropped column aborts the whole restore (`Could not get dtype for column 'X'`).
An aggregate names a target column and, in the multi-arg form, a weight column;
validate both and drop the *entry*, never the layout, since `restore()` is
all-or-nothing. pf_app's `cleanLayout()` does all of this; dataflow's does not yet
guard `aggregates`.
---
## Auto-pause deletes the view — turn it off for a static pivot
`<perspective-viewer>` auto-pauses by default (an `IntersectionObserver` on itself
plus the document's `visibilitychange`). Pausing is not a paint optimisation:
`session.set_pause(true)` runs `view_sub.take().delete()`, so the view is
destroyed, and becoming visible again rebuilds it with a full traversal — a
multi-second stall per tab switch on a large table, and any per-node
expand/collapse is lost with it.
```js
await viewer.load(table)
try { if (viewer.setAutoPause) await viewer.setAutoPause(false) } catch {}
```
Leave it on only where a live stream feeds a table nobody needs kept current
while away. Long-standing viewer behaviour, not a regression.
---
## Upgrade smoke test
Run on any change to a Perspective package or `apache-arrow` (for pf_app, after
rebuilding the vendored tarballs per `ui/vendor/README.md`):
1. All five packages (and `apache-arrow`) pinned exact and matching; lockfile
committed.
2. `vite build` — no unresolved imports.
3. Arrow apps: load a real dataset; `worker.table(buffer)` ingests without a
dictionary error, and a numeric column comes through as `Float64` / `Int`.
4. Charts, where used: open a d3fc plugin and confirm it renders.
5. Toggle dark/light; the viewer re-themes.
6. Save a layout, reload, confirm it restores; drop a column and confirm the
cleaned restore does not throw.

File diff suppressed because it is too large Load Diff

82
install.sh Executable file
View File

@ -0,0 +1,82 @@
#!/usr/bin/env bash
set -e
echo ""
echo "========================================"
echo " Pivot Forecast — Install"
echo "========================================"
echo ""
# ── DB connection ─────────────────────────────────────────────
read -p "DB host [192.168.1.110]: " DB_HOST
DB_HOST=${DB_HOST:-192.168.1.110}
read -p "DB port [5432]: " DB_PORT
DB_PORT=${DB_PORT:-5432}
read -p "DB name [ubm]: " DB_NAME
DB_NAME=${DB_NAME:-ubm}
read -p "DB user [ptrowbridge]: " DB_USER
DB_USER=${DB_USER:-ptrowbridge}
read -s -p "DB password: " DB_PASSWORD
echo ""
read -p "App port [3030]: " PORT
PORT=${PORT:-3030}
# Session cookies are signed with this; the server refuses to start without it.
SESSION_SECRET=$(node -e 'console.log(require("crypto").randomBytes(32).toString("hex"))')
read -p "Send session cookie over HTTPS only? [Y/n]: " SECURE_ANS
case "${SECURE_ANS:-y}" in [Nn]*) COOKIE_SECURE=false ;; *) COOKIE_SECURE=true ;; esac
# ── Write .env ────────────────────────────────────────────────
cat > .env <<EOF
DB_HOST=${DB_HOST}
DB_PORT=${DB_PORT}
DB_NAME=${DB_NAME}
DB_USER=${DB_USER}
DB_PASSWORD=${DB_PASSWORD}
PORT=${PORT}
SESSION_SECRET=${SESSION_SECRET}
COOKIE_SECURE=${COOKIE_SECURE}
EOF
chmod 600 .env
echo "✓ .env written"
# ── npm install ───────────────────────────────────────────────
echo ""
echo "Installing node dependencies..."
npm install --silent
echo "✓ dependencies installed"
# ── schema install ────────────────────────────────────────────
echo ""
echo "Installing pf schema into ${DB_NAME}..."
PGPASSWORD=${DB_PASSWORD} psql \
-h "${DB_HOST}" \
-p "${DB_PORT}" \
-U "${DB_USER}" \
-d "${DB_NAME}" \
-v ON_ERROR_STOP=1 \
-f setup_sql/01_schema.sql \
-f setup_sql/02_auth.sql
echo "✓ schema installed"
# ── first account ─────────────────────────────────────────────
echo ""
echo "The app is behind a login. Create the first account now:"
./pf.sh add-user
# ── done ─────────────────────────────────────────────────────
echo ""
echo "========================================"
echo " Install complete"
echo " Start with: npm run dev
More accounts: ./pf.sh add-user"
echo " Open: http://$(hostname -I | awk '{print $1}'):${PORT}"
echo "========================================"
echo ""

View File

@ -14,58 +14,93 @@
// What the pivot shows for a row's segment and its bucket.
//
// Both are STORED on the row's pf.log entry -- l.label and l.bucket -- and read
// back as plain columns. Every entry carries both: pf.log's log_named CHECK
// refuses one without, so a report can join pf.log and select them with no rule
// of its own.
//
// They used to be derived at read time, through a chain of fallbacks (a load's
// label, else its tag, else its note, else the version's unlabeled_load; an
// adjustment's label, else the version's adjustment_segment, else a constant).
// That made the pivot the only reader that knew what bucket a row was in: the
// same CASE was repeated in /agg, the /data cursor and the decorate proc in
// /opt/forecast, and anything querying pf directly had to copy it again. It also
// meant editing a version's fallback name silently renamed every past adjustment.
//
// The ordering prefix is part of the stored text, not computed here. Perspective
// orders column groups by the value string, so "01 - Actual" is the only way an
// arbitrary order can be expressed -- and l.label is where a person types it.
// Nothing derives it, which is deliberate: an earlier design built the prefix from
// a separate seq column, as Perspective expressions on the client, and the prefix
// then existed only inside the pivot -- so every other reader disagreed with it,
// and a label that could not be expressed in ExprTK's printable-ASCII-per-byte
// string scanner could not be ordered at all. Stored text has neither problem.
//
// The single exception is the adjustment fallback, whose 99 keeps unlabelled
// adjustments last. Labelling an adjustment's log row overrides it, which is how
// one kind of adjustment is split out from the rest -- l.label rather than tag or
// note, so a segment name stays separable from adjustment commentary (pf_note).
//
// ---------------------------------------------------------------------------
// ADJUSTMENT DEFAULTS -- what a scale/recode/clone is stamped with.
// DISPLAY DEFAULTS -- every hardcoded name the pivot can show.
//
// An adjustment has no label or bucket input, so the route stamps these onto its
// log entry when it is written: the version's own adjustment_segment /
// adjustment_bucket when set, else the constants below. Resolved once, at write
// time -- changing a version's default afterwards names new adjustments, not old
// ones. Exported so the operation routes stamp the rows they push back to the
// pivot with the same values they wrote.
// These are the values a row falls back to when nobody has named it. They are
// the complete list: if a segment or bucket appears in the pivot under a name
// that is not in pf.log, it came from here. CLAUDE.md has the same list under
// "Hardcoded display names".
//
// A load has no default: its label is required, and its bucket is its label
// unless one is given (the routes enforce both).
// They live on pf.version -- adjustment_segment, adjustment_bucket,
// unlabeled_load -- and the constants below are only the fallback for a version
// that has not set one. Read through a join at query time rather than
// substituted at generation: pf.sql templates are keyed on (source_id,
// operation) and shared by every version of a source, so a value baked in could
// not vary by version and regenerating for one would change the others.
//
// Exported because /data builds its own statement in routes/operations.js while
// /agg is generated here, and the two have to agree.
// ---------------------------------------------------------------------------
// An adjustment with no label of its own. The 99 keeps it after every numbered
// segment -- ordering is string ordering, so this only works while the loads
// carry 01-0n.
// carry 01-0n. Labelling an adjustment's log row overrides it, which is how one
// kind of adjustment is split out from the rest.
const ADJUSTMENT_SEGMENT = '99 - Adjustments';
// What an adjustment counts toward. Prefixed to match the segments it adjusts,
// so the adjustments sit in the same column as the rows they adjust.
// What an adjustment counts toward. Prefixed to match the segments it adjusts:
// unprefixed it read 'Forecast' while the loads read '04 - Forecast', and the
// column split in two -- the adjustments sitting apart from the rows they
// adjust. The number is a guess at the convention in use, which is the clearest
// argument for making this per-version.
const ADJUSTMENT_BUCKET = '04 - Forecast';
const SEGMENT_EXPR = `l.label`;
const BUCKET_EXPR = `l.bucket`;
// A load nobody named. No prefix, so it sorts after everything numbered --
// letters follow digits in ASCII. The old '(unlabeled load)' sorted *first*,
// since '(' is 0x28 and digits begin at 0x30.
const UNLABELED_LOAD = 'Unlabeled';
const LOAD_SEGMENT = `COALESCE(NULLIF(l.label, ''), NULLIF(l.tag, ''), NULLIF(l.note, ''),
NULLIF(v.unlabeled_load, ''), '${UNLABELED_LOAD}')`;
const SEGMENT_EXPR = `CASE WHEN l.operation IN ('baseline','reference')
THEN ${LOAD_SEGMENT}
ELSE COALESCE(NULLIF(l.label, ''), NULLIF(v.adjustment_segment, ''), '${ADJUSTMENT_SEGMENT}')
END`;
// What the row counts towards. A load falls back to its own name until it is
// bucketed; an adjustment falls back to the forecast bucket, because that is
// what an adjustment is -- exclude_iters keeps operations off the reference
// segments, so there is no adjustment that is not part of the forecast.
const BUCKET_EXPR = `COALESCE(NULLIF(l.bucket, ''),
CASE WHEN l.operation IN ('baseline','reference')
THEN ${LOAD_SEGMENT}
ELSE COALESCE(NULLIF(v.adjustment_bucket, ''), '${ADJUSTMENT_BUCKET}')
END)`;
const NOTE_EXPR = `CASE WHEN l.operation IN ('baseline','reference')
THEN NULL
ELSE COALESCE(NULLIF(l.tag, ''), NULLIF(l.note, ''))
END`;
// Every pf.log column the expressions above read, for /agg's GROUP BY: they
// Every pf.log column the two expressions above read, for /agg's GROUP BY: they
// are functionally dependent on pf_logid, which is in the grain, but Postgres
// will not infer that.
const LABEL_GROUP_COLS = ['l.operation', 'l.label', 'l.tag', 'l.note', 'l.bucket'];
const LABEL_GROUP_COLS = ['l.operation', 'l.label', 'l.tag', 'l.note', 'l.bucket',
'v.adjustment_segment', 'v.adjustment_bucket', 'v.unlabeled_load'];
// The version carries the fallback names, so every statement that reads the
// expressions above needs it in scope as `v`. LEFT, not inner: a forecast row
// whose log entry somehow has no version should still come back, named by the
// constants.
const VERSION_JOIN = `
LEFT JOIN pf.version v
ON v.id = l.version_id`;
// wrap a column name in double quotes for safe use in SQL
function q(name) { return `"${name}"`; }
@ -284,7 +319,7 @@ SELECT
,l.operation AS pf_op
FROM {{fc_table}} t
LEFT JOIN pf.log l
ON l.id = t.pf_logid
ON l.id = t.pf_logid${VERSION_JOIN}
WHERE {{territory_clause}}
GROUP BY
${grain.groupCols('t.').join('\n ,')}
@ -396,9 +431,8 @@ SELECT count(*) AS rows_affected, (SELECT id FROM ilog) AS log_id FROM ins`.trim
return `
WITH
ilog AS (
INSERT INTO pf.log (version_id, pf_user, operation, slice, params, note, label, bucket)
VALUES ({{version_id}}, '{{pf_user}}', 'scale', '{{slice}}'::jsonb, '{{params}}'::jsonb, '{{note}}',
NULLIF('{{label}}', ''), NULLIF('{{bucket}}', ''))
INSERT INTO pf.log (version_id, pf_user, operation, slice, params, note)
VALUES ({{version_id}}, '{{pf_user}}', 'scale', '{{slice}}'::jsonb, '{{params}}'::jsonb, '{{note}}')
RETURNING id
)
,base AS (
@ -425,9 +459,8 @@ ${opTail('ins')}`.trim();
return `
WITH
ilog AS (
INSERT INTO pf.log (version_id, pf_user, operation, slice, params, note, label, bucket)
VALUES ({{version_id}}, '{{pf_user}}', 'recode', '{{slice}}'::jsonb, '{{params}}'::jsonb, '{{note}}',
NULLIF('{{label}}', ''), NULLIF('{{bucket}}', ''))
INSERT INTO pf.log (version_id, pf_user, operation, slice, params, note)
VALUES ({{version_id}}, '{{pf_user}}', 'recode', '{{slice}}'::jsonb, '{{params}}'::jsonb, '{{note}}')
RETURNING id
)
,src AS (
@ -439,11 +472,6 @@ ilog AS (
{{exclude_clause}}
GROUP BY
${groupCols([...dims, ...dateCols])}
-- a coordinate whose earlier adjustments already net it to zero has nothing to
-- move; writing it anyway is two empty rows per coordinate. Rare on a first
-- recode, but a second recode over cells a first one emptied would rewrite
-- every one of them
HAVING ${[effectiveValue, effectiveUnits].filter(Boolean).map(c => `sum(${q(c)}) <> 0`).join(' OR ')}
)
,neg AS (
INSERT INTO {{fc_table}} (${insertCols})
@ -452,14 +480,11 @@ ilog AS (
FROM src
RETURNING *
)
-- the put-back leg, moved by {{date_offset}} ('0 days' for a plain recode). The
-- route resolves {{set_clause}} against alias s, deriving the period dimensions
-- from the dim_period joins below only when the offset is non-zero.
,ins AS (
INSERT INTO {{fc_table}} (${insertCols})
SELECT {{set_clause}}, ${dateCols.map(c => `(s.${q(c)} + '{{date_offset}}'::interval)::date`).join(', ')}, ${effectiveValue ? `s.${q(effectiveValue)}` : '0'}${effectiveUnits ? `, s.${q(effectiveUnits)}` : ''},
SELECT {{set_clause}}, ${datesJoined}, ${effectiveValue ? q(effectiveValue) : '0'}${effectiveUnits ? `, ${q(effectiveUnits)}` : ''},
'recode', (SELECT id FROM ilog), '{{pf_user}}', now()
FROM src s${hasDimPeriod ? dimPeriodJoins(dateGroups) : ''}
FROM src
RETURNING *
)
${grain ? `,allrows AS (
@ -484,9 +509,8 @@ ${opTail('allrows')}` : 'SELECT * FROM neg UNION ALL SELECT * FROM ins'}`.trim()
return `
WITH
ilog AS (
INSERT INTO pf.log (version_id, pf_user, operation, slice, params, note, label, bucket)
VALUES ({{version_id}}, '{{pf_user}}', 'clone', '{{slice}}'::jsonb, '{{params}}'::jsonb, '{{note}}',
NULLIF('{{label}}', ''), NULLIF('{{bucket}}', ''))
INSERT INTO pf.log (version_id, pf_user, operation, slice, params, note)
VALUES ({{version_id}}, '{{pf_user}}', 'clone', '{{slice}}'::jsonb, '{{params}}'::jsonb, '{{note}}')
RETURNING id
)
,ins AS (
@ -533,33 +557,15 @@ function applyTokens(sql, tokens) {
// build a SQL WHERE clause string from a slice object
// only dimension columns are included; unrecognised keys are silently skipped
// pf_segment and pf_bucket are not columns on the forecast table -- they are
// stored on the row's pf.log entry -- so a slice naming one cannot be compared
// directly. It resolves to a set of log ids instead, which is
// computed at read time from the row's pf.log entry -- so a slice naming one
// cannot be compared directly. It resolves to a set of log ids instead, which is
// exact: the name lives on the log row, and every forecast row carries the
// pf_logid that points at it.
//
// Without this they were dropped from the slice, and clicking a single bucket's
// cell scaled every bucket at that dimension intersection while the panel showed
// only the one clicked.
//
// pf_note and pf_op are the same kind of column -- the pivot shows them, from
// the log entry -- and used to be refused, which the client pre-empted by
// stripping them. The ledger did not strip them, so a pivot grouped by one
// totalled a narrower set of rows than the operation then wrote.
const COMPUTED_SLICE_COLS = {
pf_segment: SEGMENT_EXPR,
pf_bucket: BUCKET_EXPR,
pf_note: NOTE_EXPR,
pf_op: 'l.operation',
};
// pf_ columns that are real columns of the forecast table, compared directly.
// pf_iter was stripped by the client and skipped here, on the reasoning that
// two cells differing only by iter band are the same slice. They are not to the
// ledger, which filtered on it: with the default layout grouped by pf_iter, a
// click on a customer's `scale` row showed the scale rows' total and wrote to
// every row the customer has.
const TABLE_SLICE_COLS = new Set(['pf_iter', 'pf_user', 'pf_logid']);
const COMPUTED_SLICE_COLS = { pf_segment: SEGMENT_EXPR, pf_bucket: BUCKET_EXPR };
function computedSlicePredicate(col, val, versionId) {
if (versionId == null) {
@ -567,21 +573,12 @@ function computedSlicePredicate(col, val, versionId) {
err.status = 500;
throw err;
}
// a blank is a value here too -- a load has no note -- and `IN (NULL)` is
// never true, so the null arm is ORed on as buildWhere does
const list = Array.isArray(val) ? val : [val];
const nulls = list.some(v => v === null);
const vals = list.filter(v => v !== null).map(v => `'${esc(v)}'`);
const expr = COMPUTED_SLICE_COLS[col];
const inList = vals.length ? `(${expr}) IN (${vals.join(', ')})` : null;
const test = nulls && inList ? `((${expr}) IS NULL OR ${inList})`
: nulls ? `(${expr}) IS NULL`
: inList;
const vals = (Array.isArray(val) ? val : [val]).map(v => `'${esc(v)}'`).join(', ');
return `pf_logid IN (
SELECT l.id
FROM pf.log l
FROM pf.log l${VERSION_JOIN}
WHERE l.version_id = ${parseInt(versionId)}
AND ${test}
AND ${COMPUTED_SLICE_COLS[col]} IN (${vals})
)`;
}
@ -598,13 +595,15 @@ function buildWhere(slice, dimCols, versionId) {
}
// A pf_ key this does not understand is refused rather than skipped.
// Skipping is how a selection silently widened: the operation ran against
// everything the dropped key would have excluded.
if (col.startsWith('pf_') && !TABLE_SLICE_COLS.has(col)) {
// everything the dropped key would have excluded. pf_iter is the one
// exception -- the client strips it deliberately, since two cells that
// differ only by iter band are the same slice.
if (col.startsWith('pf_') && col !== 'pf_iter') {
const err = new Error(`Slice names ${col}, which cannot be filtered on`);
err.status = 400;
throw err;
}
if (!allowed.has(col) && !TABLE_SLICE_COLS.has(col)) continue;
if (!allowed.has(col)) continue;
// A blank dimension value is a value. Both blanks exist in this data and
// they are different rows -- on fc_osm_skinny_34, partgroup is null on
// 51,964 and '' on 111,696 -- so a null has to become IS NULL. It used
@ -752,11 +751,8 @@ function buildTerritoryClause(territory, territoryCol, alias = '') {
// {Region:East, State:NY} and {Region:West, State:CA} would become
// Region IN (East,West) AND State IN (NY,CA), which also matches East/CA.
function buildWhereAny(slices, dimCols, versionId) {
const list = (slices || []).filter(Boolean);
// An empty slice is a total cell, and the union with a total is the total.
// Dropping it instead would narrow the write to the other cells. The caller
// only lets one through under a scope, which is ANDed on afterwards.
if (list.length === 0 || list.some(s => Object.keys(s).length === 0)) return 'TRUE';
const list = (slices || []).filter(s => s && Object.keys(s).length > 0);
if (list.length === 0) return 'TRUE';
if (list.length === 1) return buildWhere(list[0], dimCols, versionId);
const groups = list
@ -846,6 +842,6 @@ function esc(val) {
return String(val).replace(/'/g, "''");
}
module.exports = { generateSQL, grainOf, COMPUTED_SLICE_COLS, TABLE_SLICE_COLS, buildScopeClause, buildTerritoryClause,
SEGMENT_EXPR, BUCKET_EXPR, NOTE_EXPR, LABEL_GROUP_COLS,
ADJUSTMENT_SEGMENT, ADJUSTMENT_BUCKET, dateGroupsOf, dimPeriodMapOf, dimPeriodJoins, applyTokens, buildWhere, buildWhereAny, buildExcludeClause, buildExcludePredicate, buildSetClause, buildFilterClause, esc };
module.exports = { generateSQL, grainOf, COMPUTED_SLICE_COLS, buildScopeClause, buildTerritoryClause,
SEGMENT_EXPR, BUCKET_EXPR, NOTE_EXPR, LABEL_GROUP_COLS, VERSION_JOIN,
ADJUSTMENT_SEGMENT, ADJUSTMENT_BUCKET, UNLABELED_LOAD, dateGroupsOf, dimPeriodMapOf, dimPeriodJoins, applyTokens, buildWhere, buildWhereAny, buildExcludeClause, buildExcludePredicate, buildSetClause, buildFilterClause, esc };

10
pf.sh
View File

@ -194,15 +194,13 @@ cmd_db_setup() {
command -v psql &>/dev/null || die "psql not found — install postgresql-client"
echo
bold "DB Setup — will run: setup_sql/01_schema.sql, setup_sql/gen_dim_period.sql, setup_sql/02_auth.sql"
warn "This creates the pf schema and tables, the pf.dim_period calendar, and the account/session tables."
warn "The calendar's fiscal year starts in the month set in gen_dim_period.sql (fiscal_start_month) — check it before a first install."
warn "Safe to re-run (CREATE IF NOT EXISTS; existing calendar rows are never overwritten)."
bold "DB Setup — will run: setup_sql/01_schema.sql, setup_sql/02_auth.sql"
warn "This creates the pf schema, tables, and the account/session tables."
warn "Safe to re-run (CREATE IF NOT EXISTS)."
read -rp " Continue? [y/N] " confirm
[[ "$confirm" =~ ^[Yy]$ ]] || { echo "Aborted."; return; }
run_psql -v ON_ERROR_STOP=1 -f "${APP_DIR}/setup_sql/01_schema.sql"
run_psql -v ON_ERROR_STOP=1 -f "${APP_DIR}/setup_sql/gen_dim_period.sql"
run_psql -v ON_ERROR_STOP=1 -f "${APP_DIR}/setup_sql/02_auth.sql"
success "Schema applied."
@ -576,7 +574,7 @@ interactive_menu() {
echo " 4) restart restart server"
echo " 5) status service + DB + git info"
echo " 6) logs tail journald logs"
echo " 7) db-setup apply the pf schema, calendar and auth tables"
echo " 7) db-setup apply setup_sql/01_schema.sql"
echo " 8) config set DB connection + app PORT"
echo " 9) install-service create systemd unit file"
echo " 10) uninstall-service remove systemd unit file"

View File

@ -1,17 +1,5 @@
# Perspective Architecture Options
> **Decision record, written 2026-08.** The outcome was the display-grain
> pre-aggregation (§Spike findings onward), and it shipped. **"Current
> architecture" below describes the app as it was then**, not now: it has
> since moved to a vendored Perspective 5.4.0 bundled inline, `tableFromArrays`
> on the server, `table.remove()` on undo, and `/data` / `/agg` emitting
> `pf_segment`, `pf_bucket`, `pf_note` and `pf_op`. For today's data flow see
> `CLAUDE.md` → §Core data flow and `pf_spec.md` → §Display-grain
> pre-aggregation. What remains current here is the spike findings, the
> display-grain reasoning, and the Path A / Path B analysis behind the
> "dynamic grain" next step. The DuckDB virtual-server spike is parked on
> branch `spike/duckdb-virtual-server`.
This document weighs how the Forecast view should source data for the Perspective
pivot. The current implementation hits practical limits on initial load
(~30s for 350k rows × ~55 cols), and growth is expected. Choosing an architecture

1076
pf_spec.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -217,14 +217,6 @@ module.exports = function(pool) {
error: 'Nothing to update — send note, tag, bucket and/or label'
});
}
// Every entry carries a label and a bucket (pf.log's log_named CHECK), so
// they can be renamed but not cleared. Refused here for the message; the
// constraint would refuse it anyway.
for (const [field, value] of [['label', label], ['bucket', bucket]]) {
if (value !== undefined && !String(value ?? '').trim()) {
return res.status(400).json({ error: `The ${field} can be renamed but not cleared` });
}
}
try {
// Same rule as undo: your own entries, or an admin's. These are
// annotations, but label and bucket name the pivot's columns for

View File

@ -1,7 +1,7 @@
const express = require('express');
const { tableFromArrays, tableToIPC } = require('apache-arrow');
const { applyTokens, buildWhere, buildWhereAny, COMPUTED_SLICE_COLS, TABLE_SLICE_COLS, buildScopeClause, buildTerritoryClause, buildExcludeClause, buildExcludePredicate, buildSetClause, dateGroupsOf, dimPeriodMapOf, esc,
SEGMENT_EXPR, BUCKET_EXPR, NOTE_EXPR,
const { applyTokens, buildWhere, buildWhereAny, COMPUTED_SLICE_COLS, buildScopeClause, buildTerritoryClause, buildExcludeClause, buildExcludePredicate, buildSetClause, dateGroupsOf, dimPeriodMapOf, esc,
SEGMENT_EXPR, BUCKET_EXPR, NOTE_EXPR, VERSION_JOIN,
ADJUSTMENT_SEGMENT, ADJUSTMENT_BUCKET } = require('../lib/sql_generator');
const { sessionUser, sessionTerritory, requireAdmin } = require('../lib/auth');
const { fcTable } = require('../lib/utils');
@ -9,11 +9,10 @@ const { fcTable } = require('../lib/utils');
module.exports = function(pool) {
const router = express.Router();
// The segment and bucket an adjustment is stamped with: written onto its
// pf.log entry by the template, and onto the rows pushed back to the pivot,
// so the two agree. The version's own defaults when set, else the
// built-ins. Resolved at write time, so changing a version's default
// afterwards names new adjustments and leaves existing ones as they are.
// the names an adjustment's rows carry until the next load re-reads them
// through VERSION_JOIN. The per-version overrides live on pf.version, so
// stamping the built-in constants here made every fresh adjustment show as
// '99 - Adjustments' and jump to the version's own name on refresh.
function adjustmentNames(version) {
const pick = (v, dflt) => (v || '').trim() || dflt;
return {
@ -22,34 +21,16 @@ module.exports = function(pool) {
};
}
// The segment and bucket a load is stamped with. A load's label is its
// segment name and is required -- it used to fall back through tag, note and
// the version's unlabeled_load at read time, which left pf.log unable to say
// what a row was called. The bucket is the label unless one is given, which
// is how an unbucketed load has always read. Sends the 400 and returns null
// when there is no label.
function loadNames(label, bucket, res) {
const segment = String(label ?? '').trim();
if (!segment) {
res.status(400).json({ error: 'Give the segment a label — it is what the pivot shows and what reports group on' });
return null;
}
return { label: segment, bucket: String(bucket ?? '').trim() || segment };
}
async function runSQL(sql, client) {
console.log('--- SQL ---\n', sql, '\n--- END SQL ---');
return (client || pool).query(sql);
}
// accept either the legacy single `slice` object or the newer `slices` array.
// An empty slice is a total cell -- everything the pivot's filter shows -- so
// it is kept only when a scope came with it; without one it would be the
// whole version, and is dropped so an empty selection can never widen to TRUE
// accept either the legacy single `slice` object or the newer `slices` array,
// and drop any empty entries so an empty selection can never widen to TRUE
function normalizeSlices(body) {
const raw = Array.isArray(body.slices) && body.slices.length ? body.slices : [body.slice];
const scoped = Array.isArray(body.scope) && body.scope.length > 0;
return raw.filter(s => s && typeof s === 'object' && (scoped || Object.keys(s).length > 0));
return raw.filter(s => s && typeof s === 'object' && Object.keys(s).length > 0);
}
// Stamp the tag onto the log entry the operation just created.
@ -100,46 +81,26 @@ module.exports = function(pool) {
// Postgres to parse it alone first, where the failure is cheap and can name the
// field it came from. Negative intervals are valid and useful -- '-90 days'
// pulls a plan back a quarter -- so this checks validity, not sign.
// A date offset has to be a whole number of days. Every date it shifts is
// cast back to a date, so anything finer is rounded away -- and Postgres
// reads a bare number as seconds, so "30" meant thirty seconds, moved
// nothing, and a shifted recode took rows out of a cell and put them back
// into the same one.
async function assertInterval(value, res) {
const example = `Try something like "30 days", "1 month", "-90 days" or "0 days".`;
let whole;
try {
whole = (await pool.query(
`SELECT date_trunc('day', $1::interval) = $1::interval AS whole`, [value]
)).rows[0].whole;
await pool.query(`SELECT $1::interval`, [value]);
return true;
} catch {
res.status(400).json({ error: `"${value}" is not a valid interval. ${example}` });
return false;
}
if (!whole) {
res.status(400).json({
error: `"${value}" is not a whole number of days, so it would not move any date `
+ `(a number with no unit is read as seconds). ${example}`
error: `"${value}" is not a valid interval. Try something like `
+ `"4 months", "1 year", "-90 days" or "0 days".`
});
return false;
}
return true;
}
// A slice is only meaningful if at least one of its keys is a filterable
// column. buildWhere silently drops unknown keys, so {"typo": "x"} would
// otherwise reduce to TRUE and apply the operation to the whole version.
// Refuse rather than let a malformed selection rewrite every row.
// An empty slice passes only under a scope (see normalizeSlices), which
// buildScopeClause refuses to let reduce to nothing.
function assertSelective(slices, ctx, scope) {
// pf_iter, pf_note, pf_op, pf_user and pf_logid narrow a slice once it
// is selective, but do not make it so: they were only just accepted at
// all, and `pf_iter = scale` alone is every adjustment in the version
const allowed = new Set([...ctx.filterCols, 'pf_segment', 'pf_bucket']);
const scoped = Array.isArray(scope) && scope.length > 0;
function assertSelective(slices, ctx) {
const allowed = new Set([...ctx.filterCols, ...Object.keys(COMPUTED_SLICE_COLS)]);
slices.forEach((sl, i) => {
if (scoped && Object.keys(sl).length === 0) return;
const hits = Object.keys(sl).filter(k => allowed.has(k));
if (hits.length === 0) {
const err = new Error(
@ -221,11 +182,7 @@ module.exports = function(pool) {
// echo back what the caller asked for, for the audit log
function pickIntent(body) {
const keys = ['mode', 'target_basis', 'value_incr', 'units_incr', 'value_pct', 'units_pct', 'pct',
'target_value', 'target_units', 'target_price', 'scope', 'plug',
// What the panel's Result line promised, kept beside
// `resolved` so a disagreement between the two can be
// found by reading the log. Not checked -- yet.
'expected'];
'target_value', 'target_units', 'target_price', 'scope'];
const out = {};
for (const k of keys) if (body[k] !== undefined && body[k] !== null && body[k] !== '') out[k] = body[k];
return out;
@ -310,35 +267,15 @@ module.exports = function(pool) {
let value = resolve(tValue, vPct, vIncr, totals.value, fixedValue);
let units = resolve(tUnits, uPct, uIncr, totals.units, fixedUnits);
// A price target is the "edit price" mode of the Excel form. The price
// named holds, and whichever other measure was named decides the rest:
// price alone qty holds, dollars = price x qty
// price + qty qty lands as named (target, change or %),
// dollars = price x that qty
// price + dollars dollars land as named, qty = dollars / price
// all three refused -- two degrees of freedom, three numbers
// All on the basis the panel measures against, so under 'selected' the
// price is that of the whole selection, fixed rows included.
//
// This used to read only a qty *target*, so a qty change or % sized the
// dollars on the old qty and missed the price; took the fixed qty off a
// qty target but not the fixed dollars off the value, so under 'selected'
// it landed short; and let a dollar figure outrank the price outright.
const unitsGiven = [tUnits, uIncr, uPct].some(v => v !== null);
const valueGiven = [tValue, vIncr, vPct].some(v => v !== null);
if (tPrice !== null) {
const bad = (msg) => { const err = new Error(msg); err.status = 400; return err; };
if (valueGiven && unitsGiven) {
throw bad('Price, dollars and qty were all given. Any two decide the third -- clear one of them.');
}
const curValue = totals.value + fixedValue;
const curUnits = totals.units + fixedUnits;
if (valueGiven) {
if (!(tPrice > 0)) throw bad('A price of zero or less cannot set qty from dollars.');
units = (curValue + value) / tPrice - curUnits;
} else {
value = tPrice * (curUnits + units) - curValue;
}
// A price target is the "edit price" mode of the Excel form: price and
// volume are the inputs and dollars fall out of them. With a units target
// alongside it, both move; without one, volume holds and price alone carries
// the change. An explicit value target outranks it either way.
if (tPrice !== null && tValue === null) {
const targetUnits = tUnits !== null
? (tUnits - fixedUnits) + 0 // the units target is already absolute
: (totals.units + fixedUnits);
value = (tPrice * targetUnits) - (totals.value + fixedValue);
}
// Which side of price x volume absorbs a dollar change.
@ -355,21 +292,13 @@ module.exports = function(pool) {
// -- the dollars land on target either way -- so the default is now the
// conservative one and moving price takes saying so.
const plug = body.plug === 'price' ? 'price' : 'volume';
const unitsGiven = [tUnits, uIncr, uPct].some(v => v !== null);
const valueGiven = [tValue, vIncr, vPct].some(v => v !== null);
// The price held is the *adjustable* rows' own, never a blend with the
// fixed ones: the scale SQL prorates both increments over the adjustable
// rows only, so a units increment sized at the blended price cuts qty
// faster than dollars whenever the fixed rows are priced differently.
// Taking a slice to its fixed total -- dollars to zero -- then drove qty
// past zero, leaving grains with positive sales and negative qty.
//
// Not when a price was named. That is an edit-price, not a dollar edit:
// qty holds and the dollars were sized off the price above. The panel
// greys the plug out then and does not send it, so `plug` reads as its
// 'volume' default, and this block used to scale qty by the dollar
// change as well -- which put price straight back where it started.
if (plug === 'volume' && value !== 0 && !unitsGiven && tPrice === null) {
if (totals.value === 0) {
if (plug === 'volume' && value !== 0 && !unitsGiven) {
const curValue = totals.value + fixedValue;
const curUnits = totals.units + fixedUnits;
if (curValue === 0) {
const err = new Error(
'Cannot hold price constant here: the selection currently has no value, ' +
'so there is no price to hold. Scale units directly, or let price absorb ' +
@ -378,8 +307,8 @@ module.exports = function(pool) {
err.status = 400; throw err;
}
// price constant means value and units move by the same proportion:
// fVol = adjVol * (fVal / adjVal), so the units delta is adjVol * value/adjVal
units = totals.units * (value / totals.value);
// fVol = curVol * (fVal / curVal), so the units delta is curVol * value/curVal
units = curUnits * (value / curValue);
}
// The same question asked of a qty input, and the answer has to match:
@ -392,12 +321,13 @@ module.exports = function(pool) {
// deliberate forms are still available and both outrank this: name the
// dollars alongside the qty (then price falls out of the two), or name
// the price outright.
// Again at the adjustable rows' own price, for the reason given above.
if (units !== 0 && !valueGiven && tPrice === null) {
const curValue = totals.value + fixedValue;
const curUnits = totals.units + fixedUnits;
// no units means no price to hold, so the dollars stay where they are
// and the edit is a pure qty change -- adding units to a slice that
// has none cannot infer what they are worth
if (totals.units !== 0) value = (totals.value / totals.units) * units;
if (curUnits !== 0) value = (curValue / curUnits) * units;
}
// the scale SQL divides by the slice total; with no rows there is
@ -411,19 +341,7 @@ module.exports = function(pool) {
// offset each other is the usual cause, and 'each' handles that correctly.
assertProratable(totals, value, units);
// Which of price or volume the edit moved, written onto the log entry.
// The request's own fields do not say -- `plug` is a per-browser
// preference, and an unqualified dollar figure is a price edit or a
// volume edit depending on it -- so without this the log could not tell
// the two apart, and the only way to find a price edit afterwards was to
// notice qty had not moved.
const method =
tPrice !== null ? 'price' // price named
: unitsGiven && valueGiven ? 'both' // dollars and qty named, price falls out
: unitsGiven ? 'volume' // qty named, dollars follow at price
: plug; // dollars only: the plug decides
return { value: round(value, 6), units: round(units, 6), method };
return { value: round(value, 6), units: round(units, 6) };
}
// a pool is proratable only if its net is a meaningful fraction of its gross
@ -568,7 +486,7 @@ module.exports = function(pool) {
,${NOTE_EXPR} AS pf_note
FROM ${tbl} t
LEFT JOIN pf.log l
ON l.id = t.pf_logid
ON l.id = t.pf_logid${VERSION_JOIN}
${terrAlias ? `WHERE ${terrAlias}` : ''}
`);
@ -672,8 +590,6 @@ module.exports = function(pool) {
const dateOffset = date_offset || '0 days';
if (!await assertInterval(dateOffset, res)) return;
const filterClause = (raw_where || where_clause || '').trim() || 'TRUE';
const names = loadNames(label, bucket, res);
if (!names) return;
try {
const ctx = await getContext(parseInt(req.params.id), 'baseline');
if (!guardOpen(ctx.version, res)) return;
@ -687,8 +603,8 @@ module.exports = function(pool) {
version_id: ctx.version.id,
pf_user: esc(pf_user || ''),
note: esc(note || ''),
label: esc(names.label),
bucket: esc(names.bucket),
label: esc(label || ''),
bucket: esc(bucket || ''),
tag: esc(tag || ''),
params: esc(paramsJson),
filter_clause: filterClause,
@ -756,15 +672,13 @@ module.exports = function(pool) {
// so tag is always the latter and must survive an edit made for any
// other reason.
const keep = (sent, prior) => esc(sent ?? prior ?? '');
const names = loadNames(label ?? oldLog.label, bucket ?? oldLog.bucket, res);
if (!names) return;
const sql = applyTokens(ctx.sql, {
fc_table: ctx.table,
version_id: ctx.version.id,
pf_user: esc(pf_user || ''),
note: esc(note || ''),
label: esc(names.label),
bucket: esc(names.bucket),
label: keep(label, oldLog.label),
bucket: keep(bucket, oldLog.bucket),
tag: keep(tag, oldLog.tag),
params: esc(paramsJson),
filter_clause: filterClause,
@ -835,8 +749,6 @@ module.exports = function(pool) {
const pf_user = sessionUser(req);
const dateOffset = date_offset || '0 days';
const filterClause = (raw_where || where_clause || '').trim() || 'TRUE';
const names = loadNames(label, bucket, res);
if (!names) return;
try {
const ctx = await getContext(parseInt(req.params.id), 'reference');
if (!guardOpen(ctx.version, res)) return;
@ -850,8 +762,8 @@ module.exports = function(pool) {
version_id: ctx.version.id,
pf_user: esc(pf_user || ''),
note: esc(note || ''),
label: esc(names.label),
bucket: esc(names.bucket),
label: esc(label || ''),
bucket: esc(bucket || ''),
tag: esc(tag || ''),
params: esc(paramsJson),
filter_clause: filterClause,
@ -881,7 +793,7 @@ module.exports = function(pool) {
try {
const ctx = await getContext(parseInt(req.params.id), 'scale');
if (!guardOpen(ctx.version, res)) return;
assertSelective(slices, ctx, req.body.scope);
assertSelective(slices, ctx);
const excludeClause = buildExcludeClause(ctx.version.exclude_iters);
@ -891,7 +803,6 @@ module.exports = function(pool) {
// reaches the target on its own and gets its own log entry.
const units = sliceUnits(slices, ctx, applyMode, req.body.scope, req);
const names = adjustmentNames(ctx.version);
const client = await pool.connect();
let committed = false;
try {
@ -905,7 +816,7 @@ module.exports = function(pool) {
for (const unit of units) {
const incr = await resolveIncrs(client, ctx, unit.where, excludeClause, req.body);
// no rows, or already at the target — nothing to write for this slice
if (incr.value === 0 && incr.units === 0) { for (const s of unit.slices) skipped.push(s); continue; }
if (incr.value === 0 && incr.units === 0) { skipped.push(...unit.slices); continue; }
applied++;
const loggedSlice = unit.slices.length === 1 ? unit.slices[0] : unit.slices;
@ -914,13 +825,11 @@ module.exports = function(pool) {
version_id: ctx.version.id,
pf_user: esc(pf_user || ''),
note: esc(note || ''),
label: esc(names.pf_segment),
bucket: esc(names.pf_bucket),
params: esc(JSON.stringify({
slices: unit.slices,
apply_mode: applyMode,
...pickIntent(req.body),
resolved: { value_incr: incr.value, units_incr: incr.units, method: incr.method }
resolved: { value_incr: incr.value, units_incr: incr.units }
})),
slice: esc(JSON.stringify(loggedSlice)),
where_clause: unit.where,
@ -933,7 +842,7 @@ module.exports = function(pool) {
for (const r of result.rows) {
if (r.pf_logid != null) sqlByLogId.set(r.pf_logid, sql);
}
for (const r of result.rows) allRows.push(r);
allRows.push(...result.rows);
}
if (allRows.length === 0) {
@ -953,6 +862,7 @@ module.exports = function(pool) {
});
}
const opLabel = (req.body.tag || '').trim() || note || null;
const names = adjustmentNames(ctx.version);
const rows = allRows.map(r => ({ ...r, ...names, pf_note: opLabel, pf_op: 'scale' }));
res.json({
rows,
@ -972,47 +882,23 @@ module.exports = function(pool) {
// recode dimension values on one or more slices
// inserts negative rows to zero out the original, positive rows with new dimension values
//
// date_offset moves the positive rows in time as well -- the negative leg stays
// where the rows were, so the pair is a timing move (pull forward, push out)
// rather than a copy. With an offset, set may be empty: moving a slice without
// renaming anything is the whole request.
router.post('/versions/:id/recode', async (req, res) => {
const { note, set, apply_mode, date_offset } = req.body;
const { note, set, apply_mode } = req.body;
const pf_user = sessionUser(req);
const slices = normalizeSlices(req.body);
if (slices.length === 0) return res.status(400).json({ error: 'slice is required' });
const dateOffset = String(date_offset ?? '').trim() || '0 days';
if (!await assertInterval(dateOffset, res)) return;
const shifted = (await pool.query(`SELECT $1::interval <> '0'::interval AS s`, [dateOffset])).rows[0].s;
if ((!set || Object.keys(set).length === 0) && !shifted) {
return res.status(400).json({ error: 'Recode needs a new value or a date offset' });
}
if (!set || Object.keys(set).length === 0) return res.status(400).json({ error: 'set is required' });
try {
const ctx = await getContext(parseInt(req.params.id), 'recode');
if (!guardOpen(ctx.version, res)) return;
assertSelective(slices, ctx, req.body.scope);
assertSelective(slices, ctx);
if (!assertMayRecodeTerritory(req, ctx, set, res)) return;
const excludeClause = buildExcludeClause(ctx.version.exclude_iters);
// Moved rows take their period dimensions from the calendar against the
// shifted date, as clone does; an explicit set still wins. A row whose
// date is null has nothing to shift and keeps the label it had, rather
// than having it blanked by a calendar lookup that found nothing. Not
// derived at all without an offset, so a plain recode copies them as
// it always has.
const dateGroups = dateGroupsOf(ctx.colMeta);
const dateColOf = Object.fromEntries(dateGroups.map(g => [g.alias, g.dateCol]));
const derivedExprs = shifted ? Object.fromEntries(
[...dimPeriodMapOf(dateGroups)].map(([cname, { alias, periodCol }]) =>
[cname, `CASE WHEN s."${dateColOf[alias]}" IS NULL THEN s."${cname}" ELSE ${alias}."${periodCol}" END`])
) : undefined;
const setClause = buildSetClause(ctx.dimCols, set, { derivedExprs, alias: 's' });
const setClause = buildSetClause(ctx.dimCols, set);
const units = sliceUnits(slices, ctx, apply_mode === 'each' ? 'each' : 'prorate', req.body.scope, req);
const names = adjustmentNames(ctx.version);
const client = await pool.connect();
let committed = false;
try {
@ -1027,32 +913,19 @@ module.exports = function(pool) {
version_id: ctx.version.id,
pf_user: esc(pf_user || ''),
note: esc(note || ''),
label: esc(names.pf_segment),
bucket: esc(names.pf_bucket),
params: esc(JSON.stringify({ slices: unit.slices, set, apply_mode: unit.mode, date_offset: dateOffset })),
params: esc(JSON.stringify({ slices: unit.slices, set, apply_mode: unit.mode })),
slice: esc(JSON.stringify(loggedSlice)),
where_clause: unit.where,
exclude_clause: excludeClause,
set_clause: setClause,
date_offset: esc(dateOffset)
set_clause: setClause
});
const result = await runSQL(sql, client);
await tagLog(client, result.rows, req.body.tag);
for (const r of result.rows) {
if (r.pf_logid != null) sqlByLogId.set(r.pf_logid, sql);
}
for (const r of result.rows) allRows.push(r);
allRows.push(...result.rows);
}
// Every coordinate in the selection nets to zero -- nothing to move.
// Refused like scale's no-op rather than committing an empty log
// entry that reads as a recode that happened.
if (allRows.length === 0) {
await client.query('ROLLBACK');
return res.status(400).json({
error: 'Nothing to recode — the selection nets to zero, or holds only rows recode cannot touch'
});
}
await client.query('COMMIT');
committed = true;
// one log id per unit: apply_mode 'each' writes an entry per slice
@ -1063,6 +936,7 @@ module.exports = function(pool) {
});
}
const opLabel = (req.body.tag || '').trim() || note || null;
const names = adjustmentNames(ctx.version);
const rows = allRows.map(r => ({ ...r, ...names, pf_note: opLabel, pf_op: 'recode' }));
res.json({ rows, rows_affected: rows.length, slices_applied: units.length });
} finally {
@ -1086,7 +960,7 @@ module.exports = function(pool) {
try {
const ctx = await getContext(parseInt(req.params.id), 'clone');
if (!guardOpen(ctx.version, res)) return;
assertSelective(slices, ctx, req.body.scope);
assertSelective(slices, ctx);
const scaleFactor = (scale != null) ? parseFloat(scale) : 1.0;
const dateOffset = (date_offset || '0 days').trim() || '0 days';
@ -1127,7 +1001,6 @@ module.exports = function(pool) {
const setClause = buildSetClause(ctx.dimCols, set, { derivedExprs, alias: 's' });
const units = sliceUnits(slices, ctx, apply_mode === 'each' ? 'each' : 'prorate', req.body.scope, req);
const names = adjustmentNames(ctx.version);
const client = await pool.connect();
let committed = false;
try {
@ -1142,8 +1015,6 @@ module.exports = function(pool) {
version_id: ctx.version.id,
pf_user: esc(pf_user || ''),
note: esc(note || ''),
label: esc(names.pf_segment),
bucket: esc(names.pf_bucket),
params: esc(JSON.stringify({
slices: unit.slices, set, scale: scaleFactor, apply_mode: unit.mode,
date_offset: dateOffset,
@ -1161,7 +1032,7 @@ module.exports = function(pool) {
for (const r of result.rows) {
if (r.pf_logid != null) sqlByLogId.set(r.pf_logid, sql);
}
for (const r of result.rows) allRows.push(r);
allRows.push(...result.rows);
}
await client.query('COMMIT');
committed = true;
@ -1173,6 +1044,7 @@ module.exports = function(pool) {
});
}
const opLabel = (req.body.tag || '').trim() || note || null;
const names = adjustmentNames(ctx.version);
const rows = allRows.map(r => ({ ...r, ...names, pf_note: opLabel, pf_op: 'clone' }));
res.json({ rows, rows_affected: rows.length, slices_applied: units.length });
} finally {

View File

@ -7,7 +7,7 @@ const { Pool, types } = require('pg');
const { requireAuth } = require('./lib/auth');
// Return bigint (oid 20) and numeric (oid 1700) as JS numbers instead of strings,
// so apache-arrow encodes them as Int/Float64 rather than Dictionary<Utf8>.
// so apache-arrow's tableFromJSON infers Int/Float64 rather than Dictionary<Utf8>.
types.setTypeParser(20, v => v === null ? null : Number(v));
types.setTypeParser(1700, v => v === null ? null : Number(v));

View File

@ -122,54 +122,6 @@ ALTER TABLE pf.version ADD COLUMN IF NOT EXISTS adjustment_segment text;
ALTER TABLE pf.version ADD COLUMN IF NOT EXISTS adjustment_bucket text;
ALTER TABLE pf.version ADD COLUMN IF NOT EXISTS unlabeled_load text;
-- Every log entry is named. label is the row's segment and bucket what it counts
-- toward, both stored rather than derived, so a report reads them off pf.log with
-- no rule of its own. The routes stamp them on write: a load's label is required
-- and its bucket defaults to the label; an adjustment gets the version's
-- adjustment_segment / adjustment_bucket, else the built-ins in sql_generator.
--
-- The backfill names entries written before that, with the rule the pivot used to
-- apply at read time, so nothing already on screen changes name. It is the last
-- reader of pf.version.unlabeled_load; the column remains.
WITH
-- the read-time rule, verbatim
named AS (
SELECT
l.id
,CASE
WHEN l.operation IN ('baseline', 'reference')
THEN COALESCE(NULLIF(l.label, ''), NULLIF(l.tag, ''), NULLIF(l.note, ''), NULLIF(v.unlabeled_load, ''), 'Unlabeled')
ELSE COALESCE(NULLIF(l.label, ''), NULLIF(v.adjustment_segment, ''), '99 - Adjustments')
END AS label
,l.operation
,l.bucket
,v.adjustment_bucket
FROM
pf.log l
LEFT JOIN pf.version v
ON v.id = l.version_id
WHERE TRUE
AND (NULLIF(l.label, '') IS NULL OR NULLIF(l.bucket, '') IS NULL)
)
UPDATE pf.log l
SET
label = n.label
,bucket = COALESCE(
NULLIF(n.bucket, '')
,CASE
WHEN n.operation IN ('baseline', 'reference') THEN n.label
ELSE COALESCE(NULLIF(n.adjustment_bucket, ''), '04 - Forecast')
END
)
FROM
named n
WHERE TRUE
AND l.id = n.id;
ALTER TABLE pf.log DROP CONSTRAINT IF EXISTS log_named;
ALTER TABLE pf.log ADD CONSTRAINT log_named
CHECK (NULLIF(label, '') IS NOT NULL AND NULLIF(bucket, '') IS NOT NULL);
-- Where an account lands when it has no saved selection of its own.
--
-- Without it the app fell back to the newest version, which is whatever

16
ui/README.md Normal file
View File

@ -0,0 +1,16 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.

View File

@ -15,15 +15,9 @@ import { useState, useEffect, useRef, useCallback } from 'react'
const UP = '#2a78d6' // increase
const DOWN = '#e34948' // decrease
const ANCHOR = '#6b7280' // baseline / current — neutral, 4.83:1 on white
// Everything that is not a bar follows the theme. These were hex literals, so in
// dark mode the value labels stayed #374151 on a #2a2c2f panel and could not be
// read. Applied through `style` rather than the presentation attribute, where
// var() is not reliable across browsers.
const GRID = 'var(--border-color)'
const AXIS = 'var(--chart-axis)'
const INK = 'var(--text-secondary)'
const INK_DIM = 'var(--chart-ink-dim)'
const PANEL = 'var(--bg-secondary)' // the bars' separating stroke
const GRID = '#e5e7eb'
const INK = '#374151'
const INK_DIM = '#6b7280'
const fmt = (n, dp = 2) =>
n == null || !isFinite(n) ? '—'
@ -129,11 +123,9 @@ export function buildSteps(rows, {
if (isLoad) { loads.value += v; loads.units += u; loads.rows += 1; continue }
const meta = logMeta[r.pf_logid] || {}
// The tag alone. This used to be label first, to match pf_segment, but every
// adjustment now carries a label -- the version's adjustment segment, the
// same one for all of them -- so the walk collapsed into a single bar and
// no tag was ever shown. The label is the segment; the tag is the initiative.
const tag = (meta.tag || '').trim()
// label first, the same precedence pf_segment uses, so the bridge and the
// pivot call a step by the same name
const tag = (meta.label || meta.tag || '').trim()
// Untagged entries collapse into one step, the same as the ledger's
// "N untagged adjustments" line in OperationPanel.jsx -- one bar per
// untagged scale/recode would otherwise turn a routine cleanup pass into
@ -302,10 +294,6 @@ export default function BridgeView({
const [hover, setHover] = useState(null)
const [width, setWidth] = useState(880)
const boxRef = useRef(null)
// Where the panel has been dragged to, as an offset from centre. It opens
// centred each time rather than wherever it was last left.
const [pos, setPos] = useState({ x: 0, y: 0 })
const dragRef = useRef(null)
// Build the steps: baseline anchor, one floating step per tag, current anchor.
const compute = useCallback(async () => {
@ -417,36 +405,6 @@ export default function BridgeView({
useEffect(() => { try { localStorage.setItem('pf_bridge_basis', basis) } catch {} }, [basis])
useEffect(() => { if (open) compute() }, [open, compute])
useEffect(() => { if (open) setPos({ x: 0, y: 0 }) }, [open])
// Escape closes it. There is no backdrop to click any more -- see below.
useEffect(() => {
if (!open) return
const onKey = (e) => { if (e.key === 'Escape') onClose() }
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [open, onClose])
// Drag by the header. Pointer capture keeps the drag alive when the cursor
// outruns the panel; the close button is excluded so clicking it still closes.
const startDrag = (e) => {
if (e.button !== 0 || e.target.closest('button')) return
e.currentTarget.setPointerCapture(e.pointerId)
dragRef.current = { px: e.clientX, py: e.clientY, x: pos.x, y: pos.y }
}
const moveDrag = (e) => {
const d = dragRef.current
if (!d) return
// keep the header on screen, or there is nothing left to drag it back by
const maxX = window.innerWidth / 2 - 40
const maxY = window.innerHeight / 2 - 20
setPos({
x: Math.max(-maxX, Math.min(maxX, d.x + e.clientX - d.px)),
y: Math.max(-maxY, Math.min(maxY, d.y + e.clientY - d.py)),
})
}
const endDrag = () => { dragRef.current = null }
useEffect(() => {
if (!open || !boxRef.current) return
const ro = new ResizeObserver(([e]) => setWidth(Math.max(420, e.contentRect.width)))
@ -463,18 +421,11 @@ export default function BridgeView({
const ticks = niceTicks(yMin, yMax, 5)
return (
// Not modal. The overlay passes clicks through, so the pivot stays usable
// with the bridge open -- moving it aside to read the grid under it, or
// changing the selection it is scoped to, which recomputes it. It used to
// be a dimmed backdrop that closed on click, which made the panel immovable
// and the pivot unreachable until it was dismissed.
<div className="fixed inset-0 z-50 flex items-center justify-center pointer-events-none">
<div className="pointer-events-auto bg-white rounded-lg shadow-2xl border border-gray-200 w-full max-w-5xl mx-4 flex flex-col max-h-[88vh]"
style={{ transform: `translate(${pos.x}px, ${pos.y}px)` }}>
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
<div className="bg-white rounded-lg shadow-xl w-full max-w-5xl mx-4 flex flex-col max-h-[88vh]"
onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-200 shrink-0 cursor-move select-none touch-none"
onPointerDown={startDrag} onPointerMove={moveDrag}
onPointerUp={endDrag} onPointerCancel={endDrag}>
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-200 shrink-0">
<div className="flex items-baseline gap-2">
<span className="font-medium text-gray-700 text-sm">Bridge</span>
{versionName && <span className="text-gray-600 text-xs">{versionName}</span>}
@ -561,8 +512,8 @@ export default function BridgeView({
{ticks.map(t => (
<g key={t}>
<line x1={PAD.l} x2={PAD.l + plotW} y1={y(t)} y2={y(t)}
style={{ stroke: t === 0 ? AXIS : GRID }} strokeWidth={t === 0 ? 1.5 : 1} />
<text x={PAD.l - 8} y={y(t) + 3} textAnchor="end" fontSize="10" style={{ fill: INK_DIM }}>
stroke={t === 0 ? '#d1d5db' : GRID} strokeWidth={t === 0 ? 1.5 : 1} />
<text x={PAD.l - 8} y={y(t) + 3} textAnchor="end" fontSize="10" fill={INK_DIM}>
{fmtAxis(t)}
</text>
</g>
@ -586,19 +537,19 @@ export default function BridgeView({
{/* connector to the next bar, drawn behind */}
{i < steps.length - 1 && (
<line x1={x + barW} x2={xOf(i + 1)} y1={y(s.end)} y2={y(s.end)}
style={{ stroke: AXIS }} strokeWidth="1" strokeDasharray="2 2" />
stroke="#cbd5e1" strokeWidth="1" strokeDasharray="2 2" />
)}
{/* hit target larger than the mark */}
<rect x={x - 6} y={PAD.t} width={barW + 12} height={plotH} fill="transparent" />
<rect x={x} y={top} width={barW} height={h} rx="4" fill={fill}
opacity={on ? 1 : 0.92}
style={{ stroke: PANEL }} strokeWidth="2" />
stroke="#ffffff" strokeWidth="2" />
{/* direct label: few bars, so every one is labelled */}
<text x={x + barW / 2} y={top - 6} textAnchor="middle" fontSize="10"
style={{ fill: INK }} fontWeight="500">
fill={INK} fontWeight="500">
{isAnchor ? fmt(s.end, 0) : fmtSigned(s.delta, 0)}
</text>
<text x={x + barW / 2} y={PAD.t + plotH + 16} textAnchor="middle" fontSize="10" style={{ fill: INK }}>
<text x={x + barW / 2} y={PAD.t + plotH + 16} textAnchor="middle" fontSize="10" fill={INK}>
{labelLines.map((ln, li) => (
<tspan key={li} x={x + barW / 2} dy={li === 0 ? 0 : 11}>{ln}</tspan>
))}
@ -607,7 +558,7 @@ export default function BridgeView({
untagged bucket -- its label already reads "N untagged
adjustments", so a ×N here would just repeat the count. */}
{!isAnchor && s.tagged && s.entries > 1 && (
<text x={x + barW / 2} y={subY} textAnchor="middle" fontSize="9" style={{ fill: INK_DIM }}>
<text x={x + barW / 2} y={subY} textAnchor="middle" fontSize="9" fill={INK_DIM}>
×{s.entries}
</text>
)}

View File

@ -11,7 +11,7 @@ import { useEffect, useRef, useState } from 'react'
// A stepper is three controls whatever the depth, and says where you are rather
// than making you find it. The full list is still one click away, for jumping
// rather than stepping.
export default function DepthButtons({ label, levels, depth, onPick, totalLabel = 'Total', children }) {
export default function DepthButtons({ label, levels, depth, onPick, totalLabel = 'Total' }) {
const [open, setOpen] = useState(false)
const boxRef = useRef(null)
@ -35,44 +35,29 @@ export default function DepthButtons({ label, levels, depth, onPick, totalLabel
const known = depth != null
const current = !known ? '—' : (depth === 0 ? totalLabel : levels[depth - 1])
const step = (d) => onPick(Math.min(max, Math.max(0, d)))
// Capped so one long field name cannot push the rest of the toolbar off;
// past the cap the label truncates, with the full name in the title.
const widest = Math.min(18, Math.max(...[totalLabel, ...levels].map(l => String(l).length)))
// One joined control, in the style of the operation panel's
// Scale | Recode | Clone. The arrows used to be separate outlined boxes a
// few pixels wide, which in dark mode were barely visible and looked the
// same enabled or disabled; joined, the axis reads as one object and the
// arrows are halves of it rather than specks beside it.
const seg = 'px-1.5 py-0.5 leading-none bg-white transition-colors ' +
'text-gray-600 hover:bg-gray-100 disabled:text-gray-300 disabled:hover:bg-white disabled:cursor-default'
const arrow = 'border border-gray-200 rounded px-1 py-0.5 text-gray-500 leading-none ' +
'hover:border-gray-400 disabled:opacity-30 disabled:hover:border-gray-200 transition-colors'
return (
<div ref={boxRef} className="relative flex items-center gap-1.5">
<span className="pf-section-label">{label}</span>
<div ref={boxRef} className="relative flex items-center gap-1">
<span className="pf-section-label mr-0.5">{label}</span>
<div className="inline-flex rounded border border-gray-300 divide-x divide-gray-300 overflow-hidden">
<button className={seg} disabled={!known || depth <= 0}
title={`Collapse ${label.toLowerCase()} by one level`}
onClick={() => step((depth ?? 0) - 1)}>◂</button>
<button className={arrow} disabled={!known || depth <= 0}
title={`Collapse ${label.toLowerCase()} by one level`}
onClick={() => step((depth ?? 0) - 1)}>◂</button>
{/* Sized to the axis's longest level, not the current one. Sized to
its content it resized on every step, so the ▸ moved out from
under the cursor between clicks. */}
<button onClick={() => setOpen(o => !o)}
title={`${label}: showing ${current}`}
style={{ width: `calc(${widest}ch + 1rem)` }}
className={`px-2 py-0.5 truncate text-left transition-colors
${open ? 'bg-blue-50 text-blue-700' : 'bg-white text-gray-700 hover:bg-gray-100'}`}>
{current}
</button>
<button onClick={() => setOpen(o => !o)}
title={`${label}: showing ${current}`}
className={`border rounded px-2 py-0.5 max-w-[10rem] truncate transition-colors
${open ? 'border-blue-300 text-blue-700 bg-blue-50'
: 'border-gray-200 text-gray-600 hover:border-gray-400'}`}>
{current}
</button>
<button className={seg} disabled={!known || depth >= max}
title={`Expand ${label.toLowerCase()} by one level`}
onClick={() => step((depth ?? 0) + 1)}>▸</button>
</div>
{children}
<button className={arrow} disabled={!known || depth >= max}
title={`Expand ${label.toLowerCase()} by one level`}
onClick={() => step((depth ?? 0) + 1)}>▸</button>
{open && (
<div className="absolute top-full left-0 mt-1 z-50 min-w-[10rem] bg-white border
@ -92,17 +77,3 @@ export default function DepthButtons({ label, levels, depth, onPick, totalLabel
</div>
)
}
// An on/off control that looks pressed when on — the same state the toolbar's
// Operations button uses for "open". Replaces the native checkboxes, which
// drew in the light style on the dark theme and were the brightest thing on
// the toolbar.
export function ToggleChip({ on, onChange, title, children }) {
return (
<button type="button" aria-pressed={on} title={title} onClick={() => onChange(!on)}
className={`border rounded px-2 py-0.5 transition-colors ${
on ? 'pf-pressed' : 'border-gray-300 bg-white text-gray-500 hover:bg-gray-100'}`}>
{children}
</button>
)
}

View File

@ -119,26 +119,19 @@ export default function LayoutMenu({
<span className="pf-section-label">Layout</span>
<button onClick={() => setOpen(o => !o)}
className={`flex items-center gap-1 border rounded px-2 py-0.5 max-w-[22rem] transition-colors
className={`flex items-center gap-1 border rounded px-2 py-0.5 max-w-[14rem] transition-colors
${open ? 'border-blue-300 text-blue-700 bg-blue-50'
: 'border-gray-200 text-gray-600 hover:border-gray-400'}`}>
<span className="truncate">{active ? active.name : 'Unsaved'}</span>
{/* Always rendered, hidden when clean, so the button does not widen
by a dot the moment the pivot changes. */}
<span title={dirty ? 'Changed since it was saved' : undefined}
className={`text-amber-500 leading-none ${dirty ? '' : 'invisible'}`}>•</span>
{dirty && <span title="Changed since it was saved" className="text-amber-500 leading-none">•</span>}
<span className="text-gray-400 leading-none">▾</span>
</button>
{/* Save is the common action and stays outside the menu, but only when
there is something to save it to and it is yours to overwrite. Its
slot is held while the layout is clean: appearing and vanishing, it
shoved the Rows and Columns controls sideways under the cursor. */}
{active?.can_edit && (
<button onClick={() => onSave(active)} disabled={!dirty}
aria-hidden={!dirty} tabIndex={dirty ? 0 : -1}
className={`border border-blue-200 text-blue-600 hover:text-blue-800 rounded px-2 py-0.5
${dirty ? '' : 'invisible'}`}>
there is something to save it to and it is yours to overwrite. */}
{active?.can_edit && dirty && (
<button onClick={() => onSave(active)}
className="border border-blue-200 text-blue-600 hover:text-blue-800 rounded px-2 py-0.5">
Save
</button>
)}

View File

@ -39,7 +39,7 @@ function dispVal(v) {
function sliceLabel(s) {
const entries = Object.entries(s)
return entries.length ? entries.map(([k, v]) => `${k}=${dispVal(v)}`).join(' · ') : 'Total'
return entries.length ? entries.map(([k, v]) => `${k}=${dispVal(v)}`).join(' · ') : '—'
}
// A light grouping with an optional caption — a rule between blocks in the
@ -75,15 +75,11 @@ function TextButton({ onClick, children, title, danger }) {
)
}
// `locked` is not `disabled`: the button reads at full strength -- it is
// reporting something true -- but does not take a click.
function Button({ onClick, active, children, title, disabled, locked }) {
function Button({ onClick, active, children, title, disabled }) {
return (
<button onClick={onClick} title={title} disabled={disabled || locked}
<button onClick={onClick} title={title} disabled={disabled}
className={`px-3 py-1 text-xs font-medium whitespace-nowrap transition-colors ${
locked
? (active ? 'bg-blue-600 text-white cursor-default' : 'bg-white text-gray-700 cursor-default')
: disabled
disabled
? (active ? 'bg-gray-300 text-white cursor-default' : 'bg-white text-gray-400 cursor-default')
: (active ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-100')}`}>
{children}
@ -196,16 +192,13 @@ function SectionLabel({ children, className = '' }) {
// `disabled` keeps the control in place and shows which way it is set rather
// than removing it. A control that vanishes reads as broken; one that greys out
// says the choice has been made elsewhere.
// `lit`, when given, is the list of options to show active -- for a control
// that reports state, where more than one can be true at once -- in place of
// the single `value`.
function Segmented({ options, value, onChange, disabled, lit, locked }) {
function Segmented({ options, value, onChange, disabled }) {
return (
<div className={`inline-flex rounded border divide-x shadow-sm overflow-hidden w-auto self-start ${
disabled ? 'border-gray-200 divide-gray-200 bg-gray-50' : 'border-gray-300 divide-gray-300'}`}>
{options.map(([val, label, title]) => (
<Button key={val} onClick={() => onChange(val)} active={lit ? lit.includes(val) : value === val}
title={title} disabled={disabled} locked={locked}>{label}</Button>
<Button key={val} onClick={() => onChange(val)} active={value === val}
title={title} disabled={disabled}>{label}</Button>
))}
</div>
)
@ -282,10 +275,6 @@ function SelectionList({ slices, viewScope = [], distinctSlices, currentTotals,
? <span className="font-mono text-gray-700">{sliceLabel(s)}</span>
: (
<div className="flex flex-col gap-0.5">
{/* a total cell: everything the pivot's filter shows */}
{!Object.keys(s).length && (
<span className="font-medium text-gray-700">Total{scopeLine ? ' within the filter' : ''}</span>
)}
{Object.entries(s).map(([k, v]) => (
<div key={k} className="whitespace-nowrap">
<span className="text-gray-600">{k}</span>
@ -569,43 +558,7 @@ function TagInput({ value, onChange, tags = [], className }) {
)
}
// The three scale boxes foot -- price = value / qty -- so at most two of them
// can be inputs, and the third is whatever those two make it. Typing into the
// third makes it an input and one of the other two has to give way:
//
// value typed last -> the plug decides, since a dollar change is the one
// edit that says nothing about price or volume:
// 'volume' (price holds) drops the qty, 'price' (qty
// holds) drops the price
// qty or price last -> the more recently touched of the other two stays
//
// The dropped box keeps what was typed in scaleInputs -- it is only left out
// here -- so flipping the plug brings it straight back. Forecast.jsx builds
// the request from the same function, so the panel and the write agree.
// Returns the inputs that count, and whether the plug is the one choosing.
const round2 = (x) => Math.round(x * 100) / 100
const hasNumber = (edit) => edit?.raw != null && edit.raw !== '' && isFinite(parseFloat(edit.raw))
export function effectiveScaleInputs(inputs = {}, plug = 'volume') {
const keys = ['value', 'units', 'price'].filter(k => hasNumber(inputs[k]))
if (keys.length < 3) return { inputs, choosing: false }
const byAge = [...keys].sort((a, b) => (inputs[a].at ?? 0) - (inputs[b].at ?? 0))
const last = byAge[2]
const drop = last === 'value'
? (plug === 'price' ? 'price' : 'units')
: byAge.find(k => k !== last)
const { [drop]: _drop, ...rest } = inputs
return { inputs: rest, choosing: last === 'value' }
}
// a stamp that orders the edits, so the resolver can tell which came last
let editSeq = 0
const stamp = () => ++editSeq
function ScaleLedger({ currentTotals, scaleInputs: rawInputs, setScaleInputs, scaleExpectedRef, scalePlug, setScalePlug, targetBasis, setTargetBasis, logMeta = {}, multi, applyMode, settingsExtra }) {
// everything below reads the inputs that count; only the setters touch the raw ones
const { inputs: scaleInputs, choosing: plugChoosing } = effectiveScaleInputs(rawInputs, scalePlug)
function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, setScalePlug, targetBasis, setTargetBasis, logMeta = {}, multi, applyMode, settingsExtra }) {
const valueCol = currentTotals?.valueCol
const unitsCol = currentTotals?.unitsCol
const total = currentTotals?.total || { value: 0, units: 0 }
@ -629,10 +582,7 @@ function ScaleLedger({ currentTotals, scaleInputs: rawInputs, setScaleInputs, sc
baseline.push({ ...e, label: name || 'Baseline', kind: 'baseline' })
continue
}
// The tag alone, not the label. Every adjustment is now stamped with a
// label -- the version's adjustment segment -- so label-first put all of
// them under that one name and no tag was ever reached.
const tag = (meta.tag || '').trim()
const tag = (meta.label || meta.tag || '').trim()
if (tag) {
// Grouped case-insensitively. "Keystone reduction" and "Keystone
// Reduction" are one initiative typed twice, and splitting them here
@ -758,7 +708,10 @@ function ScaleLedger({ currentTotals, scaleInputs: rawInputs, setScaleInputs, sc
// an edit is dollars-only when value carries a number and neither units nor
// price does -- the one case where price and volume are both still unknown
const filled = (key) => hasNumber(scaleInputs[key])
const filled = (key) => {
const raw = scaleInputs[key]?.raw
return raw != null && raw !== '' && isFinite(parseFloat(raw))
}
const dollarsOnly = filled('value') && !filled('units') && !filled('price')
// Where the edit lands, for all three measures at once. The rows above each
@ -777,12 +730,7 @@ function ScaleLedger({ currentTotals, scaleInputs: rawInputs, setScaleInputs, sc
let newValue, newUnits
if (filled('price') && filled('value')) {
// price and dollars named: qty falls out of the two (resolveIncrs)
const targetPrice = numOf('price', 'new')
newValue = numOf('value', 'new')
newUnits = (targetPrice > 0 && newValue != null) ? newValue / targetPrice : null
} else if (filled('price') && !filled('value')) {
if (filled('price') && !filled('value')) {
// price is the input; dollars fall out of price x volume
const targetPrice = numOf('price', 'new')
newUnits = filled('units') ? numOf('units', 'new') : curUnits
@ -793,19 +741,15 @@ function ScaleLedger({ currentTotals, scaleInputs: rawInputs, setScaleInputs, sc
// 10% more cases at the same revenue is a 9% price cut nobody asked for.
// Pinning the dollars as well is how a price move is asked for, and it
// takes the branch below instead.
//
// The price held is the adjustable rows' own (`total`), not the blend with
// the fixed segments -- the write prorates over the adjustable rows only,
// and the server resolves it the same way (resolveIncrs).
newUnits = numOf('units', 'new')
newValue = (total.units === 0 || newUnits == null)
newValue = (curUnits === 0 || newUnits == null)
? curValue
: curValue + total.value * ((newUnits - curUnits) / total.units)
: curValue * (newUnits / curUnits)
} else {
newValue = filled('value') ? numOf('value', 'new') : curValue
if (filled('units')) newUnits = numOf('units', 'new')
else if (dollarsOnly && scalePlug === 'volume') newUnits = total.value === 0 ? null
: curUnits + total.units * ((newValue - curValue) / total.value)
else if (dollarsOnly && scalePlug === 'volume') newUnits = curValue === 0 ? null
: curUnits * (newValue / curValue)
else newUnits = curUnits
}
@ -824,20 +768,6 @@ function ScaleLedger({ currentTotals, scaleInputs: rawInputs, setScaleInputs, sc
}
})()
// The change the Result line promises, handed to the request as `expected`.
// Measured on the same basis as the ledger, so it compares directly with
// `resolved` in the log entry. Under 'each' it is the combined figure across
// the slices, which is what the panel shows -- not a per-slice amount.
if (scaleExpectedRef) {
scaleExpectedRef.current = outcome ? {
value_incr: round2(outcome.value - (basisOf('value') ?? 0)),
units_incr: round2(outcome.units - (basisOf('units') ?? 0)),
price: outcome.price == null ? null : Math.round(outcome.price * 1e6) / 1e6,
basis: onTotal ? 'selected' : 'adjustable',
...(perSlice ? { combined_across_slices: true } : {}),
} : null
}
// The same three readings for a measure nobody typed into, taken from where
// the edit actually lands it. Scaling dollars with price holding moves qty,
// and leaving that column blank until the Result row meant the number you had
@ -863,7 +793,7 @@ function ScaleLedger({ currentTotals, scaleInputs: rawInputs, setScaleInputs, sc
]))
const setEdit = (key, field, raw) =>
setScaleInputs(prev => ({ ...prev, [key]: { field, raw, at: prev[key]?.field === field ? (prev[key].at ?? stamp()) : stamp() } }))
setScaleInputs(prev => ({ ...prev, [key]: { field, raw } }))
// Hand a measure back to being derived. Emptying the box does the same thing
// -- filled() is false either way -- but that is only obvious once you know
@ -880,16 +810,14 @@ function ScaleLedger({ currentTotals, scaleInputs: rawInputs, setScaleInputs, sc
// carrying across whatever it currently reads
const focusRow = (key, field) =>
setScaleInputs(prev => {
// compared against the raw inputs: a box the resolver dropped still
// holds its text, and focusing it is how it is brought back as an input
const cur = prev[key]
if (cur && cur.field === field && scaleInputs[key]) return prev
if (cur && cur.field === field) return prev
// Whatever the box reads is what you get, including a back-filled
// number: clicking into an implied figure is how you pin it, which is
// the difference between "units may move" and "units are this".
const text = shown[key]?.[field] ?? ''
const raw = text === '—' ? '' : String(text).replace(/,/g, '')
return { ...prev, [key]: { field, raw, at: stamp() } }
return { ...prev, [key]: { field, raw } }
})
// A running total across the walk, in the primary measure. The lines sum to
@ -939,20 +867,10 @@ function ScaleLedger({ currentTotals, scaleInputs: rawInputs, setScaleInputs, sc
const plugState = (() => {
if (!(valueCol && unitsCol)) return null
const u = filled('units'), p = filled('price'), v = filled('value')
if (plugChoosing) return {
value: scalePlug,
live: true,
why: scalePlug === 'price'
? `${valueCol} typed last — ${unitsCol} holds, price gives way`
: `${valueCol} typed last — price holds, ${unitsCol} gives way`,
}
if (u && p) return { value: null, live: false, why: `${unitsCol} and price named — ${valueCol} follows` }
if (u && v) return { value: null, live: false, why: `${valueCol} and ${unitsCol} named — price follows` }
if (u) return { value: null, live: false, why: `price holds — ${valueCol} follows ${unitsCol}`, unpin: 'units' }
// a price on its own is an edit-price: qty holds and the dollars follow it,
// which is the Result line's reading and what the server writes
if (p && !v) return { value: 'price', live: false, why: `${unitsCol} holds — ${valueCol} follows price`, unpin: 'price' }
if (p) return { value: 'volume', live: false, why: `${valueCol} and price named — ${unitsCol} follows`, unpin: 'price' }
if (p) return { value: 'volume', live: false, why: `price pinned — ${unitsCol} absorbs it`, unpin: 'price' }
if (!v) return { value: null, live: false, why: `for a ${valueCol} figure on its own` }
return {
value: scalePlug,
@ -965,17 +883,6 @@ function ScaleLedger({ currentTotals, scaleInputs: rawInputs, setScaleInputs, sc
const numCell = 'text-right font-mono tabular-nums whitespace-nowrap px-2'
const rule = <td className="p-0"><div className="border-t border-gray-300 my-1" /></td>
// The line the edit rows are measured from. It used to be marked by weight
// alone, and the Total line's running figure was bold whichever basis was
// chosen, so either way one of the two rows had bold in it and neither read
// as the target. Tinted, tagged, and clickable -- the Target toggle sits in
// the other column, away from the rows it chooses between.
const basisRow = (hot) => (hot
? 'bg-blue-50 text-blue-700 font-semibold'
: 'text-gray-500') + (hasExcl ? ' cursor-pointer' : '')
const targetTag = (
<span className="ml-1.5 text-[9px] font-semibold uppercase tracking-wider text-blue-600">target</span>
)
return (
<div className="min-w-0">
@ -1072,12 +979,9 @@ function ScaleLedger({ currentTotals, scaleInputs: rawInputs, setScaleInputs, sc
<tr>{rule}{measures.map(m => <td key={m.key} className="p-0 px-2"><div className="border-t border-gray-300 my-1" /></td>)}{showRunning && <td className="p-0 px-2"><div className="border-t border-gray-300 my-1" /></td>}</tr>
<tr className={hasExcl ? basisRow(!onTotal) : 'font-semibold text-gray-700'}
onClick={hasExcl ? () => setTargetBasis('adjustable') : undefined}
title={hasExcl ? 'Type your number as the forecast alone' : undefined}>
<tr className={onTotal ? 'text-gray-600' : 'font-semibold text-gray-700'}>
<td className="pr-3 whitespace-nowrap">
{hasExcl ? 'Forecast' : 'Current'}{perSlice ? ' (all)' : ''}
{hasExcl && !onTotal && targetTag}
</td>
{measures.map(m => (
<td key={m.key} className={numCell}>{fmtNum(m.current, m.dp)}</td>
@ -1087,10 +991,8 @@ function ScaleLedger({ currentTotals, scaleInputs: rawInputs, setScaleInputs, sc
{hasExcl && (
<tr className={basisRow(onTotal)}
onClick={() => setTargetBasis('selected')}
title="Type your number as everything selected">
<td className="pr-3 whitespace-nowrap">Total selected{onTotal && targetTag}</td>
<tr className={onTotal ? 'font-semibold text-gray-700' : 'text-gray-600'}>
<td className="pr-3 whitespace-nowrap">Total selected</td>
{measures.map(m => (
<td key={m.key} className={numCell}>
{m.key === 'price'
@ -1099,7 +1001,7 @@ function ScaleLedger({ currentTotals, scaleInputs: rawInputs, setScaleInputs, sc
</td>
))}
{showRunning && (
<td className={numCell}>{fmtNum(grand.value, 0)}</td>
<td className={`${numCell} font-semibold text-gray-700`}>{fmtNum(grand.value, 0)}</td>
)}
</tr>
)}
@ -1183,20 +1085,11 @@ function ScaleLedger({ currentTotals, scaleInputs: rawInputs, setScaleInputs, sc
{settingsExtra}
{plugState && (
<Field label="Moves" hint={plugState.why}>
{/* What moves, not what was picked. Clickable only while it is
the one deciding -- a dollar figure on its own, or typed last
over the other two; otherwise the inputs have settled it and
it lights whatever they move, both at once if both do. It
used to show a single "selected" option even then, greyed,
which read as a choice somebody had made: price and dollars
named lit Volume, though price was moving too. */}
<Field label="Absorbs" hint={plugState.why}>
<Segmented
lit={plugState.live
? [scalePlug]
: [outcome?.moved?.price && 'price', outcome?.moved?.units && 'volume'].filter(Boolean)}
value={plugState.value}
onChange={setScalePlug}
locked={!plugState.live}
disabled={!plugState.live}
options={[
['price', 'Price', `${unitsCol} holds; price moves to reach the number`],
['volume', 'Volume', `Price holds; ${unitsCol} scales with the dollars`],
@ -1480,14 +1373,13 @@ export default function OperationPanel({
applyMode, setApplyMode,
currentTotals,
activeOp, setActiveOp,
scaleInputs, setScaleInputs, scaleExpectedRef,
scaleInputs, setScaleInputs,
scalePlug, setScalePlug,
targetBasis, setTargetBasis,
opTag, setOpTag, knownTags = [], logMeta = {},
scaleNote, setScaleNote,
recodeSet, setRecodeSet,
recodeNote, setRecodeNote,
recodeOffset, setRecodeOffset,
cloneSet, setCloneSet,
cloneScale, setCloneScale,
cloneFrom, setCloneFrom, cloneOffset, setCloneOffset, cloneSources,
@ -1500,8 +1392,7 @@ export default function OperationPanel({
const horizontal = dock === 'bottom'
const note = activeOp === 'scale' ? scaleNote : activeOp === 'recode' ? recodeNote : cloneNote
const isShift = off => !!off && off.trim() !== '' && off.trim() !== '0 days'
const shifting = isShift(cloneOffset)
const shifting = !!cloneOffset && cloneOffset.trim() !== '' && cloneOffset.trim() !== '0 days'
const setNote = activeOp === 'scale' ? setScaleNote : activeOp === 'recode' ? setRecodeNote : setCloneNote
// Slices are confirmation, not the work. The list used to hold a whole column
@ -1536,7 +1427,7 @@ export default function OperationPanel({
{activeOp === 'scale' && (
<ScaleLedger
currentTotals={currentTotals}
scaleInputs={scaleInputs} setScaleInputs={setScaleInputs} scaleExpectedRef={scaleExpectedRef}
scaleInputs={scaleInputs} setScaleInputs={setScaleInputs}
scalePlug={scalePlug} setScalePlug={setScalePlug}
targetBasis={targetBasis} setTargetBasis={setTargetBasis}
logMeta={logMeta}
@ -1548,26 +1439,7 @@ export default function OperationPanel({
<DimForm dimCols={dimCols} setObj={recodeSet} setSet={setRecodeSet}
slices={slices} lookupDerivedCols={lookupDerivedCols} dimMembers={dimMembers} versionId={versionId}
extra={
<div className="flex flex-col gap-2 pt-1 border-t border-gray-100 mt-1">
{/* The shift applies to the rows put back, not the ones taken
out, so a recode with a shift is a timing move: select the
period the business is leaving and the shift lands it in the
one it is going to. Nothing else set is fine. */}
<Field label="Shift dates"
hint={isShift(recodeOffset)
? 'moves the rows — select the period they leave'
: undefined}>
<input value={recodeOffset} list="pf-recode-offsets"
onChange={e => setRecodeOffset(e.target.value)}
placeholder="0 days" className={`${TEXT} w-28`} />
<datalist id="pf-recode-offsets">
<option value="-1 month" />
<option value="1 month" />
<option value="-3 months" />
<option value="3 months" />
<option value="12 months" />
</datalist>
</Field>
<div className="pt-1 border-t border-gray-100 mt-1">
<MovingTotal currentTotals={currentTotals} verb="Moving" />
</div>
} />

View File

@ -11,8 +11,6 @@
--border-light: #f3f4f6;
--accent-bg: #eff6ff;
--accent-text: #1d4ed8;
--chart-ink-dim: #6b7280;
--chart-axis: #d1d5db;
}
/* Dark palette tuned to Perspective's "Pro Dark" theme:
@ -29,11 +27,6 @@
--border-light: #3b3f46;
--accent-bg: rgba(39, 113, 170, 0.32);
--accent-text: #4778c2;
--chart-ink-dim: #9ca3af;
--chart-axis: #61656e;
/* Native controls — checkboxes, scrollbars, date pickers — draw in the dark
style. Without it every checkbox in the app was a white square. */
color-scheme: dark;
}
body { margin: 0; background-color: var(--bg-primary); color: var(--text-primary); }
@ -41,10 +34,6 @@ body { margin: 0; background-color: var(--bg-primary); color: var(--text-primary
.dark .bg-white { background-color: var(--bg-secondary); }
.dark .bg-gray-50 { background-color: var(--bg-tertiary); }
/* The opacity variant is its own class and the rule above does not reach it, so
without this the operation panel's header band stayed light grey in dark mode.
--bg-tertiary at the same 60%. */
.dark .bg-gray-50\/60 { background-color: rgb(59 63 70 / 0.6); }
.dark .bg-gray-100 { background-color: var(--bg-tertiary); }
.dark .bg-gray-200 { background-color: var(--bg-tertiary); }
.dark .bg-gray-300 { background-color: var(--bg-tertiary); }
@ -79,30 +68,6 @@ body { margin: 0; background-color: var(--bg-primary); color: var(--text-primary
}
.dark .pf-group-rule { background: #5a616b; }
/* A toggle that is on — the Total chips, and Operations while its panel is
open. Pressed grey rather than blue: blue is Save and Post, actions, and
these are states. Its own class so the dark values are defined once rather
than hoping each utility has a dark override. */
.pf-pressed {
background-color: #e5e7eb;
border-color: #9ca3af;
color: #1f2937;
box-shadow: inset 0 1px 2px rgb(0 0 0 / 0.08);
}
.dark .pf-pressed {
background-color: var(--bg-tertiary);
border-color: #61656e;
color: var(--text-primary);
box-shadow: inset 0 1px 2px rgb(0 0 0 / 0.3);
}
/* The joined controls — the depth steppers and the operation panel's
Scale | Recode | Clone — draw their inner rules with divide-*, which had no
dark override and stayed light grey. */
.dark .divide-gray-300 > :not(:last-child) { border-color: var(--border-color); }
.dark .disabled\:text-gray-300:disabled { color: var(--text-muted); }
.dark .disabled\:hover\:bg-white:disabled:hover { background-color: var(--bg-secondary); }
.dark .text-gray-300 { color: var(--text-muted); }
.dark .text-gray-400 { color: var(--text-muted); }
.dark .text-gray-500 { color: var(--text-muted); }

View File

@ -176,12 +176,6 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
async function saveLogField(entry, field, value) {
const next = value.trim()
if (next === (entry[field] || '')) return
// every log entry carries both; the server refuses a blank one
if (!next) {
flash(`A segment's ${field} can be renamed but not cleared`, 'error')
setBuckets(b => { const { [entry.id]: _, ...rest } = b; return rest })
return
}
try {
const res = await fetch(`/api/log/${entry.id}`, {
method: 'PATCH',
@ -190,7 +184,7 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
})
if (!res.ok) { const d = await res.json(); flash(d.error, 'error'); return }
loadLog()
flash(`Saved — reload the Forecast view to see it`)
flash(next ? `Saved — reload the Forecast view to see it` : 'Cleared')
} catch (err) { flash(err.message, 'error') }
}
@ -427,7 +421,6 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
async function loadSegment() {
const clause = useRaw ? rawSql.trim() : buildFilterClause(filters)
if (!clause) { flash(useRaw ? 'Enter a WHERE clause' : 'Add at least one filter', 'error'); return }
if (!segLabel.trim()) { flash('Give the segment a label', 'error'); return }
const isRef = segType === 'reference'
const offsetStr = offset.trim() || '0 days'
const endpoint = isRef ? 'reference' : 'baseline'
@ -683,19 +676,17 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
</div>
)}
{/* Adjustment defaults. A scale, recode or clone has no label or bucket
input, so it is stamped with these when it is written. Written onto
the log entry, not read back through it: changing one names the next
adjustment and leaves the existing ones as they are. Blank falls back
to the built-in in sql_generator's ADJUSTMENT DEFAULTS block. */}
{/* Fallback names. Not part of any segment -- they are what the pivot shows
for rows nobody has named, so they belong to the version rather than to
a log entry. Blank falls back to the built-in in sql_generator's
DISPLAY DEFAULTS block. */}
{versionId && (
<div className="bg-white border border-gray-200 rounded p-3 flex items-end gap-3 flex-wrap">
<span className="text-xs text-gray-500 uppercase tracking-wide w-full">
Defaults for new adjustments <span className="normal-case tracking-normal text-gray-400">— existing adjustments keep the names they were written with</span>
</span>
<span className="text-xs text-gray-500 uppercase tracking-wide w-full">Fallback names</span>
{[
['adjustment_segment', 'Adjustment segment', '99 - Adjustments'],
['adjustment_bucket', 'Adjustment bucket', '04 - Forecast'],
['unlabeled_load', 'Unlabeled load', 'Unlabeled'],
].map(([field, label, builtin]) => (
<div key={field} className="flex flex-col gap-1">
<label className="text-xs text-gray-500">{label}</label>
@ -970,14 +961,10 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
<input
defaultValue={entry.label || ''}
key={`label-${entry.id}-${entry.label || ''}`}
onBlur={e => {
const typed = e.target.value
if (!typed.trim()) e.target.value = entry.label || ''
saveLogField(entry, 'label', typed)
}}
onBlur={e => saveLogField(entry, 'label', e.target.value)}
readOnly={!canEdit(entry)}
title={canEdit(entry) ? '' : `${entry.pf_user || 'Another account'} made this segment`}
placeholder="—"
placeholder={entry.tag || entry.note || '—'}
className="w-full border border-transparent hover:border-gray-200
focus:border-blue-400 rounded px-1 py-0.5 text-xs
focus:outline-none bg-transparent" />
@ -1333,12 +1320,12 @@ function SegmentForm({
<div className="flex flex-col gap-1 max-w-xs">
<label className="text-xs text-gray-500">Label</label>
<input disabled={disabled} value={segLabel} onChange={e => setSegLabel(e.target.value)}
placeholder="required" className={`${baseInp} text-sm py-1.5`} />
placeholder="defaults to the note" className={`${baseInp} text-sm py-1.5`} />
</div>
<div className="flex flex-col gap-1 max-w-xs">
<label className="text-xs text-gray-500">Counts toward</label>
<input disabled={disabled} value={segBucket} onChange={e => setSegBucket(e.target.value)}
list="pf-bucket-options" placeholder="defaults to the label"
list="pf-bucket-options" placeholder="e.g. 02 - Forecast"
className={`${baseInp} text-sm py-1.5`} />
</div>
<div className="flex flex-col gap-1 flex-1 max-w-xs">

View File

@ -1,9 +1,9 @@
import { useState, useEffect, useRef } from 'react'
import useTheme from '../theme.jsx'
import LayoutMenu from '../components/LayoutMenu.jsx'
import DepthButtons, { ToggleChip } from '../components/DepthButtons.jsx'
import DepthButtons from '../components/DepthButtons.jsx'
import useAuth from '../auth.jsx'
import OperationPanel, { effectiveScaleInputs } from '../components/OperationPanel.jsx'
import OperationPanel from '../components/OperationPanel.jsx'
import BridgeView from '../components/BridgeView.jsx'
// Perspective is bundled, not fetched at runtime. The /inline entrypoints embed the
@ -15,17 +15,10 @@ import '@perspective-dev/viewer/inline'
import '@perspective-dev/viewer-datagrid'
import '@perspective-dev/viewer/themes'
// Slice keys that are not col_meta columns but are columns of the loaded table,
// so a click on them narrows the ledger -- and therefore has to narrow the write.
// pf_segment / pf_bucket / pf_note / pf_op come from pf.log and are resolved back
// to log ids server-side; pf_iter / pf_user / pf_logid are forecast-table columns.
// Mirrors COMPUTED_SLICE_COLS and TABLE_SLICE_COLS in lib/sql_generator.js.
//
// Only pf_segment and pf_bucket used to be kept. The rest were stripped, while
// the ledger still filtered on them: a click on a customer's `scale` row under
// the default layout (grouped by pf_iter) totalled the scale rows and wrote to
// every row the customer has.
const COMPUTED_SLICE_COLS = new Set(['pf_segment', 'pf_bucket', 'pf_note', 'pf_op', 'pf_iter', 'pf_user', 'pf_logid'])
// Slice keys that are not col_meta columns: computed from pf.log when the rows are
// served, so they are real columns in the loaded table but have to be resolved back
// to log ids server-side. Mirrors COMPUTED_SLICE_COLS in lib/sql_generator.js.
const COMPUTED_SLICE_COLS = new Set(['pf_segment', 'pf_bucket'])
// The unnamed last-used config stays in localStorage: it is per-browser session
// continuity, not a thing anyone names or shares. LAYOUTS_KEY is the old named
@ -43,9 +36,6 @@ 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)) {
@ -86,20 +76,6 @@ 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()
@ -163,11 +139,6 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
// you type in decides how the number is interpreted, and the other two rows of
// that measure are derived from it.
const [scaleInputs, setScaleInputs] = useState({})
// What the panel's Result line says the scale will change, written there on
// each render and sent as `expected` so the log keeps it next to what the
// server resolved. Nothing checks the two against each other yet; it is
// there so a disagreement can be found by reading the log.
const scaleExpectedRef = useRef(null)
// which side of price x volume absorbs a dollar-only change: 'price' keeps
// volume flat, 'volume' keeps price flat. Mirrors the Excel form's
// Plug Price / Plug Volume choice.
@ -193,9 +164,6 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
const [scaleNote, setScaleNote] = useState('')
const [recodeSet, setRecodeSet] = useState({})
const [recodeNote, setRecodeNote] = useState('')
// blank, unlike clone's: a recode is a rename first, and a remembered shift
// would quietly move the next one in time as well
const [recodeOffset, setRecodeOffset] = useState('')
const [cloneSet, setCloneSet] = useState({})
const [cloneScale, setCloneScale] = useState('1')
const [cloneNote, setCloneNote] = useState('')
@ -209,12 +177,6 @@ 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 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(() => {
if (!panelOpen) return
@ -317,9 +279,10 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
// Mirrors ViewConfig.group_by_depth for the toolbar's active state. The
// config is the source of truth; this only exists so the buttons can render.
const [expandDepth, setExpandDepth] = useState(null)
// The column hierarchy as the live config has it, for the Columns stepper.
// Collapsing is split_by_depth now, so split_by itself is never truncated.
// The column axis has no set_depth() — collapsing it means restoring a shorter
// split_by, so the full hierarchy has to be remembered separately to expand again.
const splitFullRef = useRef([])
// a collapse from the user rearranging split_by themselves
const initIdRef = useRef(0)
const modifierRef = useRef(false)
// the datagrid plugin element, for reading cell coordinates and driving its
@ -403,11 +366,12 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
// attaches to a click. Row dimensions come from the view's __ROW_PATH__ (raw values,
// so dates stay epoch millis rather than whatever the grid formatted them as), and
// column dimensions from the split_by segments of the column name.
async function slicesFromRegion(win) {
async function slicesFromRegion(win, { quiet = false } = {}) {
const viewer = viewerRef.current
if (!viewer) return []
const cfg = await viewer.save()
const view = await viewer.getView()
const groupBy = cfg.group_by || []
const base = cfg.filter || []
const rows = await view.to_json({ start_row: win.start_row, end_row: win.end_row })
if (!rows.length) return []
@ -415,20 +379,33 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
const c0 = win.start_col ?? 0
const c1 = win.end_col ?? userKeys.length
const out = []
// cells whose column-axis value is blank, which cannot be isolated (see
// cellFilters) -- counted so the drag can say why it skipped them once,
// rather than per cell
let blankCols = 0
for (let i = 0; i < rows.length; i++) {
const path = rows[i].__ROW_PATH__ || []
// the grand-total row resolves to no dimension at all, which would mean
// "the whole version" — never what dragging over it is asking for
if (groupBy.length && !path.length) continue
for (let c = c0; c < c1; c++) {
const key = userKeys[c]
if (!key) continue
const { rowFilters, colFilters } = cellFilters(cfg, rows[i], key)
const { rowFilters, colFilters, colBlank } = cellFilters(cfg, rows[i], key)
if (colBlank) { blankCols++; continue }
if (groupBy.length && !rowFilters.length) continue
const slice = sliceFromFilters([...base, ...rowFilters, ...colFilters],
(cfg.columns || []).filter(Boolean))
// a total cell counts when its column or the pivot's filter narrows it;
// one nothing narrows would mean the whole version (see perspective-click)
if (!Object.keys(slice).length && !base.length) continue
if (!Object.keys(slice).length) continue
const y = win.start_row + i
out.push({ slice, area: { x0: c, x1: c, y0: y, y1: y } })
}
}
// Quiet for a relocate, which sweeps the whole grid rather than a region the
// user dragged: the blanks it passes over are not cells anyone asked for.
if (blankCols && !quiet) {
flash(`${blankCols} cell${blankCols > 1 ? 's' : ''} skipped: a blank column-axis value cannot be isolated`, 'error')
}
return out
}
@ -482,7 +459,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
// any ordinary depth it is a handful. Fully expanded it is the whole
// traversal, and a slow redraw of a highlight is not worth having.
if (!n || n > MAX_RELOCATE_ROWS) { areasRef.current.clear(); paintAreas(); return }
const found = await slicesFromRegion({ start_row: 0, end_row: n })
const found = await slicesFromRegion({ start_row: 0, end_row: n }, { quiet: true })
const live = new Set(slicesRef.current.map(sliceKey))
const next = new Map()
for (const { slice, area } of found) {
@ -609,12 +586,9 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
...Object.entries(sliceObj)
.filter(([col]) => COMPUTED_SLICE_COLS.has(col) || dimNames.has(col) || dateNames.has(col)
|| schema[col] !== undefined)
// Only an `==` on the same value is a repeat. Any other filter on the
// column is wider than the cell -- `partd contains 'Keystone'` with one
// part clicked -- and dropping the cell's own predicate for it totalled
// every part the filter admits. Perspective ANDs two on one column.
.filter(([col, val]) => !viewFilter.some(f =>
f[0] === col && f[1] === '==' && val !== null && String(f[2]) === String(val)))
// a cell inside the filtered view cannot contradict it, so a repeated
// column is the same predicate twice and harmless
.filter(([col]) => !viewFilter.some(f => f[0] === col))
// A null is filtered with `is null`, not `== null`: the engine matches
// on type, and a null threshold against a string column is the same
// dropped predicate that widened the slice in the first place. The
@ -1025,12 +999,9 @@ 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 parsed = JSON.parse(saved)
adoptTotals(totalsOf(parsed))
const { table: _t, ...rest } = cleanLayout(parsed, validCols)
const { table: _t, ...rest } = cleanLayout(JSON.parse(saved), 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
@ -1053,7 +1024,6 @@ 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' } }
@ -1073,7 +1043,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 captureConfig(), defaultRow.id)
if (defaultRow) markClean(await viewer.save(), defaultRow.id)
}
// Its own try: the restore above has one, but this sits outside it, and a
@ -1128,17 +1098,17 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
const measures = (config.columns || []).filter(Boolean)
let s
if (cell) {
const { rowFilters, colFilters } = cellFilters(config, detail.row, colKey)
const { rowFilters, colFilters, colBlank } = cellFilters(config, detail.row, colKey)
if (colBlank) {
flash('A blank column-axis value cannot be isolated — filter on it instead', 'error')
return
}
if (!rowFilters.length) return
s = sliceFromFilters([...(config.filter || []), ...rowFilters, ...colFilters], measures)
} else {
s = sliceFromFilters((detail.config || {}).filter || [], measures)
}
// A total cell is its displayed amount like any other: the grand-total
// row under a column is that column's values, and inside a filtered
// pivot it is what the filter leaves -- which travels as `scope`, not in
// the slice. Only a cell nothing narrows at all is refused, since that
// would be the whole version.
if (!Object.keys(s).length && !(config.filter || []).length) return
if (!Object.keys(s).length) return
// the CustomEvent carries no modifier flags, so read them off the
// mousedown that produced it (captured on window below)
const additive = modifierRef.current
@ -1260,33 +1230,11 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
style.textContent = `
td.pf-group-start { border-left: 2px solid currentColor; opacity: 1; }
td.pf-subtotal { font-weight: 600; background: color-mix(in srgb, currentColor 7%, transparent); }
tr.pf-total-row { display: none; }
`
;(root.head || root).appendChild(style)
}
table.addStyleListener(() => {
// The grand-total row, hidden on request. The engine has no rollup mode
// for "subtotals but no grand total" -- rollup, flat and total are the
// three -- so it is dropped here instead. It is view row 0, whose row
// header the datagrid fills with a label object reading "TOTAL" and
// blanks below it -- not blank throughout, which is what this first
// assumed. y alone is not enough, since under flat rollup there is no
// total and row 0 is a leaf. Tagged per draw, because the grid recycles
// its tr elements as it scrolls.
for (const tr of table.querySelectorAll('tbody tr')) {
const th = tr.querySelector('th')
let meta
try { meta = th ? table.getMeta(th) : null } catch { meta = null }
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 && !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
// the levels below it, which is what makes a subtotal a subtotal.
@ -1305,15 +1253,6 @@ 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')
@ -1321,7 +1260,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) { if (ruled) td.classList.add('pf-group-start'); prevGroup = group }
if (group !== prevGroup) { td.classList.add('pf-group-start'); prevGroup = group }
if (named.length < depth) td.classList.add('pf-subtotal')
}
})
@ -1514,10 +1453,8 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
white-space: nowrap !important;
}
/* 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) {
th.psp-split-total:not(.psp-split-subtotal),
td.psp-split-total:not(.psp-split-subtotal) {
visibility: hidden !important;
width: 0 !important;
min-width: 0 !important;
@ -1658,40 +1595,10 @@ 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(). 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)
}
// 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
}
async function persistLayout(vid, cfg) {
@ -1768,7 +1675,6 @@ 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,
@ -1802,7 +1708,6 @@ 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 })
}
@ -1817,10 +1722,8 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
// equality: a view filtered to sseas_e <= 2027 has no slice form at all.
const body = buildPayload(op)
if (!body) return
// an empty slice is a total cell, and means "everything the pivot's filter
// shows" -- so it needs that filter, or it would be the whole version
if (!body.scope && body.slices.some(sl => !Object.keys(sl).length)) {
flash('That total covers the whole forecast — filter the pivot or pick a narrower cell', 'error'); return
if (body.slices.some(sl => !Object.keys(sl).length)) {
flash('No dimension or date columns in slice — check col_meta', 'error'); return
}
if (op === 'scale') {
const has = ['target_value','target_units','target_price',
@ -1828,13 +1731,13 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
.some(k => body[k] !== undefined)
if (!has) { flash('Enter a target or increment', 'error'); return }
}
// Recode with nothing set and no shift would rewrite rows as themselves. Clone would not:
// Recode with nothing set would rewrite rows as themselves. Clone would not:
// copying a segment forward in time changes the dates and the period
// dimensions, which is the whole point of cloning from prior year, so it needs
// no dimension override at all. It only needs to be doing *something* --
// an override, a shift, or a factor.
if (op === 'recode' && !Object.keys(body.set || {}).length && !body.date_offset) {
flash('Enter at least one new dimension value, or a date shift', 'error')
if (op === 'recode' && !Object.keys(body.set || {}).length) {
flash('Enter at least one new dimension value', 'error')
return
}
// A clone that changes nothing is only pointless when the source and the
@ -1886,7 +1789,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
// The rows just moved under the highlight — see relocateAreas.
relocateAreas()
if (op === 'scale') { setScaleInputs({}); setScaleNote('') }
if (op === 'recode') { setRecodeNote(''); setRecodeOffset('') }
if (op === 'recode') { setRecodeNote('') }
if (op === 'clone') { setCloneNote(''); setCloneScale('1') }
} catch (err) {
flash(err.message, 'error')
@ -1976,7 +1879,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
const viewFilter = viewFilterRef.current || []
if (!slices.length) return null
// Two clicked cells can differ only by a column the operation cannot filter on
// (a computed split_by column, say, which is dropped here). Those become
// (pf_iter, say, which is not in col_meta and so is dropped here). Those become
// the same effective slice, and sending it twice would apply the change twice
// under apply_mode 'each'. Collapse duplicates before they reach the API.
const seen = new Set()
@ -2012,10 +1915,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
return (raw !== '' && raw != null && isFinite(n)) ? n : null
}
// the same two inputs the panel shows -- with all three boxes typed, the
// one the resolver dropped is derived there and must not be sent here
const eff = effectiveScaleInputs(scaleInputs, scalePlug).inputs
const v = eff.value, u = eff.units, p = eff.price
const v = scaleInputs.value, u = scaleInputs.units, p = scaleInputs.price
const vn = v && num(v.raw), un = u && num(u.raw), pn = p && num(p.raw)
if (vn != null) {
@ -2040,12 +1940,9 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
// sent when dollars are the sole input; naming units or price has already
// answered the question.
if (vn != null && un == null && pn == null) body.plug = scalePlug
if (scaleExpectedRef.current) body.expected = scaleExpectedRef.current
} else if (op === 'recode') {
const set = Object.fromEntries(Object.entries(recodeSet).filter(([, v]) => v.trim()))
body = { ...body, note: recodeNote || undefined, set }
const off = recodeOffset.trim()
if (off && off !== '0 days') body.date_offset = off
} else if (op === 'clone') {
const set = Object.fromEntries(Object.entries(cloneSet).filter(([, v]) => v.trim()))
body = { ...body, note: cloneNote || undefined, set, scale: parseFloat(cloneScale) || 1 }
@ -2166,14 +2063,13 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
applyMode, setApplyMode,
currentTotals,
activeOp, setActiveOp,
scaleInputs, setScaleInputs, scaleExpectedRef,
scaleInputs, setScaleInputs,
scalePlug, setScalePlug,
targetBasis, setTargetBasis,
opTag, setOpTag, knownTags, logMeta,
scaleNote, setScaleNote,
recodeSet, setRecodeSet,
recodeNote, setRecodeNote,
recodeOffset, setRecodeOffset,
cloneSet, setCloneSet,
cloneScale, setCloneScale,
cloneFrom, setCloneFrom, cloneOffset, setCloneOffset, cloneSources,
@ -2199,11 +2095,13 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
{/* ── View: everything here changes how the loaded rows are displayed.
Nothing in this zone talks to the server or alters the forecast,
which is why it sits apart from the zone on the right. The
distance between them is the separation: each zone used to be an
outlined box as well, the view one on a gray-50 tint, and the two
boxes read as siblings while the tint was too faint to see. ── */}
<div className="flex items-center gap-4 flex-wrap">
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. ── */}
<div className="flex items-center gap-3 flex-wrap bg-gray-50 border border-gray-200
rounded-md px-2.5 py-1">
<LayoutMenu
layouts={layouts}
@ -2229,12 +2127,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
<>
<div className="pf-group-rule" />
<DepthButtons label="Rows" levels={groupFull} depth={expandDepth}
onPick={applyDepth} totalLabel="Total">
<ToggleChip on={totals.row} onChange={on => toggleTotal('row', on)}
title="Show the grand-total row at the top of the pivot">
Total
</ToggleChip>
</DepthButtons>
onPick={applyDepth} totalLabel="Total" />
</>
)}
@ -2242,12 +2135,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
<>
<div className="pf-group-rule" />
<DepthButtons label="Columns" levels={splitFull} depth={splitDepth}
onPick={applySplitDepth} totalLabel="Total">
<ToggleChip on={totals.col} onChange={on => toggleTotal('col', on)}
title="Show the grand-total column across every column group">
Total
</ToggleChip>
</DepthButtons>
onPick={applySplitDepth} totalLabel="Total" />
</>
)}
@ -2256,7 +2144,8 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
{/* ── Forecast: reaches the server, or opens something over the pivot.
Refresh discards the current view's state and re-runs the load;
Operations is where the version is actually written to. ── */}
<div className="flex items-center gap-1.5 flex-wrap ml-auto">
<div className="flex items-center gap-1.5 flex-wrap ml-auto bg-white border border-gray-200
rounded-md px-2.5 py-1">
<button onClick={() => initViewer(versionId, sourceId, layouts)} disabled={loading || !versionId}
title="Re-run the load from the database"
className="border border-gray-200 rounded px-2 py-0.5 text-gray-500 hover:bg-gray-50 disabled:opacity-40">
@ -2272,14 +2161,10 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
Bridge
</button>
<button onClick={() => setPanelOpen(o => !o)} disabled={!versionId}
aria-pressed={panelOpen}
title={panelOpen ? 'Hide the operations panel (Esc)' : 'Show the operations panel'}
className={`border rounded px-2 py-0.5 disabled:opacity-40 transition-colors ${
panelOpen ? 'pf-pressed' : 'border-gray-200 text-gray-500 hover:bg-gray-50'}`}>
{/* Open is shown as pressed, not by relabelling to "Hide panel" in
blue — blue outline is Save's colour, an action, and this is a
state. */}
Operations
panelOpen ? 'border-blue-300 text-blue-600 bg-blue-50' : 'border-gray-200 text-gray-500 hover:bg-gray-50'}`}>
{panelOpen ? 'Hide panel' : 'Operations'}
{!panelOpen && hasSlice && (
<span className="ml-1 text-blue-500">({slices.length})</span>
)}
@ -2699,18 +2584,11 @@ function sliceFromFilters(filters, measures = []) {
// a dimension whose value is 0 goes the same way. The row path is the raw truth
// and carries the blank, so the slice is built from that instead.
//
// A blank on the *column* axis arrives as text, and the two blanks arrive as
// different text: a column name is its values joined with '|', each rendered by
// t_tscalar::to_string, which gives the empty string for '' and the literal
// "null" for a null (scalar.cpp -- an invalid scalar returns {"null"} before the
// type switch). So "null" is read back as null and '' as ''. Both used to be
// mishandled: '' was refused as unrecoverable, on the belief that a null joined
// as '' too, and "null" was taken at its word -- a slice of segment_new =
// 'null', which matches no row, so the adjustment wrote nothing.
//
// The one thing this cannot tell apart is a null from a dimension value that is
// really the four-letter text 'null'. None exists in this data; one would be
// read as null.
// A blank on the *column* axis cannot be recovered at all: a column name is its
// values joined with '|', and a null joins as the empty string, so the two are
// the same text by the time it reaches us. Those cells are refused rather than
// widened -- `colBlank` says so -- since a slice missing a split_by level means
// every value of it.
function cellFilters(cfg, row, colKey) {
const groupBy = cfg.group_by || []
const splitBy = cfg.split_by || []
@ -2719,6 +2597,7 @@ function cellFilters(cfg, row, colKey) {
.map((col, ix) => (ix < path.length && path[ix] !== undefined ? [col, '==', path[ix]] : null))
.filter(Boolean)
let colFilters = []
let colBlank = false
if (colKey != null) {
// A column name is dimension values joined by | with the measure last, and
// only as many values as the axis currently shows -- collapse the column
@ -2736,11 +2615,12 @@ function cellFilters(cfg, row, colKey) {
if (v === undefined) return null
// the measure name read positionally as a dimension value
if (META_COL_RE.test(v)) return null
return [col, '==', v === 'null' ? null : v]
if (v === '') { colBlank = true; return null }
return [col, '==', v]
})
.filter(Boolean)
}
return { rowFilters, colFilters }
return { rowFilters, colFilters, colBlank }
}
// the user keys of a to_json row, in the order the grid lays its columns out
@ -2781,10 +2661,7 @@ function fmtSliceLines(slice) {
const one = (sl) => Object.entries(sl)
.map(([k, v]) => `${k} = ${fmtSliceValue(v)}`)
.join(' · ')
// an empty slice is a total cell: everything the pivot's filter showed
const TOTAL = 'Total within the pivot filter'
if (!Array.isArray(slice)) return [Object.keys(slice).length ? one(slice) : TOTAL]
if (slice.some(sl => sl && !Object.keys(sl).length)) return [TOTAL]
if (!Array.isArray(slice)) return Object.keys(slice).length ? [one(slice)] : []
const list = slice.filter(sl => sl && Object.keys(sl).length)
if (list.length <= 1) return list.map(one)

20
ui/vendor/README.md vendored
View File

@ -142,23 +142,15 @@ ctx2->set_depth(t_header::HEADER_ROW, row_pivot_depth - 1); // two-sided
The config field counts **levels to show**; `view.set_depth()` counts the
boundary below them. So `group_by_depth: n` equals `set_depth(n - 1)`, and
`t_traversal::set_depth` adds the 1 back, so `n` renders `n` levels.
`Forecast.jsx` therefore sends the stepper's level count unchanged, floored at
1 — `0` would reach the engine as `set_depth(-1)` on an unsigned depth,
underflow, and clamp to fully expanded. (It used to send `d + 1` to dodge that,
which was off by one against the grid everywhere.)
`Forecast.jsx` sends `d + 1`.
The patch is already committed on the fork's build branch (see
`PROVENANCE.txt`), so rebuilding needs only the script. The `.patch` file here
is the change on its own, for review or for carrying to a clean upstream
checkout:
To apply:
```bash
./ui/vendor/rebuild-perspective.sh # from the fork branch
# then re-resolve by path, as above -- a plain `npm install` keeps the old code
# only on a fresh upstream checkout:
cd $PSP_DIR && git apply /path/to/pf_app/ui/vendor/0001-apply-depth-fields-on-config-update.patch
cd $PSP_DIR # default ~/perspective
git apply /path/to/pf_app/ui/vendor/0001-apply-depth-fields-on-config-update.patch
./ui/vendor/rebuild-perspective.sh
cd ui && npm install
```
### Host tools this tree needs