Compare commits
No commits in common. "master" and "op-panel-layout" have entirely different histories.
master
...
op-panel-l
362
PERSPECTIVE.md
Normal file
362
PERSPECTIVE.md
Normal 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`).
|
||||||
@ -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.
|
|
||||||
1589
docs/pf_spec.md
1589
docs/pf_spec.md
File diff suppressed because it is too large
Load Diff
82
install.sh
Executable file
82
install.sh
Executable 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 ""
|
||||||
@ -14,58 +14,93 @@
|
|||||||
|
|
||||||
// What the pivot shows for a row's segment and its bucket.
|
// 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
|
// 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
|
// 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.
|
// 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
|
// These are the values a row falls back to when nobody has named it. They are
|
||||||
// log entry when it is written: the version's own adjustment_segment /
|
// the complete list: if a segment or bucket appears in the pivot under a name
|
||||||
// adjustment_bucket when set, else the constants below. Resolved once, at write
|
// that is not in pf.log, it came from here. CLAUDE.md has the same list under
|
||||||
// time -- changing a version's default afterwards names new adjustments, not old
|
// "Hardcoded display names".
|
||||||
// ones. Exported so the operation routes stamp the rows they push back to the
|
|
||||||
// pivot with the same values they wrote.
|
|
||||||
//
|
//
|
||||||
// A load has no default: its label is required, and its bucket is its label
|
// They live on pf.version -- adjustment_segment, adjustment_bucket,
|
||||||
// unless one is given (the routes enforce both).
|
// 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
|
// 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
|
// 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';
|
const ADJUSTMENT_SEGMENT = '99 - Adjustments';
|
||||||
|
|
||||||
// What an adjustment counts toward. Prefixed to match the segments it adjusts,
|
// 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.
|
// 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 ADJUSTMENT_BUCKET = '04 - Forecast';
|
||||||
|
|
||||||
const SEGMENT_EXPR = `l.label`;
|
// A load nobody named. No prefix, so it sorts after everything numbered --
|
||||||
const BUCKET_EXPR = `l.bucket`;
|
// 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')
|
const NOTE_EXPR = `CASE WHEN l.operation IN ('baseline','reference')
|
||||||
THEN NULL
|
THEN NULL
|
||||||
ELSE COALESCE(NULLIF(l.tag, ''), NULLIF(l.note, ''))
|
ELSE COALESCE(NULLIF(l.tag, ''), NULLIF(l.note, ''))
|
||||||
END`;
|
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
|
// are functionally dependent on pf_logid, which is in the grain, but Postgres
|
||||||
// will not infer that.
|
// 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
|
// wrap a column name in double quotes for safe use in SQL
|
||||||
function q(name) { return `"${name}"`; }
|
function q(name) { return `"${name}"`; }
|
||||||
@ -284,7 +319,7 @@ SELECT
|
|||||||
,l.operation AS pf_op
|
,l.operation AS pf_op
|
||||||
FROM {{fc_table}} t
|
FROM {{fc_table}} t
|
||||||
LEFT JOIN pf.log l
|
LEFT JOIN pf.log l
|
||||||
ON l.id = t.pf_logid
|
ON l.id = t.pf_logid${VERSION_JOIN}
|
||||||
WHERE {{territory_clause}}
|
WHERE {{territory_clause}}
|
||||||
GROUP BY
|
GROUP BY
|
||||||
${grain.groupCols('t.').join('\n ,')}
|
${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 `
|
return `
|
||||||
WITH
|
WITH
|
||||||
ilog AS (
|
ilog AS (
|
||||||
INSERT INTO pf.log (version_id, pf_user, operation, slice, params, note, label, bucket)
|
INSERT INTO pf.log (version_id, pf_user, operation, slice, params, note)
|
||||||
VALUES ({{version_id}}, '{{pf_user}}', 'scale', '{{slice}}'::jsonb, '{{params}}'::jsonb, '{{note}}',
|
VALUES ({{version_id}}, '{{pf_user}}', 'scale', '{{slice}}'::jsonb, '{{params}}'::jsonb, '{{note}}')
|
||||||
NULLIF('{{label}}', ''), NULLIF('{{bucket}}', ''))
|
|
||||||
RETURNING id
|
RETURNING id
|
||||||
)
|
)
|
||||||
,base AS (
|
,base AS (
|
||||||
@ -425,9 +459,8 @@ ${opTail('ins')}`.trim();
|
|||||||
return `
|
return `
|
||||||
WITH
|
WITH
|
||||||
ilog AS (
|
ilog AS (
|
||||||
INSERT INTO pf.log (version_id, pf_user, operation, slice, params, note, label, bucket)
|
INSERT INTO pf.log (version_id, pf_user, operation, slice, params, note)
|
||||||
VALUES ({{version_id}}, '{{pf_user}}', 'recode', '{{slice}}'::jsonb, '{{params}}'::jsonb, '{{note}}',
|
VALUES ({{version_id}}, '{{pf_user}}', 'recode', '{{slice}}'::jsonb, '{{params}}'::jsonb, '{{note}}')
|
||||||
NULLIF('{{label}}', ''), NULLIF('{{bucket}}', ''))
|
|
||||||
RETURNING id
|
RETURNING id
|
||||||
)
|
)
|
||||||
,src AS (
|
,src AS (
|
||||||
@ -439,11 +472,6 @@ ilog AS (
|
|||||||
{{exclude_clause}}
|
{{exclude_clause}}
|
||||||
GROUP BY
|
GROUP BY
|
||||||
${groupCols([...dims, ...dateCols])}
|
${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 (
|
,neg AS (
|
||||||
INSERT INTO {{fc_table}} (${insertCols})
|
INSERT INTO {{fc_table}} (${insertCols})
|
||||||
@ -452,14 +480,11 @@ ilog AS (
|
|||||||
FROM src
|
FROM src
|
||||||
RETURNING *
|
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 (
|
,ins AS (
|
||||||
INSERT INTO {{fc_table}} (${insertCols})
|
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()
|
'recode', (SELECT id FROM ilog), '{{pf_user}}', now()
|
||||||
FROM src s${hasDimPeriod ? dimPeriodJoins(dateGroups) : ''}
|
FROM src
|
||||||
RETURNING *
|
RETURNING *
|
||||||
)
|
)
|
||||||
${grain ? `,allrows AS (
|
${grain ? `,allrows AS (
|
||||||
@ -484,9 +509,8 @@ ${opTail('allrows')}` : 'SELECT * FROM neg UNION ALL SELECT * FROM ins'}`.trim()
|
|||||||
return `
|
return `
|
||||||
WITH
|
WITH
|
||||||
ilog AS (
|
ilog AS (
|
||||||
INSERT INTO pf.log (version_id, pf_user, operation, slice, params, note, label, bucket)
|
INSERT INTO pf.log (version_id, pf_user, operation, slice, params, note)
|
||||||
VALUES ({{version_id}}, '{{pf_user}}', 'clone', '{{slice}}'::jsonb, '{{params}}'::jsonb, '{{note}}',
|
VALUES ({{version_id}}, '{{pf_user}}', 'clone', '{{slice}}'::jsonb, '{{params}}'::jsonb, '{{note}}')
|
||||||
NULLIF('{{label}}', ''), NULLIF('{{bucket}}', ''))
|
|
||||||
RETURNING id
|
RETURNING id
|
||||||
)
|
)
|
||||||
,ins AS (
|
,ins AS (
|
||||||
@ -533,33 +557,15 @@ function applyTokens(sql, tokens) {
|
|||||||
// build a SQL WHERE clause string from a slice object
|
// build a SQL WHERE clause string from a slice object
|
||||||
// only dimension columns are included; unrecognised keys are silently skipped
|
// only dimension columns are included; unrecognised keys are silently skipped
|
||||||
// pf_segment and pf_bucket are not columns on the forecast table -- they are
|
// 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
|
// computed at read time from the row's pf.log entry -- so a slice naming one
|
||||||
// directly. It resolves to a set of log ids instead, which is
|
// 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
|
// exact: the name lives on the log row, and every forecast row carries the
|
||||||
// pf_logid that points at it.
|
// pf_logid that points at it.
|
||||||
//
|
//
|
||||||
// Without this they were dropped from the slice, and clicking a single bucket's
|
// 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
|
// cell scaled every bucket at that dimension intersection while the panel showed
|
||||||
// only the one clicked.
|
// only the one clicked.
|
||||||
//
|
const COMPUTED_SLICE_COLS = { pf_segment: SEGMENT_EXPR, pf_bucket: BUCKET_EXPR };
|
||||||
// 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']);
|
|
||||||
|
|
||||||
function computedSlicePredicate(col, val, versionId) {
|
function computedSlicePredicate(col, val, versionId) {
|
||||||
if (versionId == null) {
|
if (versionId == null) {
|
||||||
@ -567,21 +573,12 @@ function computedSlicePredicate(col, val, versionId) {
|
|||||||
err.status = 500;
|
err.status = 500;
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
// a blank is a value here too -- a load has no note -- and `IN (NULL)` is
|
const vals = (Array.isArray(val) ? val : [val]).map(v => `'${esc(v)}'`).join(', ');
|
||||||
// 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;
|
|
||||||
return `pf_logid IN (
|
return `pf_logid IN (
|
||||||
SELECT l.id
|
SELECT l.id
|
||||||
FROM pf.log l
|
FROM pf.log l${VERSION_JOIN}
|
||||||
WHERE l.version_id = ${parseInt(versionId)}
|
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.
|
// A pf_ key this does not understand is refused rather than skipped.
|
||||||
// Skipping is how a selection silently widened: the operation ran against
|
// Skipping is how a selection silently widened: the operation ran against
|
||||||
// everything the dropped key would have excluded.
|
// everything the dropped key would have excluded. pf_iter is the one
|
||||||
if (col.startsWith('pf_') && !TABLE_SLICE_COLS.has(col)) {
|
// 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`);
|
const err = new Error(`Slice names ${col}, which cannot be filtered on`);
|
||||||
err.status = 400;
|
err.status = 400;
|
||||||
throw err;
|
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
|
// 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
|
// 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
|
// 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:East, State:NY} and {Region:West, State:CA} would become
|
||||||
// Region IN (East,West) AND State IN (NY,CA), which also matches East/CA.
|
// Region IN (East,West) AND State IN (NY,CA), which also matches East/CA.
|
||||||
function buildWhereAny(slices, dimCols, versionId) {
|
function buildWhereAny(slices, dimCols, versionId) {
|
||||||
const list = (slices || []).filter(Boolean);
|
const list = (slices || []).filter(s => s && Object.keys(s).length > 0);
|
||||||
// An empty slice is a total cell, and the union with a total is the total.
|
if (list.length === 0) return 'TRUE';
|
||||||
// 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';
|
|
||||||
if (list.length === 1) return buildWhere(list[0], dimCols, versionId);
|
if (list.length === 1) return buildWhere(list[0], dimCols, versionId);
|
||||||
|
|
||||||
const groups = list
|
const groups = list
|
||||||
@ -846,6 +842,6 @@ function esc(val) {
|
|||||||
return String(val).replace(/'/g, "''");
|
return String(val).replace(/'/g, "''");
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { generateSQL, grainOf, COMPUTED_SLICE_COLS, TABLE_SLICE_COLS, buildScopeClause, buildTerritoryClause,
|
module.exports = { generateSQL, grainOf, COMPUTED_SLICE_COLS, buildScopeClause, buildTerritoryClause,
|
||||||
SEGMENT_EXPR, BUCKET_EXPR, NOTE_EXPR, LABEL_GROUP_COLS,
|
SEGMENT_EXPR, BUCKET_EXPR, NOTE_EXPR, LABEL_GROUP_COLS, VERSION_JOIN,
|
||||||
ADJUSTMENT_SEGMENT, ADJUSTMENT_BUCKET, dateGroupsOf, dimPeriodMapOf, dimPeriodJoins, applyTokens, buildWhere, buildWhereAny, buildExcludeClause, buildExcludePredicate, buildSetClause, buildFilterClause, esc };
|
ADJUSTMENT_SEGMENT, ADJUSTMENT_BUCKET, UNLABELED_LOAD, dateGroupsOf, dimPeriodMapOf, dimPeriodJoins, applyTokens, buildWhere, buildWhereAny, buildExcludeClause, buildExcludePredicate, buildSetClause, buildFilterClause, esc };
|
||||||
|
|||||||
10
pf.sh
10
pf.sh
@ -194,15 +194,13 @@ cmd_db_setup() {
|
|||||||
command -v psql &>/dev/null || die "psql not found — install postgresql-client"
|
command -v psql &>/dev/null || die "psql not found — install postgresql-client"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
bold "DB Setup — will run: setup_sql/01_schema.sql, setup_sql/gen_dim_period.sql, setup_sql/02_auth.sql"
|
bold "DB Setup — will run: setup_sql/01_schema.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 "This creates the pf schema, tables, 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)."
|
||||||
warn "Safe to re-run (CREATE IF NOT EXISTS; existing calendar rows are never overwritten)."
|
|
||||||
read -rp " Continue? [y/N] " confirm
|
read -rp " Continue? [y/N] " confirm
|
||||||
[[ "$confirm" =~ ^[Yy]$ ]] || { echo "Aborted."; return; }
|
[[ "$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/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"
|
run_psql -v ON_ERROR_STOP=1 -f "${APP_DIR}/setup_sql/02_auth.sql"
|
||||||
success "Schema applied."
|
success "Schema applied."
|
||||||
|
|
||||||
@ -576,7 +574,7 @@ interactive_menu() {
|
|||||||
echo " 4) restart restart server"
|
echo " 4) restart restart server"
|
||||||
echo " 5) status service + DB + git info"
|
echo " 5) status service + DB + git info"
|
||||||
echo " 6) logs tail journald logs"
|
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 " 8) config set DB connection + app PORT"
|
||||||
echo " 9) install-service create systemd unit file"
|
echo " 9) install-service create systemd unit file"
|
||||||
echo " 10) uninstall-service remove systemd unit file"
|
echo " 10) uninstall-service remove systemd unit file"
|
||||||
|
|||||||
@ -1,17 +1,5 @@
|
|||||||
# Perspective Architecture Options
|
# 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
|
This document weighs how the Forecast view should source data for the Perspective
|
||||||
pivot. The current implementation hits practical limits on initial load
|
pivot. The current implementation hits practical limits on initial load
|
||||||
(~30s for 350k rows × ~55 cols), and growth is expected. Choosing an architecture
|
(~30s for 350k rows × ~55 cols), and growth is expected. Choosing an architecture
|
||||||
1076
pf_spec.md
Normal file
1076
pf_spec.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -217,14 +217,6 @@ module.exports = function(pool) {
|
|||||||
error: 'Nothing to update — send note, tag, bucket and/or label'
|
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 {
|
try {
|
||||||
// Same rule as undo: your own entries, or an admin's. These are
|
// Same rule as undo: your own entries, or an admin's. These are
|
||||||
// annotations, but label and bucket name the pivot's columns for
|
// annotations, but label and bucket name the pivot's columns for
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { tableFromArrays, tableToIPC } = require('apache-arrow');
|
const { tableFromArrays, tableToIPC } = require('apache-arrow');
|
||||||
const { applyTokens, buildWhere, buildWhereAny, COMPUTED_SLICE_COLS, TABLE_SLICE_COLS, buildScopeClause, buildTerritoryClause, buildExcludeClause, buildExcludePredicate, buildSetClause, dateGroupsOf, dimPeriodMapOf, esc,
|
const { applyTokens, buildWhere, buildWhereAny, COMPUTED_SLICE_COLS, buildScopeClause, buildTerritoryClause, buildExcludeClause, buildExcludePredicate, buildSetClause, dateGroupsOf, dimPeriodMapOf, esc,
|
||||||
SEGMENT_EXPR, BUCKET_EXPR, NOTE_EXPR,
|
SEGMENT_EXPR, BUCKET_EXPR, NOTE_EXPR, VERSION_JOIN,
|
||||||
ADJUSTMENT_SEGMENT, ADJUSTMENT_BUCKET } = require('../lib/sql_generator');
|
ADJUSTMENT_SEGMENT, ADJUSTMENT_BUCKET } = require('../lib/sql_generator');
|
||||||
const { sessionUser, sessionTerritory, requireAdmin } = require('../lib/auth');
|
const { sessionUser, sessionTerritory, requireAdmin } = require('../lib/auth');
|
||||||
const { fcTable } = require('../lib/utils');
|
const { fcTable } = require('../lib/utils');
|
||||||
@ -9,11 +9,10 @@ const { fcTable } = require('../lib/utils');
|
|||||||
module.exports = function(pool) {
|
module.exports = function(pool) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// The segment and bucket an adjustment is stamped with: written onto its
|
// the names an adjustment's rows carry until the next load re-reads them
|
||||||
// pf.log entry by the template, and onto the rows pushed back to the pivot,
|
// through VERSION_JOIN. The per-version overrides live on pf.version, so
|
||||||
// so the two agree. The version's own defaults when set, else the
|
// stamping the built-in constants here made every fresh adjustment show as
|
||||||
// built-ins. Resolved at write time, so changing a version's default
|
// '99 - Adjustments' and jump to the version's own name on refresh.
|
||||||
// afterwards names new adjustments and leaves existing ones as they are.
|
|
||||||
function adjustmentNames(version) {
|
function adjustmentNames(version) {
|
||||||
const pick = (v, dflt) => (v || '').trim() || dflt;
|
const pick = (v, dflt) => (v || '').trim() || dflt;
|
||||||
return {
|
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) {
|
async function runSQL(sql, client) {
|
||||||
console.log('--- SQL ---\n', sql, '\n--- END SQL ---');
|
console.log('--- SQL ---\n', sql, '\n--- END SQL ---');
|
||||||
return (client || pool).query(sql);
|
return (client || pool).query(sql);
|
||||||
}
|
}
|
||||||
|
|
||||||
// accept either the legacy single `slice` object or the newer `slices` array.
|
// 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
|
// and drop any empty entries so an empty selection can never widen to TRUE
|
||||||
// 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
|
|
||||||
function normalizeSlices(body) {
|
function normalizeSlices(body) {
|
||||||
const raw = Array.isArray(body.slices) && body.slices.length ? body.slices : [body.slice];
|
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' && Object.keys(s).length > 0);
|
||||||
return raw.filter(s => s && typeof s === 'object' && (scoped || Object.keys(s).length > 0));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stamp the tag onto the log entry the operation just created.
|
// 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
|
// 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'
|
// field it came from. Negative intervals are valid and useful -- '-90 days'
|
||||||
// pulls a plan back a quarter -- so this checks validity, not sign.
|
// 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) {
|
async function assertInterval(value, res) {
|
||||||
const example = `Try something like "30 days", "1 month", "-90 days" or "0 days".`;
|
|
||||||
let whole;
|
|
||||||
try {
|
try {
|
||||||
whole = (await pool.query(
|
await pool.query(`SELECT $1::interval`, [value]);
|
||||||
`SELECT date_trunc('day', $1::interval) = $1::interval AS whole`, [value]
|
return true;
|
||||||
)).rows[0].whole;
|
|
||||||
} catch {
|
} catch {
|
||||||
res.status(400).json({ error: `"${value}" is not a valid interval. ${example}` });
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!whole) {
|
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
error: `"${value}" is not a whole number of days, so it would not move any date `
|
error: `"${value}" is not a valid interval. Try something like `
|
||||||
+ `(a number with no unit is read as seconds). ${example}`
|
+ `"4 months", "1 year", "-90 days" or "0 days".`
|
||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// A slice is only meaningful if at least one of its keys is a filterable
|
// 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
|
// column. buildWhere silently drops unknown keys, so {"typo": "x"} would
|
||||||
// otherwise reduce to TRUE and apply the operation to the whole version.
|
// otherwise reduce to TRUE and apply the operation to the whole version.
|
||||||
// Refuse rather than let a malformed selection rewrite every row.
|
// Refuse rather than let a malformed selection rewrite every row.
|
||||||
// An empty slice passes only under a scope (see normalizeSlices), which
|
function assertSelective(slices, ctx) {
|
||||||
// buildScopeClause refuses to let reduce to nothing.
|
const allowed = new Set([...ctx.filterCols, ...Object.keys(COMPUTED_SLICE_COLS)]);
|
||||||
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;
|
|
||||||
slices.forEach((sl, i) => {
|
slices.forEach((sl, i) => {
|
||||||
if (scoped && Object.keys(sl).length === 0) return;
|
|
||||||
const hits = Object.keys(sl).filter(k => allowed.has(k));
|
const hits = Object.keys(sl).filter(k => allowed.has(k));
|
||||||
if (hits.length === 0) {
|
if (hits.length === 0) {
|
||||||
const err = new Error(
|
const err = new Error(
|
||||||
@ -221,11 +182,7 @@ module.exports = function(pool) {
|
|||||||
// echo back what the caller asked for, for the audit log
|
// echo back what the caller asked for, for the audit log
|
||||||
function pickIntent(body) {
|
function pickIntent(body) {
|
||||||
const keys = ['mode', 'target_basis', 'value_incr', 'units_incr', 'value_pct', 'units_pct', 'pct',
|
const keys = ['mode', 'target_basis', 'value_incr', 'units_incr', 'value_pct', 'units_pct', 'pct',
|
||||||
'target_value', 'target_units', 'target_price', 'scope', 'plug',
|
'target_value', 'target_units', 'target_price', 'scope'];
|
||||||
// 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'];
|
|
||||||
const out = {};
|
const out = {};
|
||||||
for (const k of keys) if (body[k] !== undefined && body[k] !== null && body[k] !== '') out[k] = body[k];
|
for (const k of keys) if (body[k] !== undefined && body[k] !== null && body[k] !== '') out[k] = body[k];
|
||||||
return out;
|
return out;
|
||||||
@ -310,35 +267,15 @@ module.exports = function(pool) {
|
|||||||
let value = resolve(tValue, vPct, vIncr, totals.value, fixedValue);
|
let value = resolve(tValue, vPct, vIncr, totals.value, fixedValue);
|
||||||
let units = resolve(tUnits, uPct, uIncr, totals.units, fixedUnits);
|
let units = resolve(tUnits, uPct, uIncr, totals.units, fixedUnits);
|
||||||
|
|
||||||
// A price target is the "edit price" mode of the Excel form. The price
|
// A price target is the "edit price" mode of the Excel form: price and
|
||||||
// named holds, and whichever other measure was named decides the rest:
|
// volume are the inputs and dollars fall out of them. With a units target
|
||||||
// price alone qty holds, dollars = price x qty
|
// alongside it, both move; without one, volume holds and price alone carries
|
||||||
// price + qty qty lands as named (target, change or %),
|
// the change. An explicit value target outranks it either way.
|
||||||
// dollars = price x that qty
|
if (tPrice !== null && tValue === null) {
|
||||||
// price + dollars dollars land as named, qty = dollars / price
|
const targetUnits = tUnits !== null
|
||||||
// all three refused -- two degrees of freedom, three numbers
|
? (tUnits - fixedUnits) + 0 // the units target is already absolute
|
||||||
// All on the basis the panel measures against, so under 'selected' the
|
: (totals.units + fixedUnits);
|
||||||
// price is that of the whole selection, fixed rows included.
|
value = (tPrice * targetUnits) - (totals.value + fixedValue);
|
||||||
//
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Which side of price x volume absorbs a dollar change.
|
// 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
|
// -- the dollars land on target either way -- so the default is now the
|
||||||
// conservative one and moving price takes saying so.
|
// conservative one and moving price takes saying so.
|
||||||
const plug = body.plug === 'price' ? 'price' : 'volume';
|
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
|
if (plug === 'volume' && value !== 0 && !unitsGiven) {
|
||||||
// fixed ones: the scale SQL prorates both increments over the adjustable
|
const curValue = totals.value + fixedValue;
|
||||||
// rows only, so a units increment sized at the blended price cuts qty
|
const curUnits = totals.units + fixedUnits;
|
||||||
// faster than dollars whenever the fixed rows are priced differently.
|
if (curValue === 0) {
|
||||||
// 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) {
|
|
||||||
const err = new Error(
|
const err = new Error(
|
||||||
'Cannot hold price constant here: the selection currently has no value, ' +
|
'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 ' +
|
'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;
|
err.status = 400; throw err;
|
||||||
}
|
}
|
||||||
// price constant means value and units move by the same proportion:
|
// price constant means value and units move by the same proportion:
|
||||||
// fVol = adjVol * (fVal / adjVal), so the units delta is adjVol * value/adjVal
|
// fVol = curVol * (fVal / curVal), so the units delta is curVol * value/curVal
|
||||||
units = totals.units * (value / totals.value);
|
units = curUnits * (value / curValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The same question asked of a qty input, and the answer has to match:
|
// 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
|
// deliberate forms are still available and both outrank this: name the
|
||||||
// dollars alongside the qty (then price falls out of the two), or name
|
// dollars alongside the qty (then price falls out of the two), or name
|
||||||
// the price outright.
|
// the price outright.
|
||||||
// Again at the adjustable rows' own price, for the reason given above.
|
|
||||||
if (units !== 0 && !valueGiven && tPrice === null) {
|
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
|
// 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
|
// and the edit is a pure qty change -- adding units to a slice that
|
||||||
// has none cannot infer what they are worth
|
// 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
|
// 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.
|
// offset each other is the usual cause, and 'each' handles that correctly.
|
||||||
assertProratable(totals, value, units);
|
assertProratable(totals, value, units);
|
||||||
|
|
||||||
// Which of price or volume the edit moved, written onto the log entry.
|
return { value: round(value, 6), units: round(units, 6) };
|
||||||
// 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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// a pool is proratable only if its net is a meaningful fraction of its gross
|
// 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
|
,${NOTE_EXPR} AS pf_note
|
||||||
FROM ${tbl} t
|
FROM ${tbl} t
|
||||||
LEFT JOIN pf.log l
|
LEFT JOIN pf.log l
|
||||||
ON l.id = t.pf_logid
|
ON l.id = t.pf_logid${VERSION_JOIN}
|
||||||
${terrAlias ? `WHERE ${terrAlias}` : ''}
|
${terrAlias ? `WHERE ${terrAlias}` : ''}
|
||||||
`);
|
`);
|
||||||
|
|
||||||
@ -672,8 +590,6 @@ module.exports = function(pool) {
|
|||||||
const dateOffset = date_offset || '0 days';
|
const dateOffset = date_offset || '0 days';
|
||||||
if (!await assertInterval(dateOffset, res)) return;
|
if (!await assertInterval(dateOffset, res)) return;
|
||||||
const filterClause = (raw_where || where_clause || '').trim() || 'TRUE';
|
const filterClause = (raw_where || where_clause || '').trim() || 'TRUE';
|
||||||
const names = loadNames(label, bucket, res);
|
|
||||||
if (!names) return;
|
|
||||||
try {
|
try {
|
||||||
const ctx = await getContext(parseInt(req.params.id), 'baseline');
|
const ctx = await getContext(parseInt(req.params.id), 'baseline');
|
||||||
if (!guardOpen(ctx.version, res)) return;
|
if (!guardOpen(ctx.version, res)) return;
|
||||||
@ -687,8 +603,8 @@ module.exports = function(pool) {
|
|||||||
version_id: ctx.version.id,
|
version_id: ctx.version.id,
|
||||||
pf_user: esc(pf_user || ''),
|
pf_user: esc(pf_user || ''),
|
||||||
note: esc(note || ''),
|
note: esc(note || ''),
|
||||||
label: esc(names.label),
|
label: esc(label || ''),
|
||||||
bucket: esc(names.bucket),
|
bucket: esc(bucket || ''),
|
||||||
tag: esc(tag || ''),
|
tag: esc(tag || ''),
|
||||||
params: esc(paramsJson),
|
params: esc(paramsJson),
|
||||||
filter_clause: filterClause,
|
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
|
// so tag is always the latter and must survive an edit made for any
|
||||||
// other reason.
|
// other reason.
|
||||||
const keep = (sent, prior) => esc(sent ?? prior ?? '');
|
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, {
|
const sql = applyTokens(ctx.sql, {
|
||||||
fc_table: ctx.table,
|
fc_table: ctx.table,
|
||||||
version_id: ctx.version.id,
|
version_id: ctx.version.id,
|
||||||
pf_user: esc(pf_user || ''),
|
pf_user: esc(pf_user || ''),
|
||||||
note: esc(note || ''),
|
note: esc(note || ''),
|
||||||
label: esc(names.label),
|
label: keep(label, oldLog.label),
|
||||||
bucket: esc(names.bucket),
|
bucket: keep(bucket, oldLog.bucket),
|
||||||
tag: keep(tag, oldLog.tag),
|
tag: keep(tag, oldLog.tag),
|
||||||
params: esc(paramsJson),
|
params: esc(paramsJson),
|
||||||
filter_clause: filterClause,
|
filter_clause: filterClause,
|
||||||
@ -835,8 +749,6 @@ module.exports = function(pool) {
|
|||||||
const pf_user = sessionUser(req);
|
const pf_user = sessionUser(req);
|
||||||
const dateOffset = date_offset || '0 days';
|
const dateOffset = date_offset || '0 days';
|
||||||
const filterClause = (raw_where || where_clause || '').trim() || 'TRUE';
|
const filterClause = (raw_where || where_clause || '').trim() || 'TRUE';
|
||||||
const names = loadNames(label, bucket, res);
|
|
||||||
if (!names) return;
|
|
||||||
try {
|
try {
|
||||||
const ctx = await getContext(parseInt(req.params.id), 'reference');
|
const ctx = await getContext(parseInt(req.params.id), 'reference');
|
||||||
if (!guardOpen(ctx.version, res)) return;
|
if (!guardOpen(ctx.version, res)) return;
|
||||||
@ -850,8 +762,8 @@ module.exports = function(pool) {
|
|||||||
version_id: ctx.version.id,
|
version_id: ctx.version.id,
|
||||||
pf_user: esc(pf_user || ''),
|
pf_user: esc(pf_user || ''),
|
||||||
note: esc(note || ''),
|
note: esc(note || ''),
|
||||||
label: esc(names.label),
|
label: esc(label || ''),
|
||||||
bucket: esc(names.bucket),
|
bucket: esc(bucket || ''),
|
||||||
tag: esc(tag || ''),
|
tag: esc(tag || ''),
|
||||||
params: esc(paramsJson),
|
params: esc(paramsJson),
|
||||||
filter_clause: filterClause,
|
filter_clause: filterClause,
|
||||||
@ -881,7 +793,7 @@ module.exports = function(pool) {
|
|||||||
try {
|
try {
|
||||||
const ctx = await getContext(parseInt(req.params.id), 'scale');
|
const ctx = await getContext(parseInt(req.params.id), 'scale');
|
||||||
if (!guardOpen(ctx.version, res)) return;
|
if (!guardOpen(ctx.version, res)) return;
|
||||||
assertSelective(slices, ctx, req.body.scope);
|
assertSelective(slices, ctx);
|
||||||
|
|
||||||
const excludeClause = buildExcludeClause(ctx.version.exclude_iters);
|
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.
|
// reaches the target on its own and gets its own log entry.
|
||||||
const units = sliceUnits(slices, ctx, applyMode, req.body.scope, req);
|
const units = sliceUnits(slices, ctx, applyMode, req.body.scope, req);
|
||||||
|
|
||||||
const names = adjustmentNames(ctx.version);
|
|
||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
let committed = false;
|
let committed = false;
|
||||||
try {
|
try {
|
||||||
@ -905,7 +816,7 @@ module.exports = function(pool) {
|
|||||||
for (const unit of units) {
|
for (const unit of units) {
|
||||||
const incr = await resolveIncrs(client, ctx, unit.where, excludeClause, req.body);
|
const incr = await resolveIncrs(client, ctx, unit.where, excludeClause, req.body);
|
||||||
// no rows, or already at the target — nothing to write for this slice
|
// 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++;
|
applied++;
|
||||||
|
|
||||||
const loggedSlice = unit.slices.length === 1 ? unit.slices[0] : unit.slices;
|
const loggedSlice = unit.slices.length === 1 ? unit.slices[0] : unit.slices;
|
||||||
@ -914,13 +825,11 @@ module.exports = function(pool) {
|
|||||||
version_id: ctx.version.id,
|
version_id: ctx.version.id,
|
||||||
pf_user: esc(pf_user || ''),
|
pf_user: esc(pf_user || ''),
|
||||||
note: esc(note || ''),
|
note: esc(note || ''),
|
||||||
label: esc(names.pf_segment),
|
|
||||||
bucket: esc(names.pf_bucket),
|
|
||||||
params: esc(JSON.stringify({
|
params: esc(JSON.stringify({
|
||||||
slices: unit.slices,
|
slices: unit.slices,
|
||||||
apply_mode: applyMode,
|
apply_mode: applyMode,
|
||||||
...pickIntent(req.body),
|
...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)),
|
slice: esc(JSON.stringify(loggedSlice)),
|
||||||
where_clause: unit.where,
|
where_clause: unit.where,
|
||||||
@ -933,7 +842,7 @@ module.exports = function(pool) {
|
|||||||
for (const r of result.rows) {
|
for (const r of result.rows) {
|
||||||
if (r.pf_logid != null) sqlByLogId.set(r.pf_logid, sql);
|
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) {
|
if (allRows.length === 0) {
|
||||||
@ -953,6 +862,7 @@ module.exports = function(pool) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
const opLabel = (req.body.tag || '').trim() || note || null;
|
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' }));
|
const rows = allRows.map(r => ({ ...r, ...names, pf_note: opLabel, pf_op: 'scale' }));
|
||||||
res.json({
|
res.json({
|
||||||
rows,
|
rows,
|
||||||
@ -972,47 +882,23 @@ module.exports = function(pool) {
|
|||||||
|
|
||||||
// recode dimension values on one or more slices
|
// recode dimension values on one or more slices
|
||||||
// inserts negative rows to zero out the original, positive rows with new dimension values
|
// 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) => {
|
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 pf_user = sessionUser(req);
|
||||||
const slices = normalizeSlices(req.body);
|
const slices = normalizeSlices(req.body);
|
||||||
if (slices.length === 0) return res.status(400).json({ error: 'slice is required' });
|
if (slices.length === 0) return res.status(400).json({ error: 'slice is required' });
|
||||||
const dateOffset = String(date_offset ?? '').trim() || '0 days';
|
if (!set || Object.keys(set).length === 0) return res.status(400).json({ error: 'set is required' });
|
||||||
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' });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const ctx = await getContext(parseInt(req.params.id), 'recode');
|
const ctx = await getContext(parseInt(req.params.id), 'recode');
|
||||||
if (!guardOpen(ctx.version, res)) return;
|
if (!guardOpen(ctx.version, res)) return;
|
||||||
assertSelective(slices, ctx, req.body.scope);
|
assertSelective(slices, ctx);
|
||||||
if (!assertMayRecodeTerritory(req, ctx, set, res)) return;
|
if (!assertMayRecodeTerritory(req, ctx, set, res)) return;
|
||||||
|
|
||||||
const excludeClause = buildExcludeClause(ctx.version.exclude_iters);
|
const excludeClause = buildExcludeClause(ctx.version.exclude_iters);
|
||||||
|
const setClause = buildSetClause(ctx.dimCols, set);
|
||||||
// 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 units = sliceUnits(slices, ctx, apply_mode === 'each' ? 'each' : 'prorate', req.body.scope, req);
|
const units = sliceUnits(slices, ctx, apply_mode === 'each' ? 'each' : 'prorate', req.body.scope, req);
|
||||||
|
|
||||||
const names = adjustmentNames(ctx.version);
|
|
||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
let committed = false;
|
let committed = false;
|
||||||
try {
|
try {
|
||||||
@ -1027,32 +913,19 @@ module.exports = function(pool) {
|
|||||||
version_id: ctx.version.id,
|
version_id: ctx.version.id,
|
||||||
pf_user: esc(pf_user || ''),
|
pf_user: esc(pf_user || ''),
|
||||||
note: esc(note || ''),
|
note: esc(note || ''),
|
||||||
label: esc(names.pf_segment),
|
params: esc(JSON.stringify({ slices: unit.slices, set, apply_mode: unit.mode })),
|
||||||
bucket: esc(names.pf_bucket),
|
|
||||||
params: esc(JSON.stringify({ slices: unit.slices, set, apply_mode: unit.mode, date_offset: dateOffset })),
|
|
||||||
slice: esc(JSON.stringify(loggedSlice)),
|
slice: esc(JSON.stringify(loggedSlice)),
|
||||||
where_clause: unit.where,
|
where_clause: unit.where,
|
||||||
exclude_clause: excludeClause,
|
exclude_clause: excludeClause,
|
||||||
set_clause: setClause,
|
set_clause: setClause
|
||||||
date_offset: esc(dateOffset)
|
|
||||||
});
|
});
|
||||||
const result = await runSQL(sql, client);
|
const result = await runSQL(sql, client);
|
||||||
await tagLog(client, result.rows, req.body.tag);
|
await tagLog(client, result.rows, req.body.tag);
|
||||||
for (const r of result.rows) {
|
for (const r of result.rows) {
|
||||||
if (r.pf_logid != null) sqlByLogId.set(r.pf_logid, sql);
|
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');
|
await client.query('COMMIT');
|
||||||
committed = true;
|
committed = true;
|
||||||
// one log id per unit: apply_mode 'each' writes an entry per slice
|
// 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 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' }));
|
const rows = allRows.map(r => ({ ...r, ...names, pf_note: opLabel, pf_op: 'recode' }));
|
||||||
res.json({ rows, rows_affected: rows.length, slices_applied: units.length });
|
res.json({ rows, rows_affected: rows.length, slices_applied: units.length });
|
||||||
} finally {
|
} finally {
|
||||||
@ -1086,7 +960,7 @@ module.exports = function(pool) {
|
|||||||
try {
|
try {
|
||||||
const ctx = await getContext(parseInt(req.params.id), 'clone');
|
const ctx = await getContext(parseInt(req.params.id), 'clone');
|
||||||
if (!guardOpen(ctx.version, res)) return;
|
if (!guardOpen(ctx.version, res)) return;
|
||||||
assertSelective(slices, ctx, req.body.scope);
|
assertSelective(slices, ctx);
|
||||||
|
|
||||||
const scaleFactor = (scale != null) ? parseFloat(scale) : 1.0;
|
const scaleFactor = (scale != null) ? parseFloat(scale) : 1.0;
|
||||||
const dateOffset = (date_offset || '0 days').trim() || '0 days';
|
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 setClause = buildSetClause(ctx.dimCols, set, { derivedExprs, alias: 's' });
|
||||||
const units = sliceUnits(slices, ctx, apply_mode === 'each' ? 'each' : 'prorate', req.body.scope, req);
|
const units = sliceUnits(slices, ctx, apply_mode === 'each' ? 'each' : 'prorate', req.body.scope, req);
|
||||||
|
|
||||||
const names = adjustmentNames(ctx.version);
|
|
||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
let committed = false;
|
let committed = false;
|
||||||
try {
|
try {
|
||||||
@ -1142,8 +1015,6 @@ module.exports = function(pool) {
|
|||||||
version_id: ctx.version.id,
|
version_id: ctx.version.id,
|
||||||
pf_user: esc(pf_user || ''),
|
pf_user: esc(pf_user || ''),
|
||||||
note: esc(note || ''),
|
note: esc(note || ''),
|
||||||
label: esc(names.pf_segment),
|
|
||||||
bucket: esc(names.pf_bucket),
|
|
||||||
params: esc(JSON.stringify({
|
params: esc(JSON.stringify({
|
||||||
slices: unit.slices, set, scale: scaleFactor, apply_mode: unit.mode,
|
slices: unit.slices, set, scale: scaleFactor, apply_mode: unit.mode,
|
||||||
date_offset: dateOffset,
|
date_offset: dateOffset,
|
||||||
@ -1161,7 +1032,7 @@ module.exports = function(pool) {
|
|||||||
for (const r of result.rows) {
|
for (const r of result.rows) {
|
||||||
if (r.pf_logid != null) sqlByLogId.set(r.pf_logid, sql);
|
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');
|
await client.query('COMMIT');
|
||||||
committed = true;
|
committed = true;
|
||||||
@ -1173,6 +1044,7 @@ module.exports = function(pool) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
const opLabel = (req.body.tag || '').trim() || note || null;
|
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' }));
|
const rows = allRows.map(r => ({ ...r, ...names, pf_note: opLabel, pf_op: 'clone' }));
|
||||||
res.json({ rows, rows_affected: rows.length, slices_applied: units.length });
|
res.json({ rows, rows_affected: rows.length, slices_applied: units.length });
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@ -7,7 +7,7 @@ const { Pool, types } = require('pg');
|
|||||||
const { requireAuth } = require('./lib/auth');
|
const { requireAuth } = require('./lib/auth');
|
||||||
|
|
||||||
// Return bigint (oid 20) and numeric (oid 1700) as JS numbers instead of strings,
|
// 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(20, v => v === null ? null : Number(v));
|
||||||
types.setTypeParser(1700, v => v === null ? null : Number(v));
|
types.setTypeParser(1700, v => v === null ? null : Number(v));
|
||||||
|
|
||||||
|
|||||||
@ -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 adjustment_bucket text;
|
||||||
ALTER TABLE pf.version ADD COLUMN IF NOT EXISTS unlabeled_load 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.
|
-- 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
|
-- Without it the app fell back to the newest version, which is whatever
|
||||||
|
|||||||
16
ui/README.md
Normal file
16
ui/README.md
Normal 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.
|
||||||
@ -15,15 +15,9 @@ import { useState, useEffect, useRef, useCallback } from 'react'
|
|||||||
const UP = '#2a78d6' // increase
|
const UP = '#2a78d6' // increase
|
||||||
const DOWN = '#e34948' // decrease
|
const DOWN = '#e34948' // decrease
|
||||||
const ANCHOR = '#6b7280' // baseline / current — neutral, 4.83:1 on white
|
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
|
const GRID = '#e5e7eb'
|
||||||
// dark mode the value labels stayed #374151 on a #2a2c2f panel and could not be
|
const INK = '#374151'
|
||||||
// read. Applied through `style` rather than the presentation attribute, where
|
const INK_DIM = '#6b7280'
|
||||||
// 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 fmt = (n, dp = 2) =>
|
const fmt = (n, dp = 2) =>
|
||||||
n == null || !isFinite(n) ? '—'
|
n == null || !isFinite(n) ? '—'
|
||||||
@ -129,11 +123,9 @@ export function buildSteps(rows, {
|
|||||||
if (isLoad) { loads.value += v; loads.units += u; loads.rows += 1; continue }
|
if (isLoad) { loads.value += v; loads.units += u; loads.rows += 1; continue }
|
||||||
|
|
||||||
const meta = logMeta[r.pf_logid] || {}
|
const meta = logMeta[r.pf_logid] || {}
|
||||||
// The tag alone. This used to be label first, to match pf_segment, but every
|
// label first, the same precedence pf_segment uses, so the bridge and the
|
||||||
// adjustment now carries a label -- the version's adjustment segment, the
|
// pivot call a step by the same name
|
||||||
// same one for all of them -- so the walk collapsed into a single bar and
|
const tag = (meta.label || meta.tag || '').trim()
|
||||||
// no tag was ever shown. The label is the segment; the tag is the initiative.
|
|
||||||
const tag = (meta.tag || '').trim()
|
|
||||||
// Untagged entries collapse into one step, the same as the ledger's
|
// Untagged entries collapse into one step, the same as the ledger's
|
||||||
// "N untagged adjustments" line in OperationPanel.jsx -- one bar per
|
// "N untagged adjustments" line in OperationPanel.jsx -- one bar per
|
||||||
// untagged scale/recode would otherwise turn a routine cleanup pass into
|
// 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 [hover, setHover] = useState(null)
|
||||||
const [width, setWidth] = useState(880)
|
const [width, setWidth] = useState(880)
|
||||||
const boxRef = useRef(null)
|
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.
|
// Build the steps: baseline anchor, one floating step per tag, current anchor.
|
||||||
const compute = useCallback(async () => {
|
const compute = useCallback(async () => {
|
||||||
@ -417,36 +405,6 @@ export default function BridgeView({
|
|||||||
useEffect(() => { try { localStorage.setItem('pf_bridge_basis', basis) } catch {} }, [basis])
|
useEffect(() => { try { localStorage.setItem('pf_bridge_basis', basis) } catch {} }, [basis])
|
||||||
useEffect(() => { if (open) compute() }, [open, compute])
|
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(() => {
|
useEffect(() => {
|
||||||
if (!open || !boxRef.current) return
|
if (!open || !boxRef.current) return
|
||||||
const ro = new ResizeObserver(([e]) => setWidth(Math.max(420, e.contentRect.width)))
|
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)
|
const ticks = niceTicks(yMin, yMax, 5)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// Not modal. The overlay passes clicks through, so the pivot stays usable
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
|
||||||
// with the bridge open -- moving it aside to read the grid under it, or
|
<div className="bg-white rounded-lg shadow-xl w-full max-w-5xl mx-4 flex flex-col max-h-[88vh]"
|
||||||
// changing the selection it is scoped to, which recomputes it. It used to
|
onClick={e => e.stopPropagation()}>
|
||||||
// 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="flex items-center justify-between px-5 py-3 border-b border-gray-200 shrink-0 cursor-move select-none touch-none"
|
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-200 shrink-0">
|
||||||
onPointerDown={startDrag} onPointerMove={moveDrag}
|
|
||||||
onPointerUp={endDrag} onPointerCancel={endDrag}>
|
|
||||||
<div className="flex items-baseline gap-2">
|
<div className="flex items-baseline gap-2">
|
||||||
<span className="font-medium text-gray-700 text-sm">Bridge</span>
|
<span className="font-medium text-gray-700 text-sm">Bridge</span>
|
||||||
{versionName && <span className="text-gray-600 text-xs">{versionName}</span>}
|
{versionName && <span className="text-gray-600 text-xs">{versionName}</span>}
|
||||||
@ -561,8 +512,8 @@ export default function BridgeView({
|
|||||||
{ticks.map(t => (
|
{ticks.map(t => (
|
||||||
<g key={t}>
|
<g key={t}>
|
||||||
<line x1={PAD.l} x2={PAD.l + plotW} y1={y(t)} y2={y(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} />
|
stroke={t === 0 ? '#d1d5db' : GRID} strokeWidth={t === 0 ? 1.5 : 1} />
|
||||||
<text x={PAD.l - 8} y={y(t) + 3} textAnchor="end" fontSize="10" style={{ fill: INK_DIM }}>
|
<text x={PAD.l - 8} y={y(t) + 3} textAnchor="end" fontSize="10" fill={INK_DIM}>
|
||||||
{fmtAxis(t)}
|
{fmtAxis(t)}
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
@ -586,19 +537,19 @@ export default function BridgeView({
|
|||||||
{/* connector to the next bar, drawn behind */}
|
{/* connector to the next bar, drawn behind */}
|
||||||
{i < steps.length - 1 && (
|
{i < steps.length - 1 && (
|
||||||
<line x1={x + barW} x2={xOf(i + 1)} y1={y(s.end)} y2={y(s.end)}
|
<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 */}
|
{/* hit target larger than the mark */}
|
||||||
<rect x={x - 6} y={PAD.t} width={barW + 12} height={plotH} fill="transparent" />
|
<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}
|
<rect x={x} y={top} width={barW} height={h} rx="4" fill={fill}
|
||||||
opacity={on ? 1 : 0.92}
|
opacity={on ? 1 : 0.92}
|
||||||
style={{ stroke: PANEL }} strokeWidth="2" />
|
stroke="#ffffff" strokeWidth="2" />
|
||||||
{/* direct label: few bars, so every one is labelled */}
|
{/* direct label: few bars, so every one is labelled */}
|
||||||
<text x={x + barW / 2} y={top - 6} textAnchor="middle" fontSize="10"
|
<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)}
|
{isAnchor ? fmt(s.end, 0) : fmtSigned(s.delta, 0)}
|
||||||
</text>
|
</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) => (
|
{labelLines.map((ln, li) => (
|
||||||
<tspan key={li} x={x + barW / 2} dy={li === 0 ? 0 : 11}>{ln}</tspan>
|
<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
|
untagged bucket -- its label already reads "N untagged
|
||||||
adjustments", so a ×N here would just repeat the count. */}
|
adjustments", so a ×N here would just repeat the count. */}
|
||||||
{!isAnchor && s.tagged && s.entries > 1 && (
|
{!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}
|
×{s.entries}
|
||||||
</text>
|
</text>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import { useEffect, useRef, useState } from 'react'
|
|||||||
// A stepper is three controls whatever the depth, and says where you are rather
|
// 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
|
// than making you find it. The full list is still one click away, for jumping
|
||||||
// rather than stepping.
|
// 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 [open, setOpen] = useState(false)
|
||||||
const boxRef = useRef(null)
|
const boxRef = useRef(null)
|
||||||
|
|
||||||
@ -35,44 +35,29 @@ export default function DepthButtons({ label, levels, depth, onPick, totalLabel
|
|||||||
const known = depth != null
|
const known = depth != null
|
||||||
const current = !known ? '—' : (depth === 0 ? totalLabel : levels[depth - 1])
|
const current = !known ? '—' : (depth === 0 ? totalLabel : levels[depth - 1])
|
||||||
const step = (d) => onPick(Math.min(max, Math.max(0, d)))
|
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
|
const arrow = 'border border-gray-200 rounded px-1 py-0.5 text-gray-500 leading-none ' +
|
||||||
// Scale | Recode | Clone. The arrows used to be separate outlined boxes a
|
'hover:border-gray-400 disabled:opacity-30 disabled:hover:border-gray-200 transition-colors'
|
||||||
// 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'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={boxRef} className="relative flex items-center gap-1.5">
|
<div ref={boxRef} className="relative flex items-center gap-1">
|
||||||
<span className="pf-section-label">{label}</span>
|
<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={arrow} disabled={!known || depth <= 0}
|
||||||
<button className={seg} disabled={!known || depth <= 0}
|
|
||||||
title={`Collapse ${label.toLowerCase()} by one level`}
|
title={`Collapse ${label.toLowerCase()} by one level`}
|
||||||
onClick={() => step((depth ?? 0) - 1)}>◂</button>
|
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)}
|
<button onClick={() => setOpen(o => !o)}
|
||||||
title={`${label}: showing ${current}`}
|
title={`${label}: showing ${current}`}
|
||||||
style={{ width: `calc(${widest}ch + 1rem)` }}
|
className={`border rounded px-2 py-0.5 max-w-[10rem] truncate transition-colors
|
||||||
className={`px-2 py-0.5 truncate text-left transition-colors
|
${open ? 'border-blue-300 text-blue-700 bg-blue-50'
|
||||||
${open ? 'bg-blue-50 text-blue-700' : 'bg-white text-gray-700 hover:bg-gray-100'}`}>
|
: 'border-gray-200 text-gray-600 hover:border-gray-400'}`}>
|
||||||
{current}
|
{current}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button className={seg} disabled={!known || depth >= max}
|
<button className={arrow} disabled={!known || depth >= max}
|
||||||
title={`Expand ${label.toLowerCase()} by one level`}
|
title={`Expand ${label.toLowerCase()} by one level`}
|
||||||
onClick={() => step((depth ?? 0) + 1)}>▸</button>
|
onClick={() => step((depth ?? 0) + 1)}>▸</button>
|
||||||
</div>
|
|
||||||
|
|
||||||
{children}
|
|
||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div className="absolute top-full left-0 mt-1 z-50 min-w-[10rem] bg-white border
|
<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>
|
</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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@ -119,26 +119,19 @@ export default function LayoutMenu({
|
|||||||
<span className="pf-section-label">Layout</span>
|
<span className="pf-section-label">Layout</span>
|
||||||
|
|
||||||
<button onClick={() => setOpen(o => !o)}
|
<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'
|
${open ? 'border-blue-300 text-blue-700 bg-blue-50'
|
||||||
: 'border-gray-200 text-gray-600 hover:border-gray-400'}`}>
|
: 'border-gray-200 text-gray-600 hover:border-gray-400'}`}>
|
||||||
<span className="truncate">{active ? active.name : 'Unsaved'}</span>
|
<span className="truncate">{active ? active.name : 'Unsaved'}</span>
|
||||||
{/* Always rendered, hidden when clean, so the button does not widen
|
{dirty && <span title="Changed since it was saved" className="text-amber-500 leading-none">•</span>}
|
||||||
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>
|
|
||||||
<span className="text-gray-400 leading-none">▾</span>
|
<span className="text-gray-400 leading-none">▾</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Save is the common action and stays outside the menu, but only when
|
{/* 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
|
there is something to save it to and it is yours to overwrite. */}
|
||||||
slot is held while the layout is clean: appearing and vanishing, it
|
{active?.can_edit && dirty && (
|
||||||
shoved the Rows and Columns controls sideways under the cursor. */}
|
<button onClick={() => onSave(active)}
|
||||||
{active?.can_edit && (
|
className="border border-blue-200 text-blue-600 hover:text-blue-800 rounded px-2 py-0.5">
|
||||||
<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'}`}>
|
|
||||||
Save
|
Save
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -39,7 +39,7 @@ function dispVal(v) {
|
|||||||
|
|
||||||
function sliceLabel(s) {
|
function sliceLabel(s) {
|
||||||
const entries = Object.entries(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
|
// 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
|
function Button({ onClick, active, children, title, disabled }) {
|
||||||
// reporting something true -- but does not take a click.
|
|
||||||
function Button({ onClick, active, children, title, disabled, locked }) {
|
|
||||||
return (
|
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 ${
|
className={`px-3 py-1 text-xs font-medium whitespace-nowrap transition-colors ${
|
||||||
locked
|
disabled
|
||||||
? (active ? 'bg-blue-600 text-white cursor-default' : 'bg-white text-gray-700 cursor-default')
|
|
||||||
: disabled
|
|
||||||
? (active ? 'bg-gray-300 text-white cursor-default' : 'bg-white text-gray-400 cursor-default')
|
? (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')}`}>
|
: (active ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-100')}`}>
|
||||||
{children}
|
{children}
|
||||||
@ -196,16 +192,13 @@ function SectionLabel({ children, className = '' }) {
|
|||||||
// `disabled` keeps the control in place and shows which way it is set rather
|
// `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
|
// than removing it. A control that vanishes reads as broken; one that greys out
|
||||||
// says the choice has been made elsewhere.
|
// says the choice has been made elsewhere.
|
||||||
// `lit`, when given, is the list of options to show active -- for a control
|
function Segmented({ options, value, onChange, disabled }) {
|
||||||
// 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 }) {
|
|
||||||
return (
|
return (
|
||||||
<div className={`inline-flex rounded border divide-x shadow-sm overflow-hidden w-auto self-start ${
|
<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'}`}>
|
disabled ? 'border-gray-200 divide-gray-200 bg-gray-50' : 'border-gray-300 divide-gray-300'}`}>
|
||||||
{options.map(([val, label, title]) => (
|
{options.map(([val, label, title]) => (
|
||||||
<Button key={val} onClick={() => onChange(val)} active={lit ? lit.includes(val) : value === val}
|
<Button key={val} onClick={() => onChange(val)} active={value === val}
|
||||||
title={title} disabled={disabled} locked={locked}>{label}</Button>
|
title={title} disabled={disabled}>{label}</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@ -282,10 +275,6 @@ function SelectionList({ slices, viewScope = [], distinctSlices, currentTotals,
|
|||||||
? <span className="font-mono text-gray-700">{sliceLabel(s)}</span>
|
? <span className="font-mono text-gray-700">{sliceLabel(s)}</span>
|
||||||
: (
|
: (
|
||||||
<div className="flex flex-col gap-0.5">
|
<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]) => (
|
{Object.entries(s).map(([k, v]) => (
|
||||||
<div key={k} className="whitespace-nowrap">
|
<div key={k} className="whitespace-nowrap">
|
||||||
<span className="text-gray-600">{k}</span>
|
<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
|
function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, setScalePlug, targetBasis, setTargetBasis, logMeta = {}, multi, applyMode, settingsExtra }) {
|
||||||
// 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)
|
|
||||||
const valueCol = currentTotals?.valueCol
|
const valueCol = currentTotals?.valueCol
|
||||||
const unitsCol = currentTotals?.unitsCol
|
const unitsCol = currentTotals?.unitsCol
|
||||||
const total = currentTotals?.total || { value: 0, units: 0 }
|
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' })
|
baseline.push({ ...e, label: name || 'Baseline', kind: 'baseline' })
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// The tag alone, not the label. Every adjustment is now stamped with a
|
const tag = (meta.label || meta.tag || '').trim()
|
||||||
// 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()
|
|
||||||
if (tag) {
|
if (tag) {
|
||||||
// Grouped case-insensitively. "Keystone reduction" and "Keystone
|
// Grouped case-insensitively. "Keystone reduction" and "Keystone
|
||||||
// Reduction" are one initiative typed twice, and splitting them here
|
// 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
|
// 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
|
// 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')
|
const dollarsOnly = filled('value') && !filled('units') && !filled('price')
|
||||||
|
|
||||||
// Where the edit lands, for all three measures at once. The rows above each
|
// 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
|
let newValue, newUnits
|
||||||
|
|
||||||
if (filled('price') && filled('value')) {
|
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')) {
|
|
||||||
// price is the input; dollars fall out of price x volume
|
// price is the input; dollars fall out of price x volume
|
||||||
const targetPrice = numOf('price', 'new')
|
const targetPrice = numOf('price', 'new')
|
||||||
newUnits = filled('units') ? numOf('units', 'new') : curUnits
|
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.
|
// 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
|
// Pinning the dollars as well is how a price move is asked for, and it
|
||||||
// takes the branch below instead.
|
// 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')
|
newUnits = numOf('units', 'new')
|
||||||
newValue = (total.units === 0 || newUnits == null)
|
newValue = (curUnits === 0 || newUnits == null)
|
||||||
? curValue
|
? curValue
|
||||||
: curValue + total.value * ((newUnits - curUnits) / total.units)
|
: curValue * (newUnits / curUnits)
|
||||||
} else {
|
} else {
|
||||||
newValue = filled('value') ? numOf('value', 'new') : curValue
|
newValue = filled('value') ? numOf('value', 'new') : curValue
|
||||||
if (filled('units')) newUnits = numOf('units', 'new')
|
if (filled('units')) newUnits = numOf('units', 'new')
|
||||||
else if (dollarsOnly && scalePlug === 'volume') newUnits = total.value === 0 ? null
|
else if (dollarsOnly && scalePlug === 'volume') newUnits = curValue === 0 ? null
|
||||||
: curUnits + total.units * ((newValue - curValue) / total.value)
|
: curUnits * (newValue / curValue)
|
||||||
else newUnits = curUnits
|
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 same three readings for a measure nobody typed into, taken from where
|
||||||
// the edit actually lands it. Scaling dollars with price holding moves qty,
|
// 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
|
// 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) =>
|
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
|
// 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
|
// -- 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
|
// carrying across whatever it currently reads
|
||||||
const focusRow = (key, field) =>
|
const focusRow = (key, field) =>
|
||||||
setScaleInputs(prev => {
|
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]
|
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
|
// 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
|
// number: clicking into an implied figure is how you pin it, which is
|
||||||
// the difference between "units may move" and "units are this".
|
// the difference between "units may move" and "units are this".
|
||||||
const text = shown[key]?.[field] ?? ''
|
const text = shown[key]?.[field] ?? ''
|
||||||
const raw = text === '—' ? '' : String(text).replace(/,/g, '')
|
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
|
// 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 = (() => {
|
const plugState = (() => {
|
||||||
if (!(valueCol && unitsCol)) return null
|
if (!(valueCol && unitsCol)) return null
|
||||||
const u = filled('units'), p = filled('price'), v = filled('value')
|
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 && 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 && 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' }
|
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,
|
if (p) return { value: 'volume', live: false, why: `price pinned — ${unitsCol} absorbs it`, unpin: 'price' }
|
||||||
// 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 (!v) return { value: null, live: false, why: `for a ${valueCol} figure on its own` }
|
if (!v) return { value: null, live: false, why: `for a ${valueCol} figure on its own` }
|
||||||
return {
|
return {
|
||||||
value: scalePlug,
|
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 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>
|
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 (
|
return (
|
||||||
<div className="min-w-0">
|
<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>{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'}
|
<tr className={onTotal ? 'text-gray-600' : 'font-semibold text-gray-700'}>
|
||||||
onClick={hasExcl ? () => setTargetBasis('adjustable') : undefined}
|
|
||||||
title={hasExcl ? 'Type your number as the forecast alone' : undefined}>
|
|
||||||
<td className="pr-3 whitespace-nowrap">
|
<td className="pr-3 whitespace-nowrap">
|
||||||
{hasExcl ? 'Forecast' : 'Current'}{perSlice ? ' (all)' : ''}
|
{hasExcl ? 'Forecast' : 'Current'}{perSlice ? ' (all)' : ''}
|
||||||
{hasExcl && !onTotal && targetTag}
|
|
||||||
</td>
|
</td>
|
||||||
{measures.map(m => (
|
{measures.map(m => (
|
||||||
<td key={m.key} className={numCell}>{fmtNum(m.current, m.dp)}</td>
|
<td key={m.key} className={numCell}>{fmtNum(m.current, m.dp)}</td>
|
||||||
@ -1087,10 +991,8 @@ function ScaleLedger({ currentTotals, scaleInputs: rawInputs, setScaleInputs, sc
|
|||||||
|
|
||||||
|
|
||||||
{hasExcl && (
|
{hasExcl && (
|
||||||
<tr className={basisRow(onTotal)}
|
<tr className={onTotal ? 'font-semibold text-gray-700' : 'text-gray-600'}>
|
||||||
onClick={() => setTargetBasis('selected')}
|
<td className="pr-3 whitespace-nowrap">Total selected</td>
|
||||||
title="Type your number as everything selected">
|
|
||||||
<td className="pr-3 whitespace-nowrap">Total selected{onTotal && targetTag}</td>
|
|
||||||
{measures.map(m => (
|
{measures.map(m => (
|
||||||
<td key={m.key} className={numCell}>
|
<td key={m.key} className={numCell}>
|
||||||
{m.key === 'price'
|
{m.key === 'price'
|
||||||
@ -1099,7 +1001,7 @@ function ScaleLedger({ currentTotals, scaleInputs: rawInputs, setScaleInputs, sc
|
|||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
{showRunning && (
|
{showRunning && (
|
||||||
<td className={numCell}>{fmtNum(grand.value, 0)}</td>
|
<td className={`${numCell} font-semibold text-gray-700`}>{fmtNum(grand.value, 0)}</td>
|
||||||
)}
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
@ -1183,20 +1085,11 @@ function ScaleLedger({ currentTotals, scaleInputs: rawInputs, setScaleInputs, sc
|
|||||||
{settingsExtra}
|
{settingsExtra}
|
||||||
|
|
||||||
{plugState && (
|
{plugState && (
|
||||||
<Field label="Moves" hint={plugState.why}>
|
<Field label="Absorbs" 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. */}
|
|
||||||
<Segmented
|
<Segmented
|
||||||
lit={plugState.live
|
value={plugState.value}
|
||||||
? [scalePlug]
|
|
||||||
: [outcome?.moved?.price && 'price', outcome?.moved?.units && 'volume'].filter(Boolean)}
|
|
||||||
onChange={setScalePlug}
|
onChange={setScalePlug}
|
||||||
locked={!plugState.live}
|
disabled={!plugState.live}
|
||||||
options={[
|
options={[
|
||||||
['price', 'Price', `${unitsCol} holds; price moves to reach the number`],
|
['price', 'Price', `${unitsCol} holds; price moves to reach the number`],
|
||||||
['volume', 'Volume', `Price holds; ${unitsCol} scales with the dollars`],
|
['volume', 'Volume', `Price holds; ${unitsCol} scales with the dollars`],
|
||||||
@ -1480,14 +1373,13 @@ export default function OperationPanel({
|
|||||||
applyMode, setApplyMode,
|
applyMode, setApplyMode,
|
||||||
currentTotals,
|
currentTotals,
|
||||||
activeOp, setActiveOp,
|
activeOp, setActiveOp,
|
||||||
scaleInputs, setScaleInputs, scaleExpectedRef,
|
scaleInputs, setScaleInputs,
|
||||||
scalePlug, setScalePlug,
|
scalePlug, setScalePlug,
|
||||||
targetBasis, setTargetBasis,
|
targetBasis, setTargetBasis,
|
||||||
opTag, setOpTag, knownTags = [], logMeta = {},
|
opTag, setOpTag, knownTags = [], logMeta = {},
|
||||||
scaleNote, setScaleNote,
|
scaleNote, setScaleNote,
|
||||||
recodeSet, setRecodeSet,
|
recodeSet, setRecodeSet,
|
||||||
recodeNote, setRecodeNote,
|
recodeNote, setRecodeNote,
|
||||||
recodeOffset, setRecodeOffset,
|
|
||||||
cloneSet, setCloneSet,
|
cloneSet, setCloneSet,
|
||||||
cloneScale, setCloneScale,
|
cloneScale, setCloneScale,
|
||||||
cloneFrom, setCloneFrom, cloneOffset, setCloneOffset, cloneSources,
|
cloneFrom, setCloneFrom, cloneOffset, setCloneOffset, cloneSources,
|
||||||
@ -1500,8 +1392,7 @@ export default function OperationPanel({
|
|||||||
const horizontal = dock === 'bottom'
|
const horizontal = dock === 'bottom'
|
||||||
|
|
||||||
const note = activeOp === 'scale' ? scaleNote : activeOp === 'recode' ? recodeNote : cloneNote
|
const note = activeOp === 'scale' ? scaleNote : activeOp === 'recode' ? recodeNote : cloneNote
|
||||||
const isShift = off => !!off && off.trim() !== '' && off.trim() !== '0 days'
|
const shifting = !!cloneOffset && cloneOffset.trim() !== '' && cloneOffset.trim() !== '0 days'
|
||||||
const shifting = isShift(cloneOffset)
|
|
||||||
const setNote = activeOp === 'scale' ? setScaleNote : activeOp === 'recode' ? setRecodeNote : setCloneNote
|
const setNote = activeOp === 'scale' ? setScaleNote : activeOp === 'recode' ? setRecodeNote : setCloneNote
|
||||||
|
|
||||||
// Slices are confirmation, not the work. The list used to hold a whole column
|
// Slices are confirmation, not the work. The list used to hold a whole column
|
||||||
@ -1536,7 +1427,7 @@ export default function OperationPanel({
|
|||||||
{activeOp === 'scale' && (
|
{activeOp === 'scale' && (
|
||||||
<ScaleLedger
|
<ScaleLedger
|
||||||
currentTotals={currentTotals}
|
currentTotals={currentTotals}
|
||||||
scaleInputs={scaleInputs} setScaleInputs={setScaleInputs} scaleExpectedRef={scaleExpectedRef}
|
scaleInputs={scaleInputs} setScaleInputs={setScaleInputs}
|
||||||
scalePlug={scalePlug} setScalePlug={setScalePlug}
|
scalePlug={scalePlug} setScalePlug={setScalePlug}
|
||||||
targetBasis={targetBasis} setTargetBasis={setTargetBasis}
|
targetBasis={targetBasis} setTargetBasis={setTargetBasis}
|
||||||
logMeta={logMeta}
|
logMeta={logMeta}
|
||||||
@ -1548,26 +1439,7 @@ export default function OperationPanel({
|
|||||||
<DimForm dimCols={dimCols} setObj={recodeSet} setSet={setRecodeSet}
|
<DimForm dimCols={dimCols} setObj={recodeSet} setSet={setRecodeSet}
|
||||||
slices={slices} lookupDerivedCols={lookupDerivedCols} dimMembers={dimMembers} versionId={versionId}
|
slices={slices} lookupDerivedCols={lookupDerivedCols} dimMembers={dimMembers} versionId={versionId}
|
||||||
extra={
|
extra={
|
||||||
<div className="flex flex-col gap-2 pt-1 border-t border-gray-100 mt-1">
|
<div className="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>
|
|
||||||
<MovingTotal currentTotals={currentTotals} verb="Moving" />
|
<MovingTotal currentTotals={currentTotals} verb="Moving" />
|
||||||
</div>
|
</div>
|
||||||
} />
|
} />
|
||||||
|
|||||||
@ -11,8 +11,6 @@
|
|||||||
--border-light: #f3f4f6;
|
--border-light: #f3f4f6;
|
||||||
--accent-bg: #eff6ff;
|
--accent-bg: #eff6ff;
|
||||||
--accent-text: #1d4ed8;
|
--accent-text: #1d4ed8;
|
||||||
--chart-ink-dim: #6b7280;
|
|
||||||
--chart-axis: #d1d5db;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark palette tuned to Perspective's "Pro Dark" theme:
|
/* Dark palette tuned to Perspective's "Pro Dark" theme:
|
||||||
@ -29,11 +27,6 @@
|
|||||||
--border-light: #3b3f46;
|
--border-light: #3b3f46;
|
||||||
--accent-bg: rgba(39, 113, 170, 0.32);
|
--accent-bg: rgba(39, 113, 170, 0.32);
|
||||||
--accent-text: #4778c2;
|
--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); }
|
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-white { background-color: var(--bg-secondary); }
|
||||||
.dark .bg-gray-50 { background-color: var(--bg-tertiary); }
|
.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-100 { background-color: var(--bg-tertiary); }
|
||||||
.dark .bg-gray-200 { background-color: var(--bg-tertiary); }
|
.dark .bg-gray-200 { background-color: var(--bg-tertiary); }
|
||||||
.dark .bg-gray-300 { 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; }
|
.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-300 { color: var(--text-muted); }
|
||||||
.dark .text-gray-400 { color: var(--text-muted); }
|
.dark .text-gray-400 { color: var(--text-muted); }
|
||||||
.dark .text-gray-500 { color: var(--text-muted); }
|
.dark .text-gray-500 { color: var(--text-muted); }
|
||||||
|
|||||||
@ -176,12 +176,6 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
|||||||
async function saveLogField(entry, field, value) {
|
async function saveLogField(entry, field, value) {
|
||||||
const next = value.trim()
|
const next = value.trim()
|
||||||
if (next === (entry[field] || '')) return
|
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 {
|
try {
|
||||||
const res = await fetch(`/api/log/${entry.id}`, {
|
const res = await fetch(`/api/log/${entry.id}`, {
|
||||||
method: 'PATCH',
|
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 }
|
if (!res.ok) { const d = await res.json(); flash(d.error, 'error'); return }
|
||||||
loadLog()
|
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') }
|
} catch (err) { flash(err.message, 'error') }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -427,7 +421,6 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
|||||||
async function loadSegment() {
|
async function loadSegment() {
|
||||||
const clause = useRaw ? rawSql.trim() : buildFilterClause(filters)
|
const clause = useRaw ? rawSql.trim() : buildFilterClause(filters)
|
||||||
if (!clause) { flash(useRaw ? 'Enter a WHERE clause' : 'Add at least one filter', 'error'); return }
|
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 isRef = segType === 'reference'
|
||||||
const offsetStr = offset.trim() || '0 days'
|
const offsetStr = offset.trim() || '0 days'
|
||||||
const endpoint = isRef ? 'reference' : 'baseline'
|
const endpoint = isRef ? 'reference' : 'baseline'
|
||||||
@ -683,19 +676,17 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Adjustment defaults. A scale, recode or clone has no label or bucket
|
{/* Fallback names. Not part of any segment -- they are what the pivot shows
|
||||||
input, so it is stamped with these when it is written. Written onto
|
for rows nobody has named, so they belong to the version rather than to
|
||||||
the log entry, not read back through it: changing one names the next
|
a log entry. Blank falls back to the built-in in sql_generator's
|
||||||
adjustment and leaves the existing ones as they are. Blank falls back
|
DISPLAY DEFAULTS block. */}
|
||||||
to the built-in in sql_generator's ADJUSTMENT DEFAULTS block. */}
|
|
||||||
{versionId && (
|
{versionId && (
|
||||||
<div className="bg-white border border-gray-200 rounded p-3 flex items-end gap-3 flex-wrap">
|
<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">
|
<span className="text-xs text-gray-500 uppercase tracking-wide w-full">Fallback names</span>
|
||||||
Defaults for new adjustments <span className="normal-case tracking-normal text-gray-400">— existing adjustments keep the names they were written with</span>
|
|
||||||
</span>
|
|
||||||
{[
|
{[
|
||||||
['adjustment_segment', 'Adjustment segment', '99 - Adjustments'],
|
['adjustment_segment', 'Adjustment segment', '99 - Adjustments'],
|
||||||
['adjustment_bucket', 'Adjustment bucket', '04 - Forecast'],
|
['adjustment_bucket', 'Adjustment bucket', '04 - Forecast'],
|
||||||
|
['unlabeled_load', 'Unlabeled load', 'Unlabeled'],
|
||||||
].map(([field, label, builtin]) => (
|
].map(([field, label, builtin]) => (
|
||||||
<div key={field} className="flex flex-col gap-1">
|
<div key={field} className="flex flex-col gap-1">
|
||||||
<label className="text-xs text-gray-500">{label}</label>
|
<label className="text-xs text-gray-500">{label}</label>
|
||||||
@ -970,14 +961,10 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
|||||||
<input
|
<input
|
||||||
defaultValue={entry.label || ''}
|
defaultValue={entry.label || ''}
|
||||||
key={`label-${entry.id}-${entry.label || ''}`}
|
key={`label-${entry.id}-${entry.label || ''}`}
|
||||||
onBlur={e => {
|
onBlur={e => saveLogField(entry, 'label', e.target.value)}
|
||||||
const typed = e.target.value
|
|
||||||
if (!typed.trim()) e.target.value = entry.label || ''
|
|
||||||
saveLogField(entry, 'label', typed)
|
|
||||||
}}
|
|
||||||
readOnly={!canEdit(entry)}
|
readOnly={!canEdit(entry)}
|
||||||
title={canEdit(entry) ? '' : `${entry.pf_user || 'Another account'} made this segment`}
|
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
|
className="w-full border border-transparent hover:border-gray-200
|
||||||
focus:border-blue-400 rounded px-1 py-0.5 text-xs
|
focus:border-blue-400 rounded px-1 py-0.5 text-xs
|
||||||
focus:outline-none bg-transparent" />
|
focus:outline-none bg-transparent" />
|
||||||
@ -1333,12 +1320,12 @@ function SegmentForm({
|
|||||||
<div className="flex flex-col gap-1 max-w-xs">
|
<div className="flex flex-col gap-1 max-w-xs">
|
||||||
<label className="text-xs text-gray-500">Label</label>
|
<label className="text-xs text-gray-500">Label</label>
|
||||||
<input disabled={disabled} value={segLabel} onChange={e => setSegLabel(e.target.value)}
|
<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>
|
||||||
<div className="flex flex-col gap-1 max-w-xs">
|
<div className="flex flex-col gap-1 max-w-xs">
|
||||||
<label className="text-xs text-gray-500">Counts toward</label>
|
<label className="text-xs text-gray-500">Counts toward</label>
|
||||||
<input disabled={disabled} value={segBucket} onChange={e => setSegBucket(e.target.value)}
|
<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`} />
|
className={`${baseInp} text-sm py-1.5`} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1 flex-1 max-w-xs">
|
<div className="flex flex-col gap-1 flex-1 max-w-xs">
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import useTheme from '../theme.jsx'
|
import useTheme from '../theme.jsx'
|
||||||
import LayoutMenu from '../components/LayoutMenu.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 useAuth from '../auth.jsx'
|
||||||
import OperationPanel, { effectiveScaleInputs } from '../components/OperationPanel.jsx'
|
import OperationPanel from '../components/OperationPanel.jsx'
|
||||||
import BridgeView from '../components/BridgeView.jsx'
|
import BridgeView from '../components/BridgeView.jsx'
|
||||||
|
|
||||||
// Perspective is bundled, not fetched at runtime. The /inline entrypoints embed the
|
// 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-datagrid'
|
||||||
import '@perspective-dev/viewer/themes'
|
import '@perspective-dev/viewer/themes'
|
||||||
|
|
||||||
// Slice keys that are not col_meta columns but are columns of the loaded table,
|
// Slice keys that are not col_meta columns: computed from pf.log when the rows are
|
||||||
// so a click on them narrows the ledger -- and therefore has to narrow the write.
|
// served, so they are real columns in the loaded table but have to be resolved back
|
||||||
// pf_segment / pf_bucket / pf_note / pf_op come from pf.log and are resolved back
|
// to log ids server-side. Mirrors COMPUTED_SLICE_COLS in lib/sql_generator.js.
|
||||||
// to log ids server-side; pf_iter / pf_user / pf_logid are forecast-table columns.
|
const COMPUTED_SLICE_COLS = new Set(['pf_segment', 'pf_bucket'])
|
||||||
// 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'])
|
|
||||||
|
|
||||||
// The unnamed last-used config stays in localStorage: it is per-browser session
|
// 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
|
// 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
|
// 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.
|
// layout cannot impose its author's theme on everyone who applies it.
|
||||||
delete c.theme
|
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
|
// Dead expressions go before the axis filter, not after: dropping them from
|
||||||
// `expressions` is what makes `ok()` reject them everywhere else.
|
// `expressions` is what makes `ok()` reject them everywhere else.
|
||||||
if (DEAD_ORDER_EXPRS.some(n => c.expressions?.[n] !== undefined)) {
|
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.
|
// names a column that no longer exists.
|
||||||
const DEAD_ORDER_EXPRS = ['pf_bucket_ord', 'pf_segment_ord', 'Bucket', 'Segment']
|
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 }) {
|
export default function Forecast({ sources = [], sourceId, versions = [], versionId, refreshSources }) {
|
||||||
const { dark } = useTheme()
|
const { dark } = useTheme()
|
||||||
const { user } = useAuth()
|
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
|
// you type in decides how the number is interpreted, and the other two rows of
|
||||||
// that measure are derived from it.
|
// that measure are derived from it.
|
||||||
const [scaleInputs, setScaleInputs] = useState({})
|
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
|
// which side of price x volume absorbs a dollar-only change: 'price' keeps
|
||||||
// volume flat, 'volume' keeps price flat. Mirrors the Excel form's
|
// volume flat, 'volume' keeps price flat. Mirrors the Excel form's
|
||||||
// Plug Price / Plug Volume choice.
|
// Plug Price / Plug Volume choice.
|
||||||
@ -193,9 +164,6 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
const [scaleNote, setScaleNote] = useState('')
|
const [scaleNote, setScaleNote] = useState('')
|
||||||
const [recodeSet, setRecodeSet] = useState({})
|
const [recodeSet, setRecodeSet] = useState({})
|
||||||
const [recodeNote, setRecodeNote] = 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 [cloneSet, setCloneSet] = useState({})
|
||||||
const [cloneScale, setCloneScale] = useState('1')
|
const [cloneScale, setCloneScale] = useState('1')
|
||||||
const [cloneNote, setCloneNote] = useState('')
|
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_scale_plug_v2', scalePlug) }, [scalePlug])
|
||||||
useEffect(() => { localStorage.setItem('pf_panel_open', panelOpen ? 'open' : 'closed') }, [panelOpen])
|
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
|
// Esc closes the panel — the usual way out of the floating window
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!panelOpen) return
|
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
|
// 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.
|
// config is the source of truth; this only exists so the buttons can render.
|
||||||
const [expandDepth, setExpandDepth] = useState(null)
|
const [expandDepth, setExpandDepth] = useState(null)
|
||||||
// The column hierarchy as the live config has it, for the Columns stepper.
|
// The column axis has no set_depth() — collapsing it means restoring a shorter
|
||||||
// Collapsing is split_by_depth now, so split_by itself is never truncated.
|
// split_by, so the full hierarchy has to be remembered separately to expand again.
|
||||||
const splitFullRef = useRef([])
|
const splitFullRef = useRef([])
|
||||||
|
// a collapse from the user rearranging split_by themselves
|
||||||
const initIdRef = useRef(0)
|
const initIdRef = useRef(0)
|
||||||
const modifierRef = useRef(false)
|
const modifierRef = useRef(false)
|
||||||
// the datagrid plugin element, for reading cell coordinates and driving its
|
// 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,
|
// 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
|
// 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.
|
// 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
|
const viewer = viewerRef.current
|
||||||
if (!viewer) return []
|
if (!viewer) return []
|
||||||
const cfg = await viewer.save()
|
const cfg = await viewer.save()
|
||||||
const view = await viewer.getView()
|
const view = await viewer.getView()
|
||||||
|
const groupBy = cfg.group_by || []
|
||||||
const base = cfg.filter || []
|
const base = cfg.filter || []
|
||||||
const rows = await view.to_json({ start_row: win.start_row, end_row: win.end_row })
|
const rows = await view.to_json({ start_row: win.start_row, end_row: win.end_row })
|
||||||
if (!rows.length) return []
|
if (!rows.length) return []
|
||||||
@ -415,20 +379,33 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
const c0 = win.start_col ?? 0
|
const c0 = win.start_col ?? 0
|
||||||
const c1 = win.end_col ?? userKeys.length
|
const c1 = win.end_col ?? userKeys.length
|
||||||
const out = []
|
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++) {
|
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++) {
|
for (let c = c0; c < c1; c++) {
|
||||||
const key = userKeys[c]
|
const key = userKeys[c]
|
||||||
if (!key) continue
|
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],
|
const slice = sliceFromFilters([...base, ...rowFilters, ...colFilters],
|
||||||
(cfg.columns || []).filter(Boolean))
|
(cfg.columns || []).filter(Boolean))
|
||||||
// a total cell counts when its column or the pivot's filter narrows it;
|
if (!Object.keys(slice).length) continue
|
||||||
// one nothing narrows would mean the whole version (see perspective-click)
|
|
||||||
if (!Object.keys(slice).length && !base.length) continue
|
|
||||||
const y = win.start_row + i
|
const y = win.start_row + i
|
||||||
out.push({ slice, area: { x0: c, x1: c, y0: y, y1: y } })
|
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
|
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
|
// 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.
|
// traversal, and a slow redraw of a highlight is not worth having.
|
||||||
if (!n || n > MAX_RELOCATE_ROWS) { areasRef.current.clear(); paintAreas(); return }
|
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 live = new Set(slicesRef.current.map(sliceKey))
|
||||||
const next = new Map()
|
const next = new Map()
|
||||||
for (const { slice, area } of found) {
|
for (const { slice, area } of found) {
|
||||||
@ -609,12 +586,9 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
...Object.entries(sliceObj)
|
...Object.entries(sliceObj)
|
||||||
.filter(([col]) => COMPUTED_SLICE_COLS.has(col) || dimNames.has(col) || dateNames.has(col)
|
.filter(([col]) => COMPUTED_SLICE_COLS.has(col) || dimNames.has(col) || dateNames.has(col)
|
||||||
|| schema[col] !== undefined)
|
|| schema[col] !== undefined)
|
||||||
// Only an `==` on the same value is a repeat. Any other filter on the
|
// a cell inside the filtered view cannot contradict it, so a repeated
|
||||||
// column is wider than the cell -- `partd contains 'Keystone'` with one
|
// column is the same predicate twice and harmless
|
||||||
// part clicked -- and dropping the cell's own predicate for it totalled
|
.filter(([col]) => !viewFilter.some(f => f[0] === col))
|
||||||
// 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 null is filtered with `is null`, not `== null`: the engine matches
|
// 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
|
// on type, and a null threshold against a string column is the same
|
||||||
// dropped predicate that widened the slice in the first place. The
|
// 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.
|
// localStorage and comes back when there are rows again.
|
||||||
const saved = rowCount > 0 ? localStorage.getItem(LAYOUT_KEY(vid)) : null
|
const saved = rowCount > 0 ? localStorage.getItem(LAYOUT_KEY(vid)) : null
|
||||||
if (rowCount === 0) {
|
if (rowCount === 0) {
|
||||||
adoptTotals(DEFAULT_TOTALS)
|
|
||||||
await viewer.restore({ settings: false, plugin_config: { edit_mode: 'SELECT_REGION' } })
|
await viewer.restore({ settings: false, plugin_config: { edit_mode: 'SELECT_REGION' } })
|
||||||
} else if (saved) {
|
} else if (saved) {
|
||||||
const parsed = JSON.parse(saved)
|
const { table: _t, ...rest } = cleanLayout(JSON.parse(saved), validCols)
|
||||||
adoptTotals(totalsOf(parsed))
|
|
||||||
const { table: _t, ...rest } = cleanLayout(parsed, validCols)
|
|
||||||
const cfg = { ...rest, plugin_config: { ...(rest.plugin_config || {}), edit_mode: 'SELECT_REGION' } }
|
const cfg = { ...rest, plugin_config: { ...(rest.plugin_config || {}), edit_mode: 'SELECT_REGION' } }
|
||||||
await viewer.restore(cfg)
|
await viewer.restore(cfg)
|
||||||
// split_full is the legacy key, from when collapsing truncated split_by
|
// 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 defaultRow = defaults.find(l => l.version_id != null) || defaults[0]
|
||||||
const sourceDefault = defaultRow?.config
|
const sourceDefault = defaultRow?.config
|
||||||
let cfg
|
let cfg
|
||||||
adoptTotals(totalsOf(sourceDefault))
|
|
||||||
if (sourceDefault && Object.keys(sourceDefault).length > 0) {
|
if (sourceDefault && Object.keys(sourceDefault).length > 0) {
|
||||||
const { table: _t, ...rest } = cleanLayout(sourceDefault, validCols)
|
const { table: _t, ...rest } = cleanLayout(sourceDefault, validCols)
|
||||||
cfg = { ...rest, plugin_config: { ...(rest.plugin_config || {}), edit_mode: 'SELECT_REGION' } }
|
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
|
// dirty dot has something to compare against. Read back rather than
|
||||||
// reusing cfg: restore() normalises, so the live config is what a later
|
// reusing cfg: restore() normalises, so the live config is what a later
|
||||||
// save() will be compared with.
|
// 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
|
// 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)
|
const measures = (config.columns || []).filter(Boolean)
|
||||||
let s
|
let s
|
||||||
if (cell) {
|
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)
|
s = sliceFromFilters([...(config.filter || []), ...rowFilters, ...colFilters], measures)
|
||||||
} else {
|
} else {
|
||||||
s = sliceFromFilters((detail.config || {}).filter || [], measures)
|
s = sliceFromFilters((detail.config || {}).filter || [], measures)
|
||||||
}
|
}
|
||||||
// A total cell is its displayed amount like any other: the grand-total
|
if (!Object.keys(s).length) return
|
||||||
// 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
|
|
||||||
// the CustomEvent carries no modifier flags, so read them off the
|
// the CustomEvent carries no modifier flags, so read them off the
|
||||||
// mousedown that produced it (captured on window below)
|
// mousedown that produced it (captured on window below)
|
||||||
const additive = modifierRef.current
|
const additive = modifierRef.current
|
||||||
@ -1260,33 +1230,11 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
style.textContent = `
|
style.textContent = `
|
||||||
td.pf-group-start { border-left: 2px solid currentColor; opacity: 1; }
|
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); }
|
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)
|
;(root.head || root).appendChild(style)
|
||||||
}
|
}
|
||||||
|
|
||||||
table.addStyleListener(() => {
|
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')
|
const body = table.querySelectorAll('tbody td')
|
||||||
// The deepest column path is a leaf; anything shorter is an aggregate of
|
// 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.
|
// 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)
|
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
|
let prevGroup = null
|
||||||
for (const [td, meta] of metas) {
|
for (const [td, meta] of metas) {
|
||||||
td.classList.remove('pf-group-start', 'pf-subtotal')
|
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 }
|
if (!Array.isArray(path) || !path.length) { prevGroup = null; continue }
|
||||||
const named = path.filter(notBlank)
|
const named = path.filter(notBlank)
|
||||||
const group = named[0]
|
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')
|
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;
|
white-space: nowrap !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The grand-total column, hidden unless the layout asks for it
|
th.psp-split-total:not(.psp-split-subtotal),
|
||||||
* (data-pf-col-total, set by adoptTotals and the style listener). */
|
td.psp-split-total:not(.psp-split-subtotal) {
|
||||||
regular-table:not([data-pf-col-total]) th.psp-split-total:not(.psp-split-subtotal),
|
|
||||||
regular-table:not([data-pf-col-total]) td.psp-split-total:not(.psp-split-subtotal) {
|
|
||||||
visibility: hidden !important;
|
visibility: hidden !important;
|
||||||
width: 0 !important;
|
width: 0 !important;
|
||||||
min-width: 0 !important;
|
min-width: 0 !important;
|
||||||
@ -1658,40 +1595,10 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
const viewer = viewerRef.current
|
const viewer = viewerRef.current
|
||||||
if (!viewer) return null
|
if (!viewer) return null
|
||||||
const cfg = await viewer.save()
|
const cfg = await viewer.save()
|
||||||
// Both depths are already in cfg, straight from save(). The one thing of
|
// Both depths are already in cfg, straight from save(). Nothing of ours needs
|
||||||
// ours that travels beside it is the totals choice, which the engine has no
|
// to travel beside it any more -- split_full existed only because collapsing
|
||||||
// field for (see PF_TOTALS). split_full used to as well, only because
|
// truncated split_by and the discarded levels had to be remembered.
|
||||||
// collapsing truncated split_by and the discarded levels had to be
|
return cfg
|
||||||
// remembered.
|
|
||||||
return { ...cfg, [PF_TOTALS]: { ...totalsRef.current } }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show or hide the grand totals. Both are DOM-side, so this is a redraw, not
|
|
||||||
// a rebuild: the row is tagged in the style listener, the column hidden by
|
|
||||||
// GRID_CSS unless the table carries data-pf-col-total. The attribute is set
|
|
||||||
// here as well as per draw so the draw that follows already sizes the column.
|
|
||||||
function adoptTotals(t) {
|
|
||||||
totalsRef.current = t
|
|
||||||
setTotals(t)
|
|
||||||
const table = gridRef.current?.regular_table
|
|
||||||
if (!table) return
|
|
||||||
table.toggleAttribute('data-pf-col-total', t.col)
|
|
||||||
table.draw()
|
|
||||||
}
|
|
||||||
|
|
||||||
// A toolbar toggle. It changes the layout without touching the engine, so no
|
|
||||||
// perspective-config-update follows -- the persist and the dirty dot that
|
|
||||||
// handler would have done are done here.
|
|
||||||
async function toggleTotal(axis, on) {
|
|
||||||
adoptTotals({ ...totalsRef.current, [axis]: on })
|
|
||||||
try {
|
|
||||||
const cfg = await captureConfig()
|
|
||||||
if (!cfg) return
|
|
||||||
await persistLayout(versionId, cfg)
|
|
||||||
setLayoutDirty(!!activeConfigRef.current && !sameConfig(cfg, activeConfigRef.current))
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[toggleTotal]', err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function persistLayout(vid, cfg) {
|
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' }
|
cfg.plugin_config = { ...(cfg.plugin_config || {}), edit_mode: 'SELECT_REGION' }
|
||||||
pivotBusy(true)
|
pivotBusy(true)
|
||||||
try {
|
try {
|
||||||
adoptTotals(totalsOf(layout.config))
|
|
||||||
await viewer.restore(cfg)
|
await viewer.restore(cfg)
|
||||||
await viewer.flush()
|
await viewer.flush()
|
||||||
adoptSplit(cfg.split_full?.length ? cfg.split_full : cfg.split_by,
|
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() {
|
function resetLayout() {
|
||||||
localStorage.removeItem(LAYOUT_KEY(versionId))
|
localStorage.removeItem(LAYOUT_KEY(versionId))
|
||||||
markClean(null, null)
|
markClean(null, null)
|
||||||
adoptTotals(DEFAULT_TOTALS)
|
|
||||||
const viewer = viewerRef.current
|
const viewer = viewerRef.current
|
||||||
if (viewer) viewer.restore({ settings: true })
|
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.
|
// equality: a view filtered to sseas_e <= 2027 has no slice form at all.
|
||||||
const body = buildPayload(op)
|
const body = buildPayload(op)
|
||||||
if (!body) return
|
if (!body) return
|
||||||
// an empty slice is a total cell, and means "everything the pivot's filter
|
if (body.slices.some(sl => !Object.keys(sl).length)) {
|
||||||
// shows" -- so it needs that filter, or it would be the whole version
|
flash('No dimension or date columns in slice — check col_meta', 'error'); return
|
||||||
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 (op === 'scale') {
|
if (op === 'scale') {
|
||||||
const has = ['target_value','target_units','target_price',
|
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)
|
.some(k => body[k] !== undefined)
|
||||||
if (!has) { flash('Enter a target or increment', 'error'); return }
|
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
|
// 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
|
// 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* --
|
// no dimension override at all. It only needs to be doing *something* --
|
||||||
// an override, a shift, or a factor.
|
// an override, a shift, or a factor.
|
||||||
if (op === 'recode' && !Object.keys(body.set || {}).length && !body.date_offset) {
|
if (op === 'recode' && !Object.keys(body.set || {}).length) {
|
||||||
flash('Enter at least one new dimension value, or a date shift', 'error')
|
flash('Enter at least one new dimension value', 'error')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// A clone that changes nothing is only pointless when the source and the
|
// 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.
|
// The rows just moved under the highlight — see relocateAreas.
|
||||||
relocateAreas()
|
relocateAreas()
|
||||||
if (op === 'scale') { setScaleInputs({}); setScaleNote('') }
|
if (op === 'scale') { setScaleInputs({}); setScaleNote('') }
|
||||||
if (op === 'recode') { setRecodeNote(''); setRecodeOffset('') }
|
if (op === 'recode') { setRecodeNote('') }
|
||||||
if (op === 'clone') { setCloneNote(''); setCloneScale('1') }
|
if (op === 'clone') { setCloneNote(''); setCloneScale('1') }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
flash(err.message, 'error')
|
flash(err.message, 'error')
|
||||||
@ -1976,7 +1879,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
const viewFilter = viewFilterRef.current || []
|
const viewFilter = viewFilterRef.current || []
|
||||||
if (!slices.length) return null
|
if (!slices.length) return null
|
||||||
// Two clicked cells can differ only by a column the operation cannot filter on
|
// 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
|
// the same effective slice, and sending it twice would apply the change twice
|
||||||
// under apply_mode 'each'. Collapse duplicates before they reach the API.
|
// under apply_mode 'each'. Collapse duplicates before they reach the API.
|
||||||
const seen = new Set()
|
const seen = new Set()
|
||||||
@ -2012,10 +1915,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
return (raw !== '' && raw != null && isFinite(n)) ? n : null
|
return (raw !== '' && raw != null && isFinite(n)) ? n : null
|
||||||
}
|
}
|
||||||
|
|
||||||
// the same two inputs the panel shows -- with all three boxes typed, the
|
const v = scaleInputs.value, u = scaleInputs.units, p = scaleInputs.price
|
||||||
// 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 vn = v && num(v.raw), un = u && num(u.raw), pn = p && num(p.raw)
|
const vn = v && num(v.raw), un = u && num(u.raw), pn = p && num(p.raw)
|
||||||
|
|
||||||
if (vn != null) {
|
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
|
// sent when dollars are the sole input; naming units or price has already
|
||||||
// answered the question.
|
// answered the question.
|
||||||
if (vn != null && un == null && pn == null) body.plug = scalePlug
|
if (vn != null && un == null && pn == null) body.plug = scalePlug
|
||||||
if (scaleExpectedRef.current) body.expected = scaleExpectedRef.current
|
|
||||||
} else if (op === 'recode') {
|
} else if (op === 'recode') {
|
||||||
const set = Object.fromEntries(Object.entries(recodeSet).filter(([, v]) => v.trim()))
|
const set = Object.fromEntries(Object.entries(recodeSet).filter(([, v]) => v.trim()))
|
||||||
body = { ...body, note: recodeNote || undefined, set }
|
body = { ...body, note: recodeNote || undefined, set }
|
||||||
const off = recodeOffset.trim()
|
|
||||||
if (off && off !== '0 days') body.date_offset = off
|
|
||||||
} else if (op === 'clone') {
|
} else if (op === 'clone') {
|
||||||
const set = Object.fromEntries(Object.entries(cloneSet).filter(([, v]) => v.trim()))
|
const set = Object.fromEntries(Object.entries(cloneSet).filter(([, v]) => v.trim()))
|
||||||
body = { ...body, note: cloneNote || undefined, set, scale: parseFloat(cloneScale) || 1 }
|
body = { ...body, note: cloneNote || undefined, set, scale: parseFloat(cloneScale) || 1 }
|
||||||
@ -2166,14 +2063,13 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
applyMode, setApplyMode,
|
applyMode, setApplyMode,
|
||||||
currentTotals,
|
currentTotals,
|
||||||
activeOp, setActiveOp,
|
activeOp, setActiveOp,
|
||||||
scaleInputs, setScaleInputs, scaleExpectedRef,
|
scaleInputs, setScaleInputs,
|
||||||
scalePlug, setScalePlug,
|
scalePlug, setScalePlug,
|
||||||
targetBasis, setTargetBasis,
|
targetBasis, setTargetBasis,
|
||||||
opTag, setOpTag, knownTags, logMeta,
|
opTag, setOpTag, knownTags, logMeta,
|
||||||
scaleNote, setScaleNote,
|
scaleNote, setScaleNote,
|
||||||
recodeSet, setRecodeSet,
|
recodeSet, setRecodeSet,
|
||||||
recodeNote, setRecodeNote,
|
recodeNote, setRecodeNote,
|
||||||
recodeOffset, setRecodeOffset,
|
|
||||||
cloneSet, setCloneSet,
|
cloneSet, setCloneSet,
|
||||||
cloneScale, setCloneScale,
|
cloneScale, setCloneScale,
|
||||||
cloneFrom, setCloneFrom, cloneOffset, setCloneOffset, cloneSources,
|
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.
|
{/* ── View: everything here changes how the loaded rows are displayed.
|
||||||
Nothing in this zone talks to the server or alters the forecast,
|
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
|
which is the whole reason it is fenced off from the zone on the
|
||||||
distance between them is the separation: each zone used to be an
|
right — the two used to sit in one undifferentiated row of
|
||||||
outlined box as well, the view one on a gray-50 tint, and the two
|
buttons separated by identical rules, so "Fit" and "Bridge"
|
||||||
boxes read as siblings while the tint was too faint to see. ── */}
|
read as peers when one is a column width and the other opens
|
||||||
<div className="flex items-center gap-4 flex-wrap">
|
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
|
<LayoutMenu
|
||||||
layouts={layouts}
|
layouts={layouts}
|
||||||
@ -2229,12 +2127,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
<>
|
<>
|
||||||
<div className="pf-group-rule" />
|
<div className="pf-group-rule" />
|
||||||
<DepthButtons label="Rows" levels={groupFull} depth={expandDepth}
|
<DepthButtons label="Rows" levels={groupFull} depth={expandDepth}
|
||||||
onPick={applyDepth} totalLabel="Total">
|
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>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -2242,12 +2135,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
<>
|
<>
|
||||||
<div className="pf-group-rule" />
|
<div className="pf-group-rule" />
|
||||||
<DepthButtons label="Columns" levels={splitFull} depth={splitDepth}
|
<DepthButtons label="Columns" levels={splitFull} depth={splitDepth}
|
||||||
onPick={applySplitDepth} totalLabel="Total">
|
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>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -2256,7 +2144,8 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
{/* ── Forecast: reaches the server, or opens something over the pivot.
|
{/* ── Forecast: reaches the server, or opens something over the pivot.
|
||||||
Refresh discards the current view's state and re-runs the load;
|
Refresh discards the current view's state and re-runs the load;
|
||||||
Operations is where the version is actually written to. ── */}
|
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}
|
<button onClick={() => initViewer(versionId, sourceId, layouts)} disabled={loading || !versionId}
|
||||||
title="Re-run the load from the database"
|
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">
|
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
|
Bridge
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => setPanelOpen(o => !o)} disabled={!versionId}
|
<button onClick={() => setPanelOpen(o => !o)} disabled={!versionId}
|
||||||
aria-pressed={panelOpen}
|
|
||||||
title={panelOpen ? 'Hide the operations panel (Esc)' : 'Show the operations panel'}
|
title={panelOpen ? 'Hide the operations panel (Esc)' : 'Show the operations panel'}
|
||||||
className={`border rounded px-2 py-0.5 disabled:opacity-40 transition-colors ${
|
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'}`}>
|
panelOpen ? 'border-blue-300 text-blue-600 bg-blue-50' : 'border-gray-200 text-gray-500 hover:bg-gray-50'}`}>
|
||||||
{/* Open is shown as pressed, not by relabelling to "Hide panel" in
|
{panelOpen ? 'Hide panel' : 'Operations'}
|
||||||
blue — blue outline is Save's colour, an action, and this is a
|
|
||||||
state. */}
|
|
||||||
Operations
|
|
||||||
{!panelOpen && hasSlice && (
|
{!panelOpen && hasSlice && (
|
||||||
<span className="ml-1 text-blue-500">({slices.length})</span>
|
<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
|
// 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.
|
// 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
|
// A blank on the *column* axis cannot be recovered at all: a column name is its
|
||||||
// different text: a column name is its values joined with '|', each rendered by
|
// values joined with '|', and a null joins as the empty string, so the two are
|
||||||
// t_tscalar::to_string, which gives the empty string for '' and the literal
|
// the same text by the time it reaches us. Those cells are refused rather than
|
||||||
// "null" for a null (scalar.cpp -- an invalid scalar returns {"null"} before the
|
// widened -- `colBlank` says so -- since a slice missing a split_by level means
|
||||||
// type switch). So "null" is read back as null and '' as ''. Both used to be
|
// every value of it.
|
||||||
// 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.
|
|
||||||
function cellFilters(cfg, row, colKey) {
|
function cellFilters(cfg, row, colKey) {
|
||||||
const groupBy = cfg.group_by || []
|
const groupBy = cfg.group_by || []
|
||||||
const splitBy = cfg.split_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))
|
.map((col, ix) => (ix < path.length && path[ix] !== undefined ? [col, '==', path[ix]] : null))
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
let colFilters = []
|
let colFilters = []
|
||||||
|
let colBlank = false
|
||||||
if (colKey != null) {
|
if (colKey != null) {
|
||||||
// A column name is dimension values joined by | with the measure last, and
|
// 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
|
// 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
|
if (v === undefined) return null
|
||||||
// the measure name read positionally as a dimension value
|
// the measure name read positionally as a dimension value
|
||||||
if (META_COL_RE.test(v)) return null
|
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)
|
.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
|
// 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)
|
const one = (sl) => Object.entries(sl)
|
||||||
.map(([k, v]) => `${k} = ${fmtSliceValue(v)}`)
|
.map(([k, v]) => `${k} = ${fmtSliceValue(v)}`)
|
||||||
.join(' · ')
|
.join(' · ')
|
||||||
// an empty slice is a total cell: everything the pivot's filter showed
|
if (!Array.isArray(slice)) return Object.keys(slice).length ? [one(slice)] : []
|
||||||
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]
|
|
||||||
|
|
||||||
const list = slice.filter(sl => sl && Object.keys(sl).length)
|
const list = slice.filter(sl => sl && Object.keys(sl).length)
|
||||||
if (list.length <= 1) return list.map(one)
|
if (list.length <= 1) return list.map(one)
|
||||||
|
|||||||
20
ui/vendor/README.md
vendored
20
ui/vendor/README.md
vendored
@ -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
|
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
|
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` sends `d + 1`.
|
||||||
`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.)
|
|
||||||
|
|
||||||
The patch is already committed on the fork's build branch (see
|
To apply:
|
||||||
`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:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./ui/vendor/rebuild-perspective.sh # from the fork branch
|
cd $PSP_DIR # default ~/perspective
|
||||||
# then re-resolve by path, as above -- a plain `npm install` keeps the old code
|
git apply /path/to/pf_app/ui/vendor/0001-apply-depth-fields-on-config-update.patch
|
||||||
|
./ui/vendor/rebuild-perspective.sh
|
||||||
# only on a fresh upstream checkout:
|
cd ui && npm install
|
||||||
cd $PSP_DIR && git apply /path/to/pf_app/ui/vendor/0001-apply-depth-fields-on-config-update.patch
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Host tools this tree needs
|
### Host tools this tree needs
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user