diff --git a/CLAUDE.md b/CLAUDE.md index 9591095..8df147c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,7 @@ Data transport architecture options: `pf_perspective_options.md` - **Backend:** Node.js / Express (`server.js`) - **Database:** PostgreSQL — isolated `pf` schema - **Frontend:** React + Vite + Tailwind CSS in `ui/`; built output lands in `public/app/` -- **Pivot:** [Perspective](https://github.com/perspective-dev/perspective) (`@perspective-dev/*` distribution, **not** FINOS `@finos/perspective`) 4.4.0 loaded from CDN at runtime — see `PERSPECTIVE.md` for config/deploy guidance +- **Pivot:** [Perspective](https://github.com/perspective-dev/perspective) (`@perspective-dev/*` distribution, **not** FINOS `@finos/perspective`) 5.2.0, **bundled inline via the `/inline` entrypoints — never from a CDN** (the 4.x CDN bundle resolves its server WASM to an unversioned path and silently pulls whatever is newest). See `PERSPECTIVE.md`. - **Dev:** `npm run dev` (nodemon) in root; `npm run build` in `ui/` --- @@ -38,9 +38,12 @@ ui/src/ views/ Setup.jsx DB browser, source registration, col_meta editor Baseline.jsx Version management, baseline workbench, reference load - Forecast.jsx Perspective pivot + operation panel (Scale/Recode/Clone) + Forecast.jsx Perspective pivot, selection handling, operation dispatch + components/ + OperationPanel.jsx The adjustment workbench — ledger + scale/recode/clone forms + BridgeView.jsx Baseline → current waterfall by tag (exports buildSteps/layoutSteps) Sidebar.jsx 3-step collapsible nav - StatusBar.jsx Source · version · row count · status + StatusBar.jsx Source · version · write target · row counts · theme Timeline.jsx Date-range preview bar for baseline segments ``` @@ -78,7 +81,15 @@ POST to `/api/versions/:id/{scale|recode|clone}` → SQL executed with `RETURNIN ## Slice mechanics -When the user clicks a pivot cell, `perspective-click` fires. The handler in `Forecast.jsx` extracts `[col, '==', value]` filters from `detail.config.filter` — only `role = dimension` columns are kept as the slice. This slice populates the operation panel and is sent as the `slice` object in all operation POST bodies. +When the user clicks a pivot cell, `perspective-click` fires. The handler in `Forecast.jsx` extracts `[col, '==', value]` filters from `detail.config.filter` — only `role = dimension` and `role = date` columns are kept as the slice. A plain click replaces the selection; ctrl/⌘/shift-click toggles a slice in or out of it, so the panel holds a **list** of slices sent as `slices` in operation POST bodies (the single `slice` object is still accepted server-side). + +Dragging across a block of cells selects a region. The datagrid runs in `edit_mode: SELECT_REGION` (forced on restore, so a saved layout can't switch it off) and reports the region as a `perspective-select` event carrying a Perspective **ViewWindow** — `{ start_row, end_row, start_col, end_col }`, *not* the per-row `insertConfigs` payload an older API used. It fires on every mouseover as the region grows, so the handler only records the latest window and a window-level `mouseup` commits it. A single-cell region is ignored there: `perspective-click` already owns plain and modifier clicks, and handling it in both places would undo a ctrl-click toggle. + +Turning a region back into slices re-derives, per cell, the same filters Perspective attaches to a click — row dimensions from the view's `__ROW_PATH__` (raw values, so dates stay epoch millis rather than whatever the grid formatted them as), column dimensions from the split_by segments of the column name. The grand-total row resolves to no dimension at all and is skipped; that would mean "the whole version". + +**Selection highlight.** The datagrid highlights whatever sits in its own `model._selection_state.selected_areas`, and wipes that list on every mousedown — so a multi-slice selection built up over several ctrl-clicks would only ever show the last cell. `Forecast.jsx` keeps `areasRef`, a `sliceKey -> rectangles` map parallel to `slices`, and an effect pushes the full set back and redraws after every change. Deselecting anywhere (ctrl-click, the panel's ×, Clear selection) prunes the map by live slice key, so the grid and the panel can't disagree. + +`pf_iter` is not a col_meta column, so it is stripped when a slice is built: two cells differing only by iter band produce the same effective slice. Duplicates are collapsed before the request — without that, `apply_mode: each` would apply the same change twice. **Limitation:** computed columns created by Perspective's split_by (e.g. Month, YearDate) don't map back to raw rows — only native dimension columns work for slice extraction. diff --git a/PERSPECTIVE.md b/PERSPECTIVE.md index 7d19abe..da40019 100644 --- a/PERSPECTIVE.md +++ b/PERSPECTIVE.md @@ -47,8 +47,27 @@ import '@perspective-dev/viewer/themes' - **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. (pf_app currently does this in - `ui/src/views/Forecast.jsx` — migrating off it is the main open item.) + 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 `` in `index.html` — so it's bundled and versioned too. @@ -60,7 +79,9 @@ The version choice is constrained by two hard facts about the `@perspective-dev` **(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. + 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. @@ -71,21 +92,47 @@ So you can have at most **two** of these three: |---|---| | 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 | +| 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: -- **Inline-bundled + charts** (dataflow's case) → `^4.5.1` viewer/client/datagrid **+ - `^4.4.1` viewer-d3fc`. This is a deliberate, necessary mixed-version pair, *not* an - accident — it's the only combo that keeps both. Accept it; pin the lockfile and gate - bumps on the smoke test (§7). Do **not** "fix" it by pinning everything to 4.4.1 — the - build breaks (`"./inline" is not exported`). +> ### ⚠️ 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 work; you give up `/inline` bundling. + **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 (then a -fully-coherent inline-capable 4.5.x suite becomes possible). +`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). --- @@ -122,6 +169,74 @@ 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]` (`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', ['']]` 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: @@ -142,6 +257,16 @@ to client-side heuristics — acceptable only at small scale. 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, and doesn't currently have it** (verified 2026-08, + pf_app). Both existing `cleanLayout()` implementations filter + `columns`/`group_by`/`split_by`/`sort`/`filter` but leave `aggregates` untouched. That + is harmless *today* only because `viewer.save()` emits `aggregates: {}` until someone + sets one 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 should be dropped rather than the layout. + Add this guard as part of adopting §3a, not after. --- @@ -168,6 +293,13 @@ Run this whenever bumping **any** Perspective package or `apache-arrow`: (`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 @@ -183,17 +315,22 @@ Run this whenever bumping **any** Perspective package or `apache-arrow`: | | pf_app | dataflow | Target | |---|---|---|---| -| Loader | CDN (runtime) | npm `/inline` | **npm `/inline`** | -| Version | 4.4.0 (CDN URLs) | 4.5.1 viewer/client + 4.4.1 d3fc | depends on loader (§2) | +| 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` (client built vs 17) | n/a | pin exact, match WASM | +| `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. Note this forces the §2 choice: going npm-`/inline` means - 4.5.x viewer/client + 4.4.1 d3fc (same pair as dataflow); or stay coherent at 4.4.x and - load via the `.` entry instead of `/inline`. Either way, pin + commit the lockfile, and - add deploy automation (systemd + nginx + `deploy.sh`). +- ~~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`). diff --git a/lib/sql_generator.js b/lib/sql_generator.js index fdba4d1..c9942e8 100644 --- a/lib/sql_generator.js +++ b/lib/sql_generator.js @@ -251,6 +251,35 @@ function buildWhere(slice, dimCols) { return parts.length ? parts.join('\nAND ') : 'TRUE'; } +// build a WHERE clause spanning several slices — an OR of AND-groups. +// A union of slices cannot be flattened into one IN list per column: slices +// {Region:East, State:NY} and {Region:West, State:CA} would become +// Region IN (East,West) AND State IN (NY,CA), which also matches East/CA. +function buildWhereAny(slices, dimCols) { + const list = (slices || []).filter(s => s && Object.keys(s).length > 0); + if (list.length === 0) return 'TRUE'; + if (list.length === 1) return buildWhere(list[0], dimCols); + + const groups = list + .map(s => buildWhere(s, dimCols)) + .filter(w => w !== 'TRUE'); + + // any slice that reduced to TRUE selects everything, so the union does too + if (groups.length !== list.length) return 'TRUE'; + + // outer parens matter: the caller appends `AND pf_iter NOT IN (...)`, + // and AND binds tighter than OR + return `(${groups.map(g => `(${g.replace(/\n/g, ' ')})`).join('\n OR ')})`; +} + +// the bare predicate for "this row participates in operations", for use in a +// FILTER clause where the excluded rows still need to be counted separately +function buildExcludePredicate(excludeIters) { + if (!excludeIters || excludeIters.length === 0) return 'TRUE'; + const list = excludeIters.map(i => `'${esc(i)}'`).join(', '); + return `pf_iter NOT IN (${list})`; +} + // build AND iter NOT IN (...) from a version's exclude_iters array function buildExcludeClause(excludeIters) { if (!excludeIters || excludeIters.length === 0) return ''; @@ -309,4 +338,4 @@ function esc(val) { return String(val).replace(/'/g, "''"); } -module.exports = { generateSQL, applyTokens, buildWhere, buildExcludeClause, buildSetClause, buildFilterClause, esc }; +module.exports = { generateSQL, applyTokens, buildWhere, buildWhereAny, buildExcludeClause, buildExcludePredicate, buildSetClause, buildFilterClause, esc }; diff --git a/pf_spec.md b/pf_spec.md index 62b2362..b6c3cf8 100644 --- a/pf_spec.md +++ b/pf_spec.md @@ -113,10 +113,20 @@ CREATE TABLE pf.log ( operation text NOT NULL, -- 'baseline' | 'reference' | 'scale' | 'recode' | 'clone' slice jsonb, -- the WHERE conditions that defined the selection params jsonb, -- operation parameters (increments, new values, scale factor, etc.) - note text -- user-provided comment + note text, -- user-provided comment + tag text -- initiative label, e.g. 'reduce_spend' ); ``` +`tag` groups adjustments into initiatives. It is what the bridge walks: every entry +carrying the same tag becomes one step from baseline to current. Both `note` and `tag` +are annotations — they never affect forecast rows — so both stay editable after the +fact via `PATCH /api/log/:logid`. + +Tags are written by a follow-up `UPDATE` after the operation runs, not by the generated +SQL. The templates in `pf.sql` are stored per source, so adding a `{{tag}}` token would +silently stop recording tags for any source that had not re-run *Generate SQL*. + ### `pf.fc_{tname}_{version_id}` (dynamic, one per version) Created when a version is created. Mirrors source table dimension/value/date columns (and units if configured) plus any `dim_period_col`-derived dimension columns, plus forecast metadata. Contains both operational rows (`pf_iter = 'baseline' | 'scale' | 'recode' | 'clone'`) and reference rows (`pf_iter = 'reference'`). @@ -318,35 +328,79 @@ All operations share a common request envelope: ```json { - "pf_user": "paul.trowbridge", - "note": "optional comment", - "slice": { - "channel": "WHS", - "geography": "WEST" - } + "pf_user": "paul.trowbridge", + "note": "optional comment", + "tag": "reduce_spend", + "slices": [ { "channel": "WHS", "geography": "WEST" }, + { "channel": "DIR", "geography": "EAST" } ], + "apply_mode": "prorate" } ``` -`slice` keys must be `role = 'dimension'` columns per col_meta. Stored in `pf.log` as the implicit link to affected rows. +- `slices` — one or more slices. The legacy single `slice` object is still accepted and + treated as a one-entry list. +- `apply_mode` — `prorate` (default) treats the selection as one pool; `each` runs the + operation once per slice, producing one log entry per slice so they can be undone + separately. With a single slice the two are identical. +- `tag` — optional initiative label, stored on the log entry. + +Slice keys must be `role = 'dimension'` or `role = 'date'` columns per col_meta. A slice +naming none of them is **rejected**: unknown keys are dropped when building the WHERE +clause, so such a slice would otherwise reduce to `TRUE` and apply the operation to the +entire version. + +Several slices become an `OR` of `AND`-groups, not per-column `IN` lists — flattening +`{A:1,B:1}` and `{A:2,B:2}` into `A IN (1,2) AND B IN (1,2)` would also match `A:1,B:2`. +The result is parenthesised because callers append `AND pf_iter NOT IN (...)`, and `AND` +binds tighter than `OR`. #### Scale `POST /api/versions/:id/scale` ```json { - "pf_user": "paul.trowbridge", - "note": "10% volume lift Q3 West", - "slice": { "channel": "WHS", "geography": "WEST" }, - "value_incr": null, - "units_incr": 5000, - "pct": false + "pf_user": "paul.trowbridge", + "note": "10% volume lift Q3 West", + "tag": "volume_push", + "slices": [ { "channel": "WHS", "geography": "WEST" } ], + "apply_mode": "prorate", + "target_value": 12000, + "units_pct": 10, + "target_basis": "selected" } ``` -- `value_incr` / `units_incr` — absolute amounts to add (positive or negative). Either can be null. -- `pct: true` — treat as percentage of current slice total instead of absolute -- Excludes `exclude_iters` rows from the source selection -- Distributes increment proportionally across rows in the slice +Each measure is resolved **independently**, so a target on one and a percentage on the +other can be sent together. Per measure, exactly one of: + +| Field | Meaning | +|---|---| +| `target_value` / `target_units` | the total to end up with | +| `value_pct` / `units_pct` | a percentage of the current total | +| `value_incr` / `units_incr` | an absolute amount to add | +| `target_price` | target value/units ratio; holds units constant | + +The legacy global `pct: true` flag (meaning "the increments are percentages") is still +honoured. + +`target_basis` decides what a target or percentage measures against: + +- `adjustable` — only the rows the operation can write. +- `selected` (UI default) — everything the pivot shows for the slice, `exclude_iters` + rows included. Those rows cannot move, so the adjustable rows absorb the whole + difference and the pivot lands on the number you asked for. Without this, a target set + against a visible total overshoots by the excluded rows' contribution. + +Behaviour: + +- Excludes `exclude_iters` rows from the rows it writes, in every basis. +- Distributes the increment proportionally across rows in the slice. +- **Refuses to prorate a pool that nets to ~zero** — below 1% of gross. Each row's new + value is `(row / total) * increment`, so as the net approaches zero the multiplier + explodes and rows fly to extreme opposite values to reach the target. Offsetting + slices are the usual cause; `apply_mode: each` handles that correctly. +- Slices matching no rows, or already on target, are skipped and returned in + `slices_skipped` rather than silently counted as applied. - Inserts rows tagged `iter = 'scale'` #### Recode @@ -356,7 +410,7 @@ All operations share a common request envelope: { "pf_user": "paul.trowbridge", "note": "Part discontinued, replaced by new SKU", - "slice": { "part": "OLD-SKU-001" }, + "slices": [ { "part": "OLD-SKU-001" } ], "set": { "part": "NEW-SKU-002" } } ``` @@ -374,7 +428,7 @@ All operations share a common request envelope: { "pf_user": "paul.trowbridge", "note": "New customer win, similar profile to existing", - "slice": { "customer": "EXISTING CO", "channel": "DIR" }, + "slices": [ { "customer": "EXISTING CO", "channel": "DIR" } ], "set": { "customer": "NEW CO" }, "scale": 0.75 } @@ -391,6 +445,10 @@ All operations share a common request envelope: |--------|-------|-------------| | GET | `/api/versions/:id/log` | List all log entries for a version, newest first | | DELETE | `/api/log/:logid` | Undo: delete all forecast rows with this logid, then delete log entry | +| PATCH | `/api/log/:logid` | Edit `note` and/or `tag`. Branches on whether a field was sent, so `""` clears rather than being read as "leave alone" | +| GET | `/api/versions/:id/table-info` | Physical forecast table, source table, and live row counts by `pf_iter` | +| GET | `/api/versions/:id/bridge` | Baseline → current rolled up by tag | +| GET | `/api/sources/:id/tags` | Tags used on this source with use counts, newest first — feeds tag autocomplete | --- @@ -484,30 +542,47 @@ Segment 2 uses two OR groups; segment 3 has two AND conditions in one group. Any ### Forecast View -**Layout:** +**Layout:** the operation panel docks **bottom** (default), **right**, or **floats** over +the pivot (drag its header to move, corner grip to resize). Position and size persist to +`localStorage`. It closes via its header ×, `Esc`, or the toolbar toggle, which shows the +selection count while shut. + ``` ┌─────────────────────────────────────────────────────────────────┐ -│ [Version label] [Refresh] [Save layout] [Reset layout] │ -├──────────────────────────────────────┬──────────────────────────┤ -│ │ │ -│ Perspective Viewer │ Operation Panel │ -│ (interactive pivot web component) │ (active when slice set) │ -│ │ │ -│ │ Slice: │ -│ │ channel = WHS │ -│ │ geography = WEST │ -│ │ │ -│ │ [ Scale ] [ Recode ] │ -│ │ [ Clone ] │ -│ │ │ -│ │ ... operation form ... │ -│ │ │ -│ │ [ Submit ] │ -│ │ │ -└──────────────────────────────────────┴──────────────────────────┘ +│ [Layout…] [Expand 0 1 2 3] [Refresh] [Change log] [Bridge] │ +│ [Hide panel] │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ Perspective Viewer (interactive pivot web component) │ +│ │ +├──────────────── drag to resize ─────────────────────────────────┤ +│ SLICE 2 selected │ scale recode clone │ Amount │ +│ channel=WHS │ Together | Each │ Baseline 1,000.00 │ +│ channel=DIR │ │ ▪ reduce_spend -20.00 │ +│ Clear selection │ │ ──────────────────── │ +│ │ │ Adjustable 1,070.00 │ +│ │ │ reference·fixed 921.72 │ +│ │ │ ──────────────────── │ +│ │ │ Selected total 1,991.72 │ +│ │ │ ──────────────────── │ +│ │ │ New value [ 2,000 ] │ +│ │ │ Change [ 8 ] │ +│ │ │ % change [ 0.4 ] │ +│ │ tag [reduce_spend] │ [Apply Scale] │ +└─────────────────────────────────────────────────────────────────┘ ``` -**Pivot control:** [Perspective](https://perspective.finos.org/) 4.4.0, loaded from CDN at runtime. Data is fetched from `GET /api/versions/:id/data` as an Arrow IPC binary stream and loaded into an in-browser Perspective worker — Perspective's native ingestion path. Supports grouping, splitting, filtering, sorting, and charting interactively. Layout (group_by, split_by, filters, plugin) is saved per version to `localStorage` via Save layout / Reset layout buttons. +**The ledger.** The scale form is one continuous statement rather than a totals display +plus a separate input form: baseline, each adjustment (grouped by tag), current, then the +edit. `New value`, `Change` and `% change` are three interchangeable editable rows — +typing in any one derives the other two, and whichever you typed in is what gets sent. +That replaces the old target/delta/percent mode toggle: the row you type in *is* the mode. + +Rows the pivot shows but operations cannot write (`exclude_iters`, typically `reference`) +appear as their own line with a `Selected total` beneath, and a control chooses which of +the two a target measures against — see `target_basis` above. + +**Pivot control:** [Perspective](https://github.com/perspective-dev/perspective) 5.2.0 (`@perspective-dev/*`), **bundled inline, not loaded from a CDN** — the `/inline` entrypoints embed the WASM so the version is pinned by `package-lock.json`. See `PERSPECTIVE.md`. Data is fetched from `GET /api/versions/:id/data` as an Arrow IPC binary stream and loaded into an in-browser Perspective worker — Perspective's native ingestion path. Supports grouping, splitting, filtering, sorting, and charting interactively. Layout (group_by, split_by, filters, plugin) is saved per version to `localStorage` via Save layout / Reset layout buttons. **Large-dataset loading sequence:** 1. Client issues `GET /api/versions/:id/data` @@ -520,9 +595,25 @@ Segment 2 uses two OR groups; segment 3 has two AND conditions in one group. Any **Interaction flow:** 1. Click a cell or row in the pivot — the `perspective-click` event fires 2. `detail.config.filter` from the event is parsed: only `==` filters on `role = dimension` columns are extracted as the slice -3. Slice populates the Operation Panel — pick operation tab, fill in parameters -4. Submit → POST to API → new rows returned via `RETURNING *` are streamed directly into the Perspective table (`pspTable.update(rows)`) — no full reload needed -5. For recode, both the negative offset rows and positive replacement rows are returned and streamed +3. A plain click replaces the selection; **ctrl/⌘/shift-click toggles** a slice in or out of + it. The `CustomEvent` carries no modifier flags, so they are read from the `mousedown` + that preceded it. `perspective-select` (region drag) is wired defensively alongside. +4. Slice populates the Operation Panel — pick operation tab, fill in parameters +5. Submit → POST to API → new rows returned via `RETURNING *` are streamed directly into the Perspective table (`pspTable.update(rows)`) — no full reload needed +6. For recode, both the negative offset rows and positive replacement rows are returned and streamed + +**Selection caveat.** `pf_iter` is not a `col_meta` column, so it is stripped when a slice +is built. Two cells differing only by iter band (baseline vs reference) produce the same +effective slice; duplicates are collapsed before the request, and the panel warns when a +selection covers fewer distinct slices than cells clicked. There is currently no way to +target one band of a slice. + +**Expand depth.** Perspective's `GROUP BY ROLLUP` view contains every level of the +hierarchy, and `view.set_depth()` — which lives on the view, not in the saved config — is +the only thing hiding the deeper ones. The viewer rebuilds its view whenever it redraws, +which its Intersection/ResizeObserver triggers on tab refocus, leaving the tree fully +expanded. The last applied depth is therefore re-applied on `visibilitychange`, `focus` +and `pageshow`. **Pivot default layout:** built from col_meta — first two `dimension` columns as `group_by`, `date` column as `split_by`. User can rearrange in Perspective settings panel and save. @@ -530,8 +621,43 @@ Segment 2 uses two OR groups; segment 3 has two AND conditions in one group. Any ### Log View -AG Grid list of log entries — user, timestamp, operation, slice, note, rows affected. -"Undo" button per row → `DELETE /api/log/:logid` → grid and pivot refresh (full reload of Perspective table). +Modal list of log entries — timestamp, operation, slice, **tag**, note, rows affected. +"Undo" button per row → `DELETE /api/log/:logid` → grid and pivot refresh (full reload of +Perspective table). + +Tag and note are edited inline (click, Enter to save, Esc to cancel) via +`PATCH /api/log/:logid`; the tag field completes from tags already used on the source. +Saving a tag regroups the ledger and bridge immediately, so history can be reclassified +after the fact. + +### Bridge View + +A waterfall answering "how did this version get from its baseline to where it stands?", +one step per initiative tag, opened from the toolbar. + +``` + 6.0k ┤ ┌──────┐- - - -┐ + │ │+3,624│ │ + 4.0k ┤ │ │ −3,800│ + │ ┌─────┐- ┘ └ - - - ┘──┐ ┌─────┐ + 2.0k ┤ │2,734│ │+509│ │3,067│ + 0 ┴──┴─────┴────────────────┴────┴─┴─────┴── + Baseline clamp give food Current +``` + +**Scope:** the current slice selection (default when one exists), the pivot's current +filters, or the whole version. Selection scope uses the **union** of the selected slices — +the same reach an operation would have — with rows matching more than one slice deduped +by `pf_id` to match the `OR` semantics operations use. + +Computed from the Perspective table already loaded in the browser rather than from +`/api/versions/:id/bridge`, so the figures always reconcile with what the pivot is +showing. The endpoint remains for API consumers. + +**Colour** encodes polarity, not identity: increases and decreases are two poles of one +scale, so it uses a validated diverging pair (blue/red, CVD ΔE 21.6 — green/red is avoided +as the classic colourblind failure) with neutral grey anchors for baseline and current. +Every bar is directly labelled and a table view gives the same numbers at full precision. --- @@ -868,14 +994,16 @@ simpler fallback and is now cheap — ~25 ms.) - **Baseline replay** — re-execute change log against a restated baseline (`replay: true`); v1 returns 501 - **Approval workflow** — user submits, admin approves before changes are visible to others (deferred) - **Territory filtering** — restrict what a user can see/edit by dimension value (deferred) -- **Export** — download forecast as CSV or push results to a reporting table -- **Version comparison** — side-by-side view of two versions (facilitated by isolated tables via UNION) +- **Export** — download forecast as CSV or push results to a reporting table. The bridge's table view is a partial stand-in for reading the numbers out, but there is no download. +- **Version comparison** — side-by-side view of two versions (facilitated by isolated tables via UNION). The bridge answers the within-version form of this question; across versions is still open. +- **Bridge drill-down** — click a step to list the adjustments behind it, or select that slice back in the pivot +- **Targeting one iter band** — make `pf_iter` part of a slice so an operation can act on, say, only the baseline rows of a selection (see Known issues) - **Col meta / version schema drift** — if col_meta roles are changed after a version's forecast table is already created, the generated SQL and the table DDL go out of sync. UI should detect this: compare col_meta against the forecast table's actual columns via `information_schema`, warn the user, and offer to rebuild the version (drop + recreate table, preserving the version record and log). Workaround: delete and recreate the version manually. - **Multi-connection support** — currently one DB via `.env`. Full vision: `pf.connection` table (host, port, dbname, user, password as env-var ref), `connection_id` on `pf.source`, per-connection pg pools at runtime. `pf` schema stays on a "home" connection; source data can live anywhere. Connections UI in Setup. Safe to defer while in dev — requires clean reinstall when added since it changes the source schema. --- -## Project Status — 2026-06-12 +## Project Status — 2026-09-11 ### What's working - Full backend: source registration, col_meta, SQL generation, versions, baseline segments, reference load, scale, recode, clone, undo @@ -885,19 +1013,30 @@ simpler fallback and is now cheap — ~25 ms.) - React + Vite + Tailwind CSS frontend in `ui/`, built output to `public/app/`, served by Express - Data transport: Arrow IPC binary stream (`GET /api/versions/:id/data`); server accumulates all rows into one record batch; client hands buffer directly to Perspective WASM - 3-step collapsible sidebar (Setup / Baseline / Forecast) -- Setup view: DB table browser with preview modal, source registration, col_meta editor (`dim_group`/`dim_period_col` fields included), SQL generation -- Baseline view: version management (create/close/reopen/delete), multi-segment baseline workbench, canvas timeline, filter builder +- Setup view: DB table browser with preview modal, source registration, col_meta editor, SQL generation +- Baseline view: version management, multi-segment baseline workbench, canvas timeline, filter builder - Perspective pivot in Forecast view: loads all version rows, interactive group/split/filter/chart, layout saved per version to localStorage -- Slice extraction from `perspective-click` event feeds operation panel directly -- Incremental row streaming: operation results (`RETURNING *`) applied to Perspective table via `pspTable.update()` — no full reload -- Status bar: shows current source · version · baseline row count · status +- Incremental row streaming: operation results (`RETURNING *`) applied via `pspTable.update()` — no full reload +- **Multi-slice operations**: ctrl/⌘-click accumulates slices; `apply_mode` prorate/each +- **Per-measure resolution**: target, percent or change amount independently per measure +- **`target_basis`**: a target measures against the adjustable rows or everything the pivot shows +- **Ledger panel**: baseline → adjustments → current → three interchangeable editable rows, docked bottom/right/floating +- **Tags and bridge**: initiative tags on log entries, editable after the fact, with a waterfall view scoped to selection / filters / version +- **Status bar** names the physical table writes land in, with live row counts by iter ### Known issues / next focus -- **Forecast view** — operation panel SQL generation complete; UI wiring to API still needed -- **Load progress bar** — jittery at high throughput; throttle to ~10 updates/sec -- **Default pivot layout** — per-source configurable layout not yet implemented; currently hardcodes first 2 dimensions -- **No "current version" persistence** — source/version selection resets on page reload -- **Perspective slice limitation** — computed date columns (Month, YearDate) from split_by don't map back to raw rows; only native dimension columns work for slice extraction +- **`pf_iter` not selectable** — it is not a col_meta column, so it is stripped from slices. Cells differing only by iter band collapse to one slice (duplicates are detected and collapsed, and the panel warns), and there is no way to operate on one band of a slice. +- **Per-row rounding drift** — the scale SQL rounds each row to 2dp, so a target of 1,000 across many rows can land on 999.99. Inherent to proportional distribution; a correction row would be needed to land exactly. +- **Manual caret expansion is not restored** — the depth re-apply on refocus only covers whole-tree depths set via the Expand buttons or a saved layout, since per-row expansion lives in the same discarded view. +- **Bridge has no drill-down** — clicking a step does not list its adjustments or select that slice back in the pivot. +- **Light surface only** — app chrome is light throughout; the dark toggle currently re-themes only the Perspective viewer. - **Col_meta / version schema drift** — if col_meta changes after a version's forecast table is created, SQL and DDL go out of sync. Workaround: delete and recreate the version. +- **No migration sequence** — `01_schema.sql` carries `ADD COLUMN IF NOT EXISTS` inline for the `tag` column, which covers fresh installs and re-runs, but there is no ordered migration mechanism. +- **No tests** — SQL generation is token substitution against append-only tables and is entirely untested. +### Fixed + +- **Non-selective slices applied to the whole version** — a slice naming no filterable column reduced to `TRUE`. Now rejected on all three operations. +- **Proration across a near-zero pool** — rows flew to extreme opposite values to reach a target. Refused when the net is below 1% of gross; `apply_mode: each` is the alternative. +- **Targets overshooting by excluded rows** — see `target_basis`. diff --git a/routes/log.js b/routes/log.js index d14d0f1..93bd0d3 100644 --- a/routes/log.js +++ b/routes/log.js @@ -85,13 +85,28 @@ module.exports = function(pool) { } }); - // update the note on a log entry + // update the note and/or tag on a log entry. Both are annotations — they never + // affect the forecast rows — so they stay editable after the fact, including on + // a closed version, where relabelling history is still legitimate. router.patch('/log/:logid', async (req, res) => { const logId = parseInt(req.params.logid); - const { note } = req.body; + const { note, tag } = req.body; + if (note === undefined && tag === undefined) { + return res.status(400).json({ error: 'Nothing to update — send note and/or tag' }); + } try { + // COALESCE on the flag, not the value: an explicit null or '' must be + // able to clear a field, which COALESCE on the value alone would ignore const result = await pool.query( - `UPDATE pf.log SET note = $1 WHERE id = $2 RETURNING *`, [note ?? null, logId] + `UPDATE pf.log SET + note = CASE WHEN $2::bool THEN $3::text ELSE note END, + tag = CASE WHEN $4::bool THEN $5::text ELSE tag END + WHERE id = $1 RETURNING *`, + [ + logId, + note !== undefined, note === undefined ? null : (String(note).trim() || null), + tag !== undefined, tag === undefined ? null : (String(tag).trim() || null), + ] ); if (!result.rows.length) return res.status(404).json({ error: 'Log entry not found' }); res.json(result.rows[0]); diff --git a/routes/operations.js b/routes/operations.js index b7da282..d397dce 100644 --- a/routes/operations.js +++ b/routes/operations.js @@ -1,14 +1,189 @@ const express = require('express'); const { tableFromArrays, tableToIPC } = require('apache-arrow'); -const { applyTokens, buildWhere, buildExcludeClause, buildSetClause, esc } = require('../lib/sql_generator'); +const { applyTokens, buildWhere, buildWhereAny, buildExcludeClause, buildExcludePredicate, buildSetClause, esc } = require('../lib/sql_generator'); const { fcTable } = require('../lib/utils'); module.exports = function(pool) { const router = express.Router(); - async function runSQL(sql) { + async function runSQL(sql, client) { console.log('--- SQL ---\n', sql, '\n--- END SQL ---'); - return pool.query(sql); + return (client || pool).query(sql); + } + + // accept either the legacy single `slice` object or the newer `slices` array, + // and drop any empty entries so an empty selection can never widen to TRUE + function normalizeSlices(body) { + const raw = Array.isArray(body.slices) && body.slices.length ? body.slices : [body.slice]; + return raw.filter(s => s && typeof s === 'object' && Object.keys(s).length > 0); + } + + // Stamp the tag onto the log entry the operation just created. + // Done as a follow-up UPDATE rather than inside the generated SQL: those + // templates live in pf.sql per source, so adding a {{tag}} token would strand + // every source that has not re-run "Generate SQL". + async function tagLog(client, rows, tag) { + const clean = (tag || '').trim(); + if (!clean) return null; + const ids = [...new Set(rows.map(r => r.pf_logid).filter(id => id != null))]; + if (ids.length === 0) return null; + await client.query(`UPDATE pf.log SET tag = $1 WHERE id = ANY($2::bigint[])`, [clean, ids]); + return clean; + } + + // A slice is only meaningful if at least one of its keys is a filterable + // column. buildWhere silently drops unknown keys, so {"typo": "x"} would + // otherwise reduce to TRUE and apply the operation to the whole version. + // Refuse rather than let a malformed selection rewrite every row. + function assertSelective(slices, ctx) { + const allowed = new Set(ctx.filterCols); + slices.forEach((sl, i) => { + const hits = Object.keys(sl).filter(k => allowed.has(k)); + if (hits.length === 0) { + const err = new Error( + `Slice ${i + 1} does not name any filterable column ` + + `(${JSON.stringify(sl)}). Expected one of: ${ctx.filterCols.join(', ')}.` + ); + err.status = 400; + throw err; + } + }); + } + + // echo back what the caller asked for, for the audit log + function pickIntent(body) { + const keys = ['mode', 'target_basis', 'value_incr', 'units_incr', 'value_pct', 'units_pct', 'pct', + 'target_value', 'target_units', 'target_price']; + const out = {}; + for (const k of keys) if (body[k] !== undefined && body[k] !== null && body[k] !== '') out[k] = body[k]; + return out; + } + + // Totals for a WHERE clause, split into the rows operations can change and the + // rows they cannot. Excluded iters (typically 'reference') are still visible in + // the pivot, so their contribution has to be reported rather than dropped — + // otherwise a target set against what the grid shows lands somewhere else. + async function sliceTotals(client, ctx, whereClause, excludeClause) { + const pred = buildExcludePredicate(ctx.version.exclude_iters); + const agg = (col, filter) => col ? `sum(${col}) FILTER (WHERE ${filter})` : 'NULL'; + const v = ctx.valueCol ? `"${ctx.valueCol}"` : null; + const u = ctx.unitsCol ? `"${ctx.unitsCol}"` : null; + + const r = await client.query(` + SELECT ${agg(v, pred)} AS total_value, + ${agg(u, pred)} AS total_units, + ${agg(v ? `abs(${v})` : null, pred)} AS abs_value, + ${agg(u ? `abs(${u})` : null, pred)} AS abs_units, + ${agg(v, `NOT (${pred})`)} AS excl_value, + ${agg(u, `NOT (${pred})`)} AS excl_units + FROM ${ctx.table} WHERE ${whereClause} + `); + const n = (x) => parseFloat(r.rows[0][x]) || 0; + return { + value: n('total_value'), units: n('total_units'), + absValue: n('abs_value'), absUnits: n('abs_units'), + exclValue: n('excl_value'), exclUnits: n('excl_units'), + }; + } + + // Resolve each measure independently into the increment the scale SQL expects. + // Value and units each accept exactly one of: an absolute target, a change + // amount, or a percentage — whichever the caller sent. They are resolved + // separately so a target on one measure and a percentage on the other can be + // submitted together. Everything is measured against the totals of *this* + // WHERE clause, which is what makes apply_mode 'each' land per slice. + async function resolveIncrs(client, ctx, whereClause, excludeClause, body) { + const num = (v) => (v === undefined || v === null || v === '') ? null : parseFloat(v); + + const tValue = num(body.target_value); + const tUnits = num(body.target_units); + const tPrice = num(body.target_price); + const vIncr = num(body.value_incr); + const uIncr = num(body.units_incr); + let vPct = num(body.value_pct); + let uPct = num(body.units_pct); + + // legacy shape: a single `pct` flag meaning "the increments are percentages" + if (body.pct) { + if (vPct === null && vIncr !== null) vPct = vIncr; + if (uPct === null && uIncr !== null) uPct = uIncr; + } + const legacyPct = !!body.pct; + + const anyInput = [tValue, tUnits, tPrice, vIncr, uIncr, vPct, uPct].some(v => v !== null); + if (!anyInput) return { value: 0, units: 0 }; + + const totals = await sliceTotals(client, ctx, whereClause, excludeClause); + + // What the number is measured against: + // 'adjustable' — only the rows this operation can write (the default, and + // what every earlier version of this API did) + // 'selected' — everything the pivot shows for the slice, excluded rows + // included. Those rows cannot move, so reaching the target + // means the adjustable rows absorb the whole difference. + const basis = body.target_basis === 'selected' ? 'selected' : 'adjustable'; + const fixedValue = basis === 'selected' ? totals.exclValue : 0; + const fixedUnits = basis === 'selected' ? totals.exclUnits : 0; + + // one measure: target wins, then percentage, then a plain change amount + const resolve = (target, pct, incr, current, fixed) => { + // subtract the immovable part: current + incr + fixed === target + if (target !== null) return (target - fixed) - current; + // a percentage of the basis, which may include the immovable part + if (pct !== null) return (current + fixed) * pct / 100; + if (incr !== null && !legacyPct) return incr; + return 0; + }; + + let value = resolve(tValue, vPct, vIncr, totals.value, fixedValue); + const units = resolve(tUnits, uPct, uIncr, totals.units, fixedUnits); + + // a price target holds units constant: new value = price x current units. + // An explicit value target outranks it. + if (tPrice !== null && tValue === null) { + value = (tPrice * (totals.units + fixedUnits)) - (totals.value + fixedValue); + } + + // the scale SQL divides by the slice total; with no rows there is + // nothing to prorate across and the increment would vanish anyway + if (totals.value === 0 && totals.units === 0) return { value: 0, units: 0 }; + + // Refuse to prorate across a pool that nets to ~zero. Each row's new value is + // (row / total) * increment, so as the net approaches zero the multiplier + // explodes and rows fly apart in opposite directions to hit the target — a + // mathematically faithful, practically useless result. Selecting slices that + // offset each other is the usual cause, and 'each' handles that correctly. + assertProratable(totals, value, units); + + return { value: round(value, 6), units: round(units, 6) }; + } + + // a pool is proratable only if its net is a meaningful fraction of its gross + const NET_TO_GROSS_FLOOR = 0.01; + + function assertProratable(totals, value, units) { + const check = (net, gross, incr, label) => { + if (!incr) return; + if (gross === 0) return; + if (Math.abs(net) >= gross * NET_TO_GROSS_FLOOR) return; + const err = new Error( + `Cannot prorate ${label} across this selection: the rows net to ` + + `${net.toFixed(2)} against a gross of ${gross.toFixed(2)}, so they very ` + + `nearly cancel out. Scaling to a target would push them to extreme ` + + `opposite values. Use "Each" to scale every slice on its own, or narrow ` + + `the selection so it does not mix offsetting rows.` + ); + err.status = 400; + throw err; + }; + check(totals.value, totals.absValue, value, 'value'); + check(totals.units, totals.absUnits, units, 'units'); + } + + function round(n, dp) { + if (!isFinite(n)) return 0; + const f = Math.pow(10, dp); + return Math.round(n * f) / f; } // fetch everything needed to execute an operation: @@ -299,131 +474,196 @@ module.exports = function(pool) { } }); - // scale a slice — adjust value and/or units by absolute amount or percentage + // scale one or more slices — adjust value and/or units toward an absolute + // target or by an increment. With several slices selected, apply_mode decides + // whether they are treated as one pool ('prorate') or independently ('each'). router.post('/versions/:id/scale', async (req, res) => { - const { pf_user, note, slice, value_incr, units_incr, pct } = req.body; - if (!slice || Object.keys(slice).length === 0) { - return res.status(400).json({ error: 'slice is required' }); - } + const { pf_user, note, apply_mode } = req.body; + const slices = normalizeSlices(req.body); + if (slices.length === 0) return res.status(400).json({ error: 'slice is required' }); + + const applyMode = apply_mode === 'each' ? 'each' : 'prorate'; + try { const ctx = await getContext(parseInt(req.params.id), 'scale'); if (!guardOpen(ctx.version, res)) return; + assertSelective(slices, ctx); - const whereClause = buildWhere(slice, ctx.filterCols); const excludeClause = buildExcludeClause(ctx.version.exclude_iters); - let absValueIncr = value_incr || 0; - let absUnitsIncr = units_incr || 0; + // 'prorate' pools every slice into one WHERE and lets the SQL's + // sum() OVER () distribute the increment across the whole pool. + // 'each' runs the same statement once per slice, so every slice + // reaches the target on its own and gets its own log entry. + const units = applyMode === 'each' + ? slices.map(sl => ({ slices: [sl], where: buildWhere(sl, ctx.filterCols) })) + : [{ slices, where: buildWhereAny(slices, ctx.filterCols) }]; - // pct mode: run a quick totals query, convert percentages to absolutes - if (pct && (value_incr || units_incr)) { - const totals = await pool.query(` - SELECT - sum("${ctx.valueCol}") AS total_value, - sum("${ctx.unitsCol}") AS total_units - FROM ${ctx.table} - WHERE ${whereClause} - ${excludeClause} - `); - const { total_value, total_units } = totals.rows[0]; - if (value_incr) absValueIncr = (parseFloat(total_value) || 0) * value_incr / 100; - if (units_incr) absUnitsIncr = (parseFloat(total_units) || 0) * units_incr / 100; + const client = await pool.connect(); + let committed = false; + try { + await client.query('BEGIN'); + const allRows = []; + let applied = 0; + const skipped = []; + + for (const unit of units) { + const incr = await resolveIncrs(client, ctx, unit.where, excludeClause, req.body); + // no rows, or already at the target — nothing to write for this slice + if (incr.value === 0 && incr.units === 0) { skipped.push(...unit.slices); continue; } + applied++; + + const loggedSlice = unit.slices.length === 1 ? unit.slices[0] : unit.slices; + const sql = applyTokens(ctx.sql, { + fc_table: ctx.table, + version_id: ctx.version.id, + pf_user: esc(pf_user || ''), + note: esc(note || ''), + params: esc(JSON.stringify({ + slices: unit.slices, + apply_mode: applyMode, + ...pickIntent(req.body), + resolved: { value_incr: incr.value, units_incr: incr.units } + })), + slice: esc(JSON.stringify(loggedSlice)), + where_clause: unit.where, + exclude_clause: excludeClause, + value_incr: incr.value, + units_incr: incr.units + }); + const result = await runSQL(sql, client); + await tagLog(client, result.rows, req.body.tag); + allRows.push(...result.rows); + } + + if (allRows.length === 0) { + await client.query('ROLLBACK'); + return res.status(400).json({ + error: 'Nothing to scale — the target matches the current total, or the increment is zero' + }); + } + + await client.query('COMMIT'); + committed = true; + const rows = allRows.map(r => ({ ...r, pf_note: note || null, pf_op: 'scale' })); + res.json({ + rows, + rows_affected: rows.length, + slices_applied: applied, + ...(skipped.length ? { slices_skipped: skipped } : {}) + }); + } finally { + if (!committed) try { await client.query('ROLLBACK'); } catch {} + client.release(); } - - if (absValueIncr === 0 && absUnitsIncr === 0) { - return res.status(400).json({ error: 'value_incr and/or units_incr must be non-zero' }); - } - - const sql = applyTokens(ctx.sql, { - fc_table: ctx.table, - version_id: ctx.version.id, - pf_user: esc(pf_user || ''), - note: esc(note || ''), - params: esc(JSON.stringify({ slice, value_incr, units_incr, pct })), - slice: esc(JSON.stringify(slice)), - where_clause: whereClause, - exclude_clause: excludeClause, - value_incr: absValueIncr, - units_incr: absUnitsIncr - }); - - const result = await runSQL(sql); - const rows = result.rows.map(r => ({ ...r, pf_note: note || null, pf_op: 'scale' })); - res.json({ rows, rows_affected: rows.length }); } catch (err) { console.error(err); res.status(err.status || 500).json({ error: err.message }); } }); - // recode dimension values on a slice + // recode dimension values on one or more slices // inserts negative rows to zero out the original, positive rows with new dimension values router.post('/versions/:id/recode', async (req, res) => { - const { pf_user, note, slice, set } = req.body; - if (!slice || Object.keys(slice).length === 0) return res.status(400).json({ error: 'slice is required' }); - if (!set || Object.keys(set).length === 0) return res.status(400).json({ error: 'set is required' }); + const { pf_user, note, set, apply_mode } = req.body; + const slices = normalizeSlices(req.body); + if (slices.length === 0) return res.status(400).json({ error: 'slice is required' }); + if (!set || Object.keys(set).length === 0) return res.status(400).json({ error: 'set is required' }); try { const ctx = await getContext(parseInt(req.params.id), 'recode'); if (!guardOpen(ctx.version, res)) return; + assertSelective(slices, ctx); - const whereClause = buildWhere(slice, ctx.filterCols); const excludeClause = buildExcludeClause(ctx.version.exclude_iters); const setClause = buildSetClause(ctx.dimCols, set); + const units = sliceUnits(slices, ctx, apply_mode); - const sql = applyTokens(ctx.sql, { - fc_table: ctx.table, - version_id: ctx.version.id, - pf_user: esc(pf_user || ''), - note: esc(note || ''), - params: esc(JSON.stringify({ slice, set })), - slice: esc(JSON.stringify(slice)), - where_clause: whereClause, - exclude_clause: excludeClause, - set_clause: setClause - }); - - const result = await runSQL(sql); - const rows = result.rows.map(r => ({ ...r, pf_note: note || null, pf_op: 'recode' })); - res.json({ rows, rows_affected: rows.length }); + const client = await pool.connect(); + let committed = false; + try { + await client.query('BEGIN'); + const allRows = []; + for (const unit of units) { + const loggedSlice = unit.slices.length === 1 ? unit.slices[0] : unit.slices; + const sql = applyTokens(ctx.sql, { + fc_table: ctx.table, + version_id: ctx.version.id, + pf_user: esc(pf_user || ''), + note: esc(note || ''), + params: esc(JSON.stringify({ slices: unit.slices, set, apply_mode: unit.mode })), + slice: esc(JSON.stringify(loggedSlice)), + where_clause: unit.where, + exclude_clause: excludeClause, + set_clause: setClause + }); + const result = await runSQL(sql, client); + await tagLog(client, result.rows, req.body.tag); + allRows.push(...result.rows); + } + await client.query('COMMIT'); + committed = true; + const rows = allRows.map(r => ({ ...r, pf_note: note || null, pf_op: 'recode' })); + res.json({ rows, rows_affected: rows.length, slices_applied: units.length }); + } finally { + if (!committed) try { await client.query('ROLLBACK'); } catch {} + client.release(); + } } catch (err) { console.error(err); res.status(err.status || 500).json({ error: err.message }); } }); - // clone a slice as new business under new dimension values + // clone one or more slices as new business under new dimension values // does not offset the original slice router.post('/versions/:id/clone', async (req, res) => { - const { pf_user, note, slice, set, scale } = req.body; - if (!slice || Object.keys(slice).length === 0) return res.status(400).json({ error: 'slice is required' }); - if (!set || Object.keys(set).length === 0) return res.status(400).json({ error: 'set is required' }); + const { pf_user, note, set, scale, apply_mode } = req.body; + const slices = normalizeSlices(req.body); + if (slices.length === 0) return res.status(400).json({ error: 'slice is required' }); + if (!set || Object.keys(set).length === 0) return res.status(400).json({ error: 'set is required' }); try { const ctx = await getContext(parseInt(req.params.id), 'clone'); if (!guardOpen(ctx.version, res)) return; + assertSelective(slices, ctx); const scaleFactor = (scale != null) ? parseFloat(scale) : 1.0; - const whereClause = buildWhere(slice, ctx.filterCols); const excludeClause = buildExcludeClause(ctx.version.exclude_iters); const setClause = buildSetClause(ctx.dimCols, set); + const units = sliceUnits(slices, ctx, apply_mode); - const sql = applyTokens(ctx.sql, { - fc_table: ctx.table, - version_id: ctx.version.id, - pf_user: esc(pf_user || ''), - note: esc(note || ''), - params: esc(JSON.stringify({ slice, set, scale: scaleFactor })), - slice: esc(JSON.stringify(slice)), - where_clause: whereClause, - exclude_clause: excludeClause, - set_clause: setClause, - scale_factor: scaleFactor - }); - - const result = await runSQL(sql); - const rows = result.rows.map(r => ({ ...r, pf_note: note || null, pf_op: 'clone' })); - res.json({ rows, rows_affected: rows.length }); + const client = await pool.connect(); + let committed = false; + try { + await client.query('BEGIN'); + const allRows = []; + for (const unit of units) { + const loggedSlice = unit.slices.length === 1 ? unit.slices[0] : unit.slices; + const sql = applyTokens(ctx.sql, { + fc_table: ctx.table, + version_id: ctx.version.id, + pf_user: esc(pf_user || ''), + note: esc(note || ''), + params: esc(JSON.stringify({ slices: unit.slices, set, scale: scaleFactor, apply_mode: unit.mode })), + slice: esc(JSON.stringify(loggedSlice)), + where_clause: unit.where, + exclude_clause: excludeClause, + set_clause: setClause, + scale_factor: scaleFactor + }); + const result = await runSQL(sql, client); + await tagLog(client, result.rows, req.body.tag); + allRows.push(...result.rows); + } + await client.query('COMMIT'); + committed = true; + const rows = allRows.map(r => ({ ...r, pf_note: note || null, pf_op: 'clone' })); + res.json({ rows, rows_affected: rows.length, slices_applied: units.length }); + } finally { + if (!committed) try { await client.query('ROLLBACK'); } catch {} + client.release(); + } } catch (err) { console.error(err); res.status(err.status || 500).json({ error: err.message }); diff --git a/routes/versions.js b/routes/versions.js index ad02ba9..f874b07 100644 --- a/routes/versions.js +++ b/routes/versions.js @@ -114,6 +114,125 @@ ${colDefs}, } }); + // where this version's writes actually land: the physical forecast table, + // its current row count, and the source table rows are read from. + // Surfaced in the status bar so the write target is never a mystery. + router.get('/versions/:id/table-info', async (req, res) => { + try { + const verResult = await pool.query(` + SELECT v.id, v.name, v.status, s.schema, s.tname + FROM pf.version v + JOIN pf.source s ON s.id = v.source_id + WHERE v.id = $1 + `, [req.params.id]); + if (verResult.rows.length === 0) return res.status(404).json({ error: 'Version not found' }); + + const v = verResult.rows[0]; + const fc = fcTable(v.tname, v.id); + const [schema, table] = fc.split('.'); + + const existsResult = await pool.query( + `SELECT to_regclass($1) IS NOT NULL AS exists`, [fc] + ); + const exists = existsResult.rows[0].exists; + + let rows = null, byIter = []; + if (exists) { + const countResult = await pool.query( + `SELECT pf_iter, count(*)::int AS n FROM ${fc} GROUP BY pf_iter ORDER BY pf_iter` + ); + byIter = countResult.rows; + rows = byIter.reduce((a, r) => a + r.n, 0); + } + + res.json({ + version_id: v.id, + version_name: v.name, + status: v.status, + source: `${v.schema}.${v.tname}`, + fc_table: fc, + fc_schema: schema, + fc_tname: table, + exists, + rows, + by_iter: byIter + }); + } catch (err) { + console.error(err); + res.status(500).json({ error: err.message }); + } + }); + + // Tags already used on this source, newest first — feeds the tag autocomplete. + // Scoped to the source rather than the version so an initiative name carries + // across versions, which is the point of naming it. + router.get('/sources/:id/tags', async (req, res) => { + try { + const result = await pool.query(` + SELECT l.tag, + count(*)::int AS uses, + max(l.stamp) AS last_used + FROM pf.log l + JOIN pf.version v ON v.id = l.version_id + WHERE v.source_id = $1 AND l.tag IS NOT NULL AND l.tag <> '' + GROUP BY l.tag + ORDER BY max(l.stamp) DESC + `, [req.params.id]); + res.json(result.rows); + } catch (err) { + console.error(err); + res.status(500).json({ error: err.message }); + } + }); + + // Bridge: how this version got from its baseline to where it stands, grouped by + // initiative. Amounts come from the version's own forecast table, so the figures + // reconcile with the pivot rather than being recomputed from the log's params. + router.get('/versions/:id/bridge', async (req, res) => { + try { + const verResult = await pool.query(` + SELECT v.id, v.exclude_iters, s.schema, s.tname, s.id AS source_id + FROM pf.version v JOIN pf.source s ON s.id = v.source_id + WHERE v.id = $1 + `, [req.params.id]); + if (verResult.rows.length === 0) return res.status(404).json({ error: 'Version not found' }); + const v = verResult.rows[0]; + const fc = fcTable(v.tname, v.id); + + const exists = await pool.query(`SELECT to_regclass($1) IS NOT NULL AS ok`, [fc]); + if (!exists.rows[0].ok) return res.json({ fc_table: fc, exists: false, rows: [] }); + + const colResult = await pool.query( + `SELECT cname, role FROM pf.col_meta WHERE source_id = $1`, [v.source_id]); + const valueCol = colResult.rows.find(c => c.role === 'value')?.cname; + const unitsCol = colResult.rows.find(c => c.role === 'units')?.cname; + if (!valueCol) return res.status(400).json({ error: 'No value column configured' }); + + const excl = (v.exclude_iters || []).length + ? `t.pf_iter NOT IN (${v.exclude_iters.map(i => `'${String(i).replace(/'/g, "''")}'`).join(', ')})` + : 'TRUE'; + + const result = await pool.query(` + SELECT CASE WHEN t.pf_iter = 'baseline' THEN '(baseline)' + ELSE coalesce(nullif(l.tag, ''), '(untagged)') END AS tag, + bool_or(t.pf_iter = 'baseline') AS is_baseline, + count(DISTINCT l.id)::int AS entries, + count(*)::int AS row_count, + round(sum(t."${valueCol}")::numeric, 2) AS value + ${unitsCol ? `, round(sum(t."${unitsCol}")::numeric, 2) AS units` : ''} + FROM ${fc} t + LEFT JOIN pf.log l ON l.id = t.pf_logid + WHERE ${excl} + GROUP BY 1 + ORDER BY bool_or(t.pf_iter = 'baseline') DESC, min(l.id) + `); + res.json({ fc_table: fc, exists: true, value_col: valueCol, units_col: unitsCol, rows: result.rows }); + } catch (err) { + console.error(err); + res.status(500).json({ error: err.message }); + } + }); + // update version name, description, or exclude_iters router.put('/versions/:id', async (req, res) => { const { name, description, exclude_iters } = req.body; diff --git a/setup_sql/01_schema.sql b/setup_sql/01_schema.sql index b555002..cb53514 100644 --- a/setup_sql/01_schema.sql +++ b/setup_sql/01_schema.sql @@ -55,9 +55,15 @@ CREATE TABLE IF NOT EXISTS pf.log ( operation text NOT NULL, -- baseline | reference | scale | recode | clone slice jsonb, params jsonb, - note text + note text, + tag text -- initiative label, e.g. 'reduce_spend'; groups + -- adjustments into a bridge from baseline to current ); +-- adding tags to an install that predates them +ALTER TABLE pf.log ADD COLUMN IF NOT EXISTS tag text; +CREATE INDEX IF NOT EXISTS log_tag_idx ON pf.log (tag) WHERE tag IS NOT NULL; + -- generated operation SQL per source, stored after col_meta is configured CREATE TABLE IF NOT EXISTS pf.sql ( id serial PRIMARY KEY, diff --git a/ui/index.html b/ui/index.html index 79bf1b9..6f5da67 100644 --- a/ui/index.html +++ b/ui/index.html @@ -5,7 +5,6 @@ Pivot Forecast -
diff --git a/ui/package-lock.json b/ui/package-lock.json index 7709e1f..a3a7acf 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -8,6 +8,10 @@ "name": "ui", "version": "0.0.0", "dependencies": { + "@perspective-dev/client": "5.2.0", + "@perspective-dev/server": "5.2.0", + "@perspective-dev/viewer": "5.2.0", + "@perspective-dev/viewer-datagrid": "5.2.0", "react": "^19.2.5", "react-dom": "^19.2.5" }, @@ -266,9 +270,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", + "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", "dev": true, "license": "MIT", "optional": true, @@ -549,6 +553,46 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@perspective-dev/client": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@perspective-dev/client/-/client-5.2.0.tgz", + "integrity": "sha512-zkJmJFwdw0wMREoJt8gUJyCsflzi7s7Yc8ZtUCOLE9XB6fdyxJmDB99cxO3Q+hno/57kLsz8VNoz8XSm6PZNrg==", + "license": "Apache-2.0", + "dependencies": { + "@perspective-dev/server": "", + "pro_self_extracting_wasm": "0.0.9", + "stoppable": "=1.1.0", + "ws": "^8.17.0" + } + }, + "node_modules/@perspective-dev/server": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@perspective-dev/server/-/server-5.2.0.tgz", + "integrity": "sha512-WRBiokT2/BYM8Ipe7Dg1/2UYNb01Vsox79KBfFVI800VmwdId0GdqI95zufN5ZoFffeBfri1sr3S3AShBRFXKA==", + "license": "Apache-2.0" + }, + "node_modules/@perspective-dev/viewer": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@perspective-dev/viewer/-/viewer-5.2.0.tgz", + "integrity": "sha512-IIyqdPduofzzZ/QWrteq29IadJQR8IapTRz7U6XbrtBBm7eyiFsIBKQV0wFfcqWg4TRnhuvCAUvBRieuE0R8Eg==", + "license": "Apache-2.0", + "dependencies": { + "@perspective-dev/client": "", + "pro_self_extracting_wasm": "0.0.9", + "regular-layout": "=0.6.1" + } + }, + "node_modules/@perspective-dev/viewer-datagrid": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@perspective-dev/viewer-datagrid/-/viewer-datagrid-5.2.0.tgz", + "integrity": "sha512-v/SR/35YfyKfivO21nglJFiyG1iE5G8vMwIwWI6fQuwjW764pmNrm/zcrNWNY70x5XHkPRM94aY6LjkLsLkO2g==", + "license": "Apache-2.0", + "dependencies": { + "@perspective-dev/client": "", + "@perspective-dev/viewer": "", + "regular-table": "=0.8.6" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.0-rc.17", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", @@ -772,6 +816,40 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@rolldown/binding-win32-arm64-msvc": { "version": "1.0.0-rc.17", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", @@ -1205,6 +1283,27 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -1228,6 +1327,24 @@ "node": ">=6.0.0" } }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/bn.js": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.5.tgz", + "integrity": "sha512-Vq886eXykuP5E6HcKSSStP3bJgrE6In5WKxVUvJ8XGpWWYs2xZHWqUwzCtGgEtBcxyd57KBFDPFoUfNzdaHCNg==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", @@ -1276,6 +1393,44 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-pipe": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/buffer-pipe/-/buffer-pipe-0.0.3.tgz", + "integrity": "sha512-GlxfuD/NrKvCNs0Ut+7b1IHjylfdegMBxQIlZHj7bObKVQBxB5S84gtm2yu1mQ8/sSggceWBDPY0cPXgvX2MuA==", + "license": "MPL-2.0", + "dependencies": { + "safe-buffer": "^5.1.2" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001790", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001790.tgz", @@ -1297,6 +1452,40 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1304,6 +1493,15 @@ "dev": true, "license": "MIT" }, + "node_modules/corser": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/corser/-/corser-2.0.1.tgz", + "integrity": "sha512-utCYNzRSQIZNPIcGZdQc92UVJYAhtGAteCFg0yRaFm8f0P+CPtyGyHXJcGXnffjCybUCEx3FQ2G7U3/o9eIkVQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1330,7 +1528,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1361,6 +1558,20 @@ "node": ">=8" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.344", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz", @@ -1382,6 +1593,36 @@ "node": ">=10.13.0" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -1588,6 +1829,12 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -1678,6 +1925,26 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1693,6 +1960,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -1703,6 +1979,43 @@ "node": ">=6.9.0" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -1729,6 +2042,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -1736,6 +2061,48 @@ "dev": true, "license": "ISC" }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -1753,6 +2120,71 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html-encoding-sniffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", + "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/http-server": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/http-server/-/http-server-14.1.1.tgz", + "integrity": "sha512-+cbxadF40UXd9T01zUHgA+rlo2Bg1Srer4+B4NwIHdaGxAGGv59nYRnGGDJ9LBk7alpS0US+J+bLLdQOOkJq4A==", + "license": "MIT", + "dependencies": { + "basic-auth": "^2.0.1", + "chalk": "^4.1.2", + "corser": "^2.0.1", + "he": "^1.2.0", + "html-encoding-sniffer": "^3.0.0", + "http-proxy": "^1.18.1", + "mime": "^1.6.0", + "minimist": "^1.2.6", + "opener": "^1.5.1", + "portfinder": "^1.0.28", + "secure-compare": "3.0.1", + "union": "~0.5.0", + "url-join": "^4.0.1" + }, + "bin": { + "http-server": "bin/http-server" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -1809,7 +2241,6 @@ "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -1878,6 +2309,16 @@ "json-buffer": "3.0.1" } }, + "node_modules/leb128": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/leb128/-/leb128-0.0.5.tgz", + "integrity": "sha512-elbNtfmu3GndZbesVF6+iQAfVjOXW9bM/aax9WwMlABZW+oK9sbAZEXoewaPHmL34sxa8kVwWsru8cNE/yn2gg==", + "license": "MPL-2.0", + "dependencies": { + "bn.js": "^5.0.0", + "buffer-pipe": "0.0.3" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -2189,6 +2630,27 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -2205,11 +2667,19 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -2245,6 +2715,27 @@ "dev": true, "license": "MIT" }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/opener": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", + "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==", + "license": "(WTFPL OR MIT)", + "bin": { + "opener": "bin/opener-bin.js" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -2336,6 +2827,19 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/portfinder": { + "version": "1.0.38", + "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.38.tgz", + "integrity": "sha512-rEwq/ZHlJIKw++XtLAO8PPuOQA/zaPJOZJ37BVuN97nLpMJeuDVLVGRwbFoBgLudgdTMP2hdRJP++H+8QOA3vg==", + "license": "MIT", + "dependencies": { + "async": "^3.2.6", + "debug": "^4.3.6" + }, + "engines": { + "node": ">= 10.12" + } + }, "node_modules/postcss": { "version": "8.5.10", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", @@ -2375,6 +2879,20 @@ "node": ">= 0.8.0" } }, + "node_modules/pro_self_extracting_wasm": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/pro_self_extracting_wasm/-/pro_self_extracting_wasm-0.0.9.tgz", + "integrity": "sha512-95/dZfLmlGc/6Xp7gqvRBgXF8M+osw/Xtalz1U/Va8MpSC1TiR7rM4lEvAs1p/q4v/EZk6bow3tKEclbMGsSFQ==", + "license": "Apache-2.0", + "dependencies": { + "http-server": "^14.1.1", + "leb128": "^0.0.5", + "zx": "^8.6.1" + }, + "bin": { + "pro_self_extracting_wasm": "main.mjs" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -2385,6 +2903,22 @@ "node": ">=6" } }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/react": { "version": "19.2.5", "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", @@ -2407,6 +2941,27 @@ "react": "^19.2.5" } }, + "node_modules/regular-layout": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/regular-layout/-/regular-layout-0.6.1.tgz", + "integrity": "sha512-vGDEFACgFbS/d5kLWJ6AMWY/3DYaZoF1P8+y4KIDnPUFqUQgV5bV8avX7836izmexfmz6ik8JZ4V0Xo4FhaZGQ==", + "license": "Apache-2.0" + }, + "node_modules/regular-table": { + "version": "0.8.6", + "resolved": "https://registry.npmjs.org/regular-table/-/regular-table-0.8.6.tgz", + "integrity": "sha512-jzJzu9WLtqwMgbf5ak/VdNm/YLHUDnDSCHD5SOksnHrHLuDN6l5i2stYfe7BjsHbQV1Gx9369Ma40nTBCgOFvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=16" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, "node_modules/rolldown": { "version": "1.0.0-rc.17", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", @@ -2448,12 +3003,30 @@ "dev": true, "license": "MIT" }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/secure-compare": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/secure-compare/-/secure-compare-3.0.1.tgz", + "integrity": "sha512-AckIIV90rPDcBcglUwXPF3kg0P0qmPsPXAj6BBEENQE1p5yA1xfmDJzfi1Tappj37Pv2mVbKpL3Z1T+Nn7k1Qw==", + "license": "MIT" + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -2487,6 +3060,78 @@ "node": ">=8" } }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2497,6 +3142,28 @@ "node": ">=0.10.0" } }, + "node_modules/stoppable": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stoppable/-/stoppable-1.1.0.tgz", + "integrity": "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==", + "license": "MIT", + "engines": { + "node": ">=4", + "npm": ">=6" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/tailwindcss": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz", @@ -2556,6 +3223,17 @@ "node": ">= 0.8.0" } }, + "node_modules/union": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/union/-/union-0.5.0.tgz", + "integrity": "sha512-N6uOhuW6zO95P3Mel2I2zMsbsanvvtgn6jVqJv4vbVcz/JN0OkL9suomjQGmWtxJQXOCqUJvquc1sMeNz/IwlA==", + "dependencies": { + "qs": "^6.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -2597,6 +3275,12 @@ "punycode": "^2.1.0" } }, + "node_modules/url-join": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", + "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==", + "license": "MIT" + }, "node_modules/vite": { "version": "8.0.10", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", @@ -2676,6 +3360,19 @@ } } }, + "node_modules/whatwg-encoding": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", + "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -2702,6 +3399,27 @@ "node": ">=0.10.0" } }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -2745,6 +3463,18 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "node_modules/zx": { + "version": "8.8.5", + "resolved": "https://registry.npmjs.org/zx/-/zx-8.8.5.tgz", + "integrity": "sha512-SNgDF5L0gfN7FwVOdEFguY3orU5AkfFZm9B5YSHog/UDHv+lvmd82ZAsOenOkQixigwH2+yyH198AwNdKhj+RA==", + "license": "Apache-2.0", + "bin": { + "zx": "build/cli.js" + }, + "engines": { + "node": ">= 12.17.0" + } } } } diff --git a/ui/package.json b/ui/package.json index 7ebf77d..8d88e21 100644 --- a/ui/package.json +++ b/ui/package.json @@ -10,6 +10,10 @@ "preview": "vite preview" }, "dependencies": { + "@perspective-dev/client": "5.2.0", + "@perspective-dev/server": "5.2.0", + "@perspective-dev/viewer": "5.2.0", + "@perspective-dev/viewer-datagrid": "5.2.0", "react": "^19.2.5", "react-dom": "^19.2.5" }, diff --git a/ui/src/components/BridgeView.jsx b/ui/src/components/BridgeView.jsx new file mode 100644 index 0000000..fa06ea0 --- /dev/null +++ b/ui/src/components/BridgeView.jsx @@ -0,0 +1,402 @@ +// Bridge (waterfall): how a version got from its baseline to where it stands, +// one step per initiative tag. +// +// Data is computed from the Perspective table already in the browser rather than +// from the /bridge endpoint, so the figures always reconcile with what the pivot +// is showing — including when the view is scoped to the pivot's current filters. +// +// Colour is a POLARITY job, not a categorical one: increases and decreases are two +// poles of one scale, with baseline and current as neutral anchors. Blue/red is the +// validated diverging pair (CVD ΔE 21.6, normal-vision 32.3 against white); +// green/red is avoided precisely because it is the classic CVD failure. + +import { useState, useEffect, useRef, useCallback } from 'react' + +const UP = '#2a78d6' // increase +const DOWN = '#e34948' // decrease +const ANCHOR = '#6b7280' // baseline / current — neutral, 4.83:1 on white +const GRID = '#e5e7eb' +const INK = '#374151' +const INK_DIM = '#6b7280' + +const fmt = (n, dp = 2) => + n == null || !isFinite(n) ? '—' + : n.toLocaleString(undefined, { minimumFractionDigits: dp, maximumFractionDigits: dp }) + +const fmtSigned = (n, dp = 2) => + n == null || !isFinite(n) ? '—' : `${n > 0 ? '+' : n < 0 ? '−' : ''}${fmt(Math.abs(n), dp)}` + +// compact axis ticks — full precision belongs on the marks and in the table +function fmtAxis(n) { + const a = Math.abs(n) + if (a >= 1e9) return `${(n / 1e9).toFixed(1)}B` + if (a >= 1e6) return `${(n / 1e6).toFixed(1)}M` + if (a >= 1e3) return `${(n / 1e3).toFixed(1)}k` + return String(Math.round(n)) +} + +function niceTicks(min, max, count = 5) { + if (!isFinite(min) || !isFinite(max) || min === max) return [min || 0] + const span = max - min + const raw = span / count + const mag = Math.pow(10, Math.floor(Math.log10(raw))) + const step = [1, 2, 2.5, 5, 10].map(m => m * mag).find(s => s >= raw) || mag * 10 + const out = [] + for (let t = Math.ceil(min / step) * step; t <= max + 1e-9; t += step) out.push(t) + return out +} + +// Turn raw forecast rows into the walk: baseline anchor, one floating step per +// initiative tag, current anchor. Pure and exported so the arithmetic can be +// checked against real data without a browser. +export function buildSteps(rows, { valueCol, unitsCol, logMeta = {}, excludeIters = ['reference'] }) { + const excl = new Set(excludeIters) + const baseline = { value: 0, units: 0, rows: 0 } + const byTag = new Map() + + for (const r of rows) { + const iter = r.pf_iter + if (excl.has(iter)) continue + const v = parseFloat(r[valueCol]) || 0 + const u = unitsCol ? (parseFloat(r[unitsCol]) || 0) : 0 + + if (iter === 'baseline') { + baseline.value += v; baseline.units += u; baseline.rows += 1 + continue + } + const meta = logMeta[r.pf_logid] || {} + const tag = (meta.tag || '').trim() + const label = tag || (meta.note || '').trim() || + `${(meta.operation || iter || 'adj')}${r.pf_logid != null ? ` #${r.pf_logid}` : ''}` + const key = tag ? `tag:${tag}` : `log:${r.pf_logid}` + const g = byTag.get(key) || + { key, label, tagged: !!tag, value: 0, units: 0, rows: 0, logIds: new Set(), first: r.pf_logid } + g.value += v; g.units += u; g.rows += 1 + if (r.pf_logid != null) { g.logIds.add(r.pf_logid); g.first = Math.min(g.first ?? r.pf_logid, r.pf_logid) } + byTag.set(key, g) + } + + const mid = [...byTag.values()].sort((a, b) => (a.first ?? 0) - (b.first ?? 0)) + + let running = baseline.value + const out = [{ + key: 'baseline', label: 'Baseline', kind: 'anchor', + delta: baseline.value, start: 0, end: baseline.value, + units: baseline.units, rows: baseline.rows, entries: 1, + }] + for (const g of mid) { + const start = running + running += g.value + out.push({ ...g, kind: 'step', delta: g.value, start, end: running, entries: g.logIds.size }) + } + out.push({ + key: 'current', label: 'Current', kind: 'anchor', + delta: running, start: 0, end: running, + units: out.reduce((a, s) => a + (s.kind === 'anchor' ? 0 : s.units || 0), baseline.units), + rows: rows.filter(r => !excl.has(r.pf_iter)).length, + entries: mid.reduce((a, g) => a + g.logIds.size, 0) + 1, + }) + return out +} + +// Plot geometry, also pure: given the steps and a canvas size, where does each +// bar and label land? Exported so collisions and overflow can be checked. +export function layoutSteps(steps, width, H = 340, PAD = { t: 24, r: 16, b: 64, l: 68 }) { + const plotW = Math.max(120, width - PAD.l - PAD.r) + const plotH = H - PAD.t - PAD.b + const values = steps.flatMap(s => [s.start, s.end]) + const rawMin = Math.min(0, ...values) + const rawMax = Math.max(0, ...values) + const span = (rawMax - rawMin) || 1 + const yMin = rawMin - span * 0.08 + const yMax = rawMax + span * 0.12 + const y = (v) => PAD.t + plotH - ((v - yMin) / (yMax - yMin)) * plotH + + const n = steps.length || 1 + const band = plotW / n + const barW = Math.max(10, Math.min(64, band - 14)) + const bars = steps.map((s, i) => { + const x = PAD.l + band * i + (band - barW) / 2 + const top = y(Math.max(s.start, s.end)) + const bot = y(Math.min(s.start, s.end)) + return { key: s.key, x, w: barW, top, h: Math.max(2, bot - top), labelY: top - 6 } + }) + return { PAD, plotW, plotH, yMin, yMax, y, band, barW, bars, H, width } +} + +export default function BridgeView({ + open, onClose, tableRef, viewerRef, logMeta = {}, + valueCol, unitsCol, colMeta = [], slices = [], + excludeIters = ['reference'], versionName, +}) { + const hasSelection = slices.length > 0 + // 'selection' | 'filtered' | 'all' + const [scope, setScope] = useState(hasSelection ? 'selection' : 'filtered') + const [asTable, setAsTable] = useState(false) + const [steps, setSteps] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [hover, setHover] = useState(null) + const [width, setWidth] = useState(880) + const boxRef = useRef(null) + + // Build the steps: baseline anchor, one floating step per tag, current anchor. + const compute = useCallback(async () => { + if (!tableRef?.current || !valueCol) return + setLoading(true); setError(null) + try { + let rows + if (scope === 'selection') { + // The union of the selected slices — the same reach an operation would + // have. Perspective view filters are AND-only, so each slice needs its own + // view; rows matching more than one slice are counted once. + const dimNames = new Set(colMeta.filter(c => c.role === 'dimension').map(c => c.cname)) + const dateNames = new Set(colMeta.filter(c => c.role === 'date').map(c => c.cname)) + const seen = new Set() + rows = [] + for (const sl of slices) { + const f = [ + ...Object.entries(sl).filter(([c]) => dimNames.has(c)).map(([c, v]) => [c, '==', v]), + ...Object.entries(sl).filter(([c]) => dateNames.has(c)).map(([c, v]) => [c, '==', Number(v)]), + ] + if (!f.length) continue + const view = await tableRef.current.view({ filter: f }) + const part = await view.to_json() + await view.delete() + for (const r of part) { + if (r.pf_id != null && seen.has(r.pf_id)) continue + if (r.pf_id != null) seen.add(r.pf_id) + rows.push(r) + } + } + } else { + let filter = [] + if (scope === 'filtered' && viewerRef?.current) { + const cfg = await viewerRef.current.save() + filter = (cfg.filter || []).filter(f => Array.isArray(f) && f.length >= 2) + } + const view = await tableRef.current.view(filter.length ? { filter } : {}) + rows = await view.to_json() + await view.delete() + } + + setSteps(buildSteps(rows, { valueCol, unitsCol, logMeta, excludeIters })) + } catch (err) { + setError(err.message || String(err)) + setSteps(null) + } finally { + setLoading(false) + } + }, [tableRef, viewerRef, scope, logMeta, valueCol, unitsCol, excludeIters, slices, colMeta]) + + useEffect(() => { + if (!hasSelection && scope === 'selection') setScope('filtered') + }, [hasSelection, scope]) + + useEffect(() => { if (open) compute() }, [open, compute]) + + useEffect(() => { + if (!open || !boxRef.current) return + const ro = new ResizeObserver(([e]) => setWidth(Math.max(420, e.contentRect.width))) + ro.observe(boxRef.current) + return () => ro.disconnect() + }, [open]) + + if (!open) return null + + const geom = layoutSteps(steps || [{ start: 0, end: 0 }], width) + const { PAD, plotW, plotH, yMin, yMax, y, barW, H, bars } = geom + const xOf = (i) => bars[i]?.x ?? PAD.l + + const ticks = niceTicks(yMin, yMax, 5) + + return ( +
+
e.stopPropagation()}> + +
+
+ Bridge + {versionName && {versionName}} + + · {scope === 'selection' + ? `${slices.length} selected slice${slices.length === 1 ? '' : 's'}` + : scope === 'filtered' ? "pivot's filters" : 'whole version'} + +
+ +
+ + {/* controls — one row above the chart */} +
+ Scope +
+ {[ + ['selection', hasSelection ? `Selection (${slices.length})` : 'Selection', + hasSelection ? 'The slices selected in the operation panel' + : 'Select one or more pivot rows first'], + ['filtered', "Pivot's filters", 'Everything the pivot currently shows'], + ['all', 'Whole version', 'Every row in the version, filters ignored'], + ].map(([v, l, title]) => ( + + ))} +
+
+ + + + {/* legend — identity is never colour alone, but say it anyway */} +
+ {[['Increase', UP], ['Decrease', DOWN], ['Total', ANCHOR]].map(([l, c]) => ( + + + {l} + + ))} +
+
+ +
+ {error &&

{error}

} + {!error && !steps &&

Computing…

} + {!error && steps && steps.length <= 2 && ( +

+ No adjustments in scope — the bridge shows the walk from baseline to current, + and this selection has only a baseline. +

+ )} + + {!error && steps && steps.length > 2 && !asTable && ( +
+ + {/* recessive grid */} + {ticks.map(t => ( + + + + {fmtAxis(t)} + + + ))} + + {steps.map((s, i) => { + const isAnchor = s.kind === 'anchor' + const up = s.delta >= 0 + const fill = isAnchor ? ANCHOR : (up ? UP : DOWN) + const top = y(Math.max(s.start, s.end)) + const bot = y(Math.min(s.start, s.end)) + const h = Math.max(2, bot - top) + const x = xOf(i) + const on = hover?.key === s.key + return ( + setHover({ ...s, x: x + barW / 2, y: top })} + onMouseLeave={() => setHover(null)}> + {/* connector to the next bar, drawn behind */} + {i < steps.length - 1 && ( + + )} + {/* hit target larger than the mark */} + + + {/* direct label: few bars, so every one is labelled */} + + {isAnchor ? fmt(s.end, 0) : fmtSigned(s.delta, 0)} + + + {s.label.length > 12 ? `${s.label.slice(0, 11)}…` : s.label} + + {!isAnchor && s.entries > 1 && ( + + ×{s.entries} + + )} + {!s.tagged && !isAnchor && ( + + untagged + + )} + + ) + })} + + + {hover && ( +
+
{hover.label}
+
+ {hover.kind === 'anchor' ? fmt(hover.end) : fmtSigned(hover.delta)} +
+ {hover.kind === 'step' && ( +
+ running → {fmt(hover.end)} +
+ )} +
+ {hover.rows} row{hover.rows === 1 ? '' : 's'} + {hover.entries > 1 ? ` · ${hover.entries} adjustments` : ''} +
+
+ )} +
+ )} + + {/* table view — the same numbers, at full precision */} + {!error && steps && steps.length > 2 && asTable && ( + + + + + + {unitsCol && } + + + + + + + {steps.map(s => ( + + + + {unitsCol && ( + + )} + + + + + ))} + +
Step{valueCol}{unitsCol}RunningAdjustmentsRows
+ {s.label}{!s.tagged && s.kind === 'step' && · untagged} + + {s.kind === 'anchor' ? fmt(s.end) : fmtSigned(s.delta)} + + {s.kind === 'anchor' ? fmt(s.units) : fmtSigned(s.units)} + {fmt(s.end)}{s.kind === 'step' ? s.entries : '—'}{s.rows}
+ )} +
+
+
+ ) +} diff --git a/ui/src/components/OperationPanel.jsx b/ui/src/components/OperationPanel.jsx new file mode 100644 index 0000000..528d5d1 --- /dev/null +++ b/ui/src/components/OperationPanel.jsx @@ -0,0 +1,683 @@ +// The operation workbench: what is selected, what it currently totals, and the +// scale / recode / clone forms. Rendered by Forecast into one of three shells +// (bottom dock, right rail, floating window). +// +// Two layout rules drive this file: +// 1. Every value you are replacing sits on the same row as the input that +// replaces it — current on the left, new on the right, delta after it. +// Reading a total in one place and typing its replacement somewhere else +// is what made the old panel hard to follow. +// 2. Controls never stretch. The bottom dock is as wide as the window, so +// flex-1 buttons grew to absurd sizes; everything here is fixed-width and +// left-aligned instead. + +import { useState } from 'react' + +const INPUT = 'border border-gray-200 rounded px-2 py-1 text-xs bg-white w-28 text-right font-mono tabular-nums' +const TEXT = 'border border-gray-200 rounded px-2 py-1 text-xs bg-white w-40 font-mono' + +function fmtNum(n, decimals = 2) { + if (n == null || !isFinite(n)) return '—' + return n.toLocaleString(undefined, { minimumFractionDigits: decimals, maximumFractionDigits: decimals }) +} + +function fmtDelta(n, decimals = 2) { + if (n == null || !isFinite(n) || n === 0) return null + const sign = n > 0 ? '+' : '−' + return `${sign}${fmtNum(Math.abs(n), decimals)}` +} + +function sliceLabel(s) { + const entries = Object.entries(s) + return entries.length ? entries.map(([k, v]) => `${k}=${v}`).join(' · ') : '—' +} + +// A light grouping with an optional caption — a rule between blocks in the +// bottom dock, nothing but spacing elsewhere. +function Block({ title, hint, horizontal, grow, children }) { + return ( +
+ {title && ( +
+

{title}

+ {hint && {hint}} +
+ )} + {children} +
+ ) +} + +function Button({ onClick, active, children, title }) { + return ( + + ) +} + +function Segmented({ options, value, onChange }) { + return ( +
+ {options.map(([val, label, title]) => ( + + ))} +
+ ) +} + +function Submit({ onClick, children, disabled }) { + return ( + + ) +} + +// ── 1. Selection ──────────────────────────────────────────────────────────── +function SelectionList({ slices, currentTotals, onRemove, onClear }) { + const multi = slices.length > 1 + const perSlice = currentTotals?.perSlice || [] + const valueCol = currentTotals?.valueCol + + if (!slices.length) { + return ( +

+ Click a pivot row to select a slice.
+ Ctrl/⌘-click to add more. +

+ ) + } + + return ( +
+
+ + + {slices.map((s, i) => ( + + + {multi && valueCol && ( + + )} + + + ))} + +
+ {multi + ? {sliceLabel(s)} + : ( +
+ {Object.entries(s).map(([k, v]) => ( +
+ {k} + = + {v} +
+ ))} +
+ )} +
+ {fmtNum(perSlice[i]?.total?.value)} + + +
+
+ +
+ ) +} + +// Rows by pf_iter — useful context, but secondary to the numbers you are editing, +// so it collapses out of the way. +function IterBreakdown({ currentTotals }) { + const [open, setOpen] = useState(false) + const rows = currentTotals?.byIter || [] + if (rows.length < 2) return null + const { valueCol, unitsCol } = currentTotals + return ( +
+ + {open && ( + + + {rows.map(r => ( + + + {valueCol && } + {unitsCol && } + + ))} + +
{r.iter}{fmtNum(r.value)}{fmtNum(r.units)}
+ )} +
+ ) +} + +// ── 2. Scale ledger ───────────────────────────────────────────────────────── +// One continuous statement: where the number came from, what it is now, and what +// you want it to be — with the edit attached to the bottom of the same table +// rather than lifted into a separate block. +// +// Baseline 1,000.00 +// Scale -20.00 +// ───────────────────────── +// Current 1,070.00 +// ───────────────────────── +// New value [ 2,000 ] +// Change [ 930 ] +// % change [ 86.9 ] +// +// The last three rows are all editable and all describe the same change: type in +// any one and the other two follow. Whichever you typed in is what gets sent. +const FIELDS = [ + ['new', 'New value'], + ['change', 'Change'], + ['pct', '% change'], +] + +// given the active edit for a measure, what do the three rows read? +function derive(current, edit) { + const blank = { new: '', change: '', pct: '' } + if (!edit || edit.raw === '' || edit.raw == null) return blank + const n = parseFloat(edit.raw) + if (!isFinite(n)) return { ...blank, [edit.field]: edit.raw } + + let next + if (edit.field === 'new') next = n + else if (edit.field === 'change') next = current + n + else next = current + current * n / 100 + + const change = next - current + const pct = current === 0 ? null : (change / Math.abs(current)) * 100 + const out = { + new: fmtNum(next), + change: fmtNum(change), + pct: pct == null ? '—' : fmtNum(pct, 1), + } + out[edit.field] = edit.raw // keep what you typed exactly as typed + return out +} + +function LedgerInput({ value, active, onChange, onFocus, suffix }) { + return ( + + onChange(e.target.value)} + onFocus={onFocus} placeholder="—" + className={`border rounded px-2 py-0.5 text-xs w-24 text-right font-mono tabular-nums + ${active ? 'border-blue-400 bg-blue-50/40 text-gray-800' : 'border-gray-200 bg-white text-gray-700'}`} /> + {suffix && {suffix}} + + ) +} + +function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, targetBasis, setTargetBasis, logMeta = {}, multi, applyMode }) { + const valueCol = currentTotals?.valueCol + const unitsCol = currentTotals?.unitsCol + const total = currentTotals?.total || { value: 0, units: 0 } + const curPrice = total.units ? total.value / total.units : null + const entries = currentTotals?.byEntry || [] + + // The bridge: baseline, then one line per initiative, then whatever is untagged. + // Adjustments sharing a tag collapse into a single line, so the ledger reads as a + // walk from baseline to current rather than a list of log ids. + const lines = (() => { + const baseline = [] + const loose = [] + const byTag = new Map() + for (const e of entries) { + if (e.key === 'baseline') { baseline.push({ ...e, label: 'Baseline', kind: 'baseline' }); continue } + const meta = logMeta[e.logid] || {} + const tag = (meta.tag || '').trim() + if (tag) { + const g = byTag.get(tag) || + { key: `tag:${tag}`, label: tag, kind: 'tag', value: 0, units: 0, count: 0, first: e.logid } + g.value += e.value || 0 + g.units += e.units || 0 + g.count += 1 + g.first = Math.min(g.first ?? e.logid, e.logid ?? g.first) + byTag.set(tag, g) + } else { + const op = meta.operation || e.iter || 'adjustment' + loose.push({ + ...e, kind: 'entry', count: 1, + label: (meta.note || '').trim() || `${op.charAt(0).toUpperCase()}${op.slice(1)} #${e.logid}`, + }) + } + } + const tagged = [...byTag.values()].sort((a, b) => (a.first ?? 0) - (b.first ?? 0)) + return [...baseline, ...tagged, ...loose] + })() + const perSlice = multi && applyMode === 'each' + + // rows the pivot shows but this operation cannot write + const excl = currentTotals?.excluded || { value: 0, units: 0, rows: 0 } + const hasExcl = excl.rows > 0 && (excl.value !== 0 || excl.units !== 0) + const onTotal = hasExcl && targetBasis !== 'adjustable' // 'selected total' is the default + const grand = { value: total.value + excl.value, units: total.units + excl.units } + const exclName = (currentTotals?.excludedIters || []).join(' / ') || 'excluded' + + // the basis decides which line the editable rows are measured from + const basisOf = (key) => { + if (!onTotal) return key === 'price' ? curPrice : total[key] + if (key === 'price') return grand.units ? grand.value / grand.units : null + return grand[key] + } + + // measure columns, in ledger order + const measures = [ + valueCol && { key: 'value', label: valueCol, current: total.value, dp: 2 }, + unitsCol && { key: 'units', label: unitsCol, current: total.units, dp: 2 }, + (valueCol && unitsCol) && { key: 'price', label: 'price', current: curPrice, dp: 4, hint: 'holds units' }, + ].filter(Boolean) + + const derived = Object.fromEntries( + measures.map(m => [m.key, derive(basisOf(m.key) ?? 0, scaleInputs[m.key])]) + ) + + const setEdit = (key, field, raw) => + setScaleInputs(prev => ({ ...prev, [key]: { field, raw } })) + + // focusing a different row hands that measure's edit to the focused row, + // carrying across whatever it currently reads + const focusRow = (key, field) => + setScaleInputs(prev => { + const cur = prev[key] + if (cur && cur.field === field) return prev + const shown = derived[key]?.[field] ?? '' + const raw = shown === '—' ? '' : String(shown).replace(/,/g, '') + return { ...prev, [key]: { field, raw: cur ? raw : '' } } + }) + + const numCell = 'text-right font-mono tabular-nums whitespace-nowrap px-2' + const rule =
+ + return ( +
+ + + + + {measures.map(m => ( + + ))} + + + + {/* the walk from baseline to current, by initiative */} + {lines.map(e => ( + + + {measures.map(m => ( + + ))} + + ))} + + {rule}{measures.map(m => )} + + + + {measures.map(m => ( + + ))} + + + {/* Rows the pivot shows but operations cannot write. Listed so the + panel's figures reconcile with what the grid displays. */} + {hasExcl && ( + + + {measures.map(m => ( + + ))} + + )} + + {hasExcl && ( + + + {measures.map(m => ( + + ))} + + )} + + {rule}{measures.map(m => )} + + {/* the edit — three equivalent ways to say the same thing */} + {FIELDS.map(([field, label]) => ( + + + {measures.map(m => { + const active = scaleInputs[m.key]?.field === field + return ( + + ) + })} + + ))} + +
+ {m.label} + {m.hint && · {m.hint}} +
+ {e.kind === 'tag' && } + {e.label} + {e.kind === 'tag' && e.count > 1 && ×{e.count}} + + {m.key === 'price' ? '' : fmtNum(e[m.key], m.dp)} +
+ {hasExcl ? 'Adjustable' : 'Current'}{perSlice ? ' (all)' : ''} + {fmtNum(m.current, m.dp)}
+ {exclName} · fixed + + {m.key === 'price' ? '' : fmtNum(excl[m.key], m.dp)} +
Selected total + {m.key === 'price' + ? fmtNum(grand.units ? grand.value / grand.units : null, m.dp) + : fmtNum(grand[m.key], m.dp)} +
+ {label}{perSlice && field === 'new' ? ' (each)' : ''} + + setEdit(m.key, field, raw)} + onFocus={() => focusRow(m.key, field)} + suffix={field === 'pct' ? '%' : null} + /> +
+ + {hasExcl && ( +
+
+ Target applies to + +
+

+ {onTotal + ? `The ${exclName} rows cannot change, so the adjustable rows absorb the whole difference — the pivot will show your target.` + : `${exclName} rows are ignored. The pivot will show your target plus ${fmtNum(excl.value)}.`} +

+
+ )} + + {perSlice && ( +

+ Applied to each slice separately — the figures above are combined totals, + so each slice's own change will differ. +

+ )} +
+ ) +} + +// ── 2b. Recode / clone form ───────────────────────────────────────────────── +// Same pairing: the dimension's current value sits beside the box that replaces it. +function DimForm({ dimCols, setObj, setSet, slices, lookupDerivedCols, extra }) { + const multi = slices.length > 1 + const first = slices[0] || {} + return ( +
+ + + + + + + + + + {dimCols.map(c => { + const cur = multi + ? (new Set(slices.map(s => s[c.cname])).size > 1 ? '(varies)' : (first[c.cname] ?? '—')) + : (first[c.cname] ?? '—') + return ( + + + + + + ) + })} + +
dimensioncurrentnew value
{c.label || c.cname}{cur} + setSet(s => ({ ...s, [c.cname]: e.target.value }))} + onBlur={c.is_key && c.dim_group + ? e => lookupDerivedCols(c.cname, e.target.value, setSet) + : undefined} + placeholder="keep" + className={TEXT} /> +
+ {extra} +
+ ) +} + +// Recode and clone change dimensions rather than amounts, but you still want to +// see how much is on the move — and for clone, what it becomes after scaling. +function MovingTotal({ currentTotals, verb, factor }) { + const t = currentTotals?.total + if (!t) return null + const { valueCol, unitsCol } = currentTotals + const scaled = factor != null && factor !== 1 + return ( +

+ {verb}{' '} + {valueCol && {fmtNum(t.value)}} + {valueCol && {valueCol}} + {unitsCol && <> + · + {fmtNum(t.units)} + {unitsCol} + } + {scaled && valueCol && <> + + {fmtNum(t.value * factor)} + } +

+ ) +} + +// ── Apply mode ────────────────────────────────────────────────────────────── +function ApplyModeChooser({ op, applyMode, setApplyMode, count }) { + const options = op === 'scale' + ? [ + ['prorate', 'Together', `One pool of ${count} slices — a target is the new combined total, and each slice keeps its share of the mix.`], + ['each', 'Each', `All ${count} slices independently — every slice reaches the target on its own.`], + ] + : [ + ['prorate', 'Together', `One operation over all ${count} slices — a single log entry.`], + ['each', 'Each', `One operation per slice — ${count} log entries, undoable separately.`], + ] + const active = options.find(([v]) => v === applyMode) + return ( +
+ [v, l, t])} /> + {active &&

{active[2]}

} +
+ ) +} + +function RequestPreview({ payload }) { + const [open, setOpen] = useState(false) + if (!payload) return null + return ( +
+ + {open && ( +
+          {JSON.stringify(payload, null, 2)}
+        
+ )} +
+ ) +} + +export default function OperationPanel({ + dock, + slices, setSlices, distinctSlices, + applyMode, setApplyMode, + currentTotals, + activeOp, setActiveOp, + scaleInputs, setScaleInputs, + targetBasis, setTargetBasis, + opTag, setOpTag, knownTags = [], logMeta = {}, + scaleNote, setScaleNote, + recodeSet, setRecodeSet, + recodeNote, setRecodeNote, + cloneSet, setCloneSet, + cloneScale, setCloneScale, + cloneNote, setCloneNote, + dimCols, lookupDerivedCols, + buildPayload, submitOp, +}) { + const hasSlice = slices.length > 0 + const multi = slices.length > 1 + const horizontal = dock === 'bottom' + + const note = activeOp === 'scale' ? scaleNote : activeOp === 'recode' ? recodeNote : cloneNote + const setNote = activeOp === 'scale' ? setScaleNote : activeOp === 'recode' ? setRecodeNote : setCloneNote + const OP_LABEL = { scale: 'Apply Scale', recode: 'Apply Recode', clone: 'Apply Clone' } + + return ( +
+ + + {/* Cells that differ only by a column operations cannot filter on collapse + to the same slice — say so, rather than implying more reach than there is */} + {distinctSlices != null && distinctSlices < slices.length && ( +

+ {slices.length} cells selected, but they cover {distinctSlices} distinct{' '} + {distinctSlices === 1 ? 'slice' : 'slices'} — some differ only by a column + operations cannot target. The duplicates are applied once. +

+ )} + setSlices(prev => prev.filter((_, x) => x !== i))} + onClear={() => setSlices([])} + /> +
+ + {hasSlice && ( + +
+
+
+ {['scale', 'recode', 'clone'].map(op => ( + + ))} +
+ {multi && ( + + )} +
+ + {activeOp === 'scale' && ( + + )} + {activeOp === 'recode' && ( + } /> + )} + {activeOp === 'clone' && ( + +
+ scale cloned rows by + setCloneScale(e.target.value)} className={INPUT} /> +
+ +
+ } /> + )} +
+ + )} + + {hasSlice && ( + +
+ {/* Tag first: it is the field that gives an adjustment meaning later, + in the ledger and in the bridge. Completes from initiatives already + used on this source; free text is still accepted. */} +
+ tag + setOpTag(e.target.value)} + list="pf-tag-options" placeholder="initiative, e.g. reduce_spend" + className={`${TEXT} w-48`} /> + {opTag.trim() && ( + + )} +
+ {knownTags.length > 0 && ( +
+ {knownTags.slice(0, 6).map(t => ( + + ))} +
+ )} + +
+ note + setNote(e.target.value)} placeholder="optional" className={TEXT} /> +
+ submitOp(activeOp)}>{OP_LABEL[activeOp]} + +
+
+ )} +
+ ) +} diff --git a/ui/src/components/StatusBar.jsx b/ui/src/components/StatusBar.jsx index 55784cd..995d9d6 100644 --- a/ui/src/components/StatusBar.jsx +++ b/ui/src/components/StatusBar.jsx @@ -1,3 +1,4 @@ +import { useState, useEffect, useCallback } from 'react' import useTheme from '../theme.jsx' export default function StatusBar({ view, sources = [], sourceId, setSourceId, versions = [], versionId, setVersionId }) { @@ -5,8 +6,40 @@ export default function StatusBar({ view, sources = [], sourceId, setSourceId, v const showVersion = view === 'baseline' || view === 'forecast' const selectedVersion = versions.find(v => String(v.id) === String(versionId)) + const [info, setInfo] = useState(null) + const [showInfo, setShow] = useState(false) + const [copied, setCopied] = useState(false) + + const refreshInfo = useCallback(async () => { + if (!versionId || !showVersion) { setInfo(null); return } + try { + const r = await fetch(`/api/versions/${versionId}/table-info`) + setInfo(r.ok ? await r.json() : null) + } catch { setInfo(null) } + }, [versionId, showVersion]) + + useEffect(() => { refreshInfo() }, [refreshInfo]) + + // operations broadcast this after a write so the row count stays honest + useEffect(() => { + const onChange = () => refreshInfo() + window.addEventListener('pf-data-changed', onChange) + return () => window.removeEventListener('pf-data-changed', onChange) + }, [refreshInfo]) + + async function copyTable() { + if (!info?.fc_table) return + try { + await navigator.clipboard.writeText(info.fc_table) + setCopied(true) + setTimeout(() => setCopied(false), 1200) + } catch {} + } + + const fmt = (n) => n == null ? '—' : n.toLocaleString() + return ( -
+
Source