Compare commits

..

5 Commits

Author SHA1 Message Date
a0d39d44c0 Merge perspective-inline-5.2.0: 5.2.0 pin, multi-slice ops, bridge, drag-select
Brings the Perspective work forward onto the display-grain decision in
c5b12aa. Four commits:

- Load Perspective from the bundled /inline entrypoints and pin every
  package at 5.2.0, after the 4.x CDN bundle resolved its server WASM to
  an unversioned path and took the app down on 2026-08-10.
- Multi-slice operations (slices array, apply_mode prorate/each),
  per-measure resolution, target_basis, initiative tags, the tagged
  bridge, and the reworked adjustment panel.
- Drag to select a region, and show the selection on the grid.

Merges clean against c5b12aa; the two touch disjoint files.
2026-09-12 09:11:11 -04:00
099925b121 Drag to select a region, and show the selection on the grid
Two gaps in how the pivot reports a selection. Dragging across cells did
nothing, and a selection built from several ctrl-clicks was invisible on
the grid — the panel listed the slices but nothing on screen said which
cells they came from.

Drag-select
- The perspective-select handler was reading detail.selected and
  detail.insertConfigs. In 5.2.0 that event carries a ViewWindow —
  { start_row, end_row, start_col, end_col }; insertConfigs only appears
  on perspective-global-filter, and only in SELECT_ROW_TREE mode. So the
  handler always returned early and the whole path was dead.
- It fires on every mouseover as the region grows, so the handler now
  records the latest window and a window-level mouseup commits it. A
  single-cell region is skipped there: perspective-click already owns
  plain and modifier clicks, and handling it in both places would undo a
  ctrl-click toggle. Modifier+drag adds to the selection.
- 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__, column dimensions from the split_by segments of
  the column name. Reading the path rather than the rendered cell is
  what keeps dates as epoch millis instead of whatever the grid
  formatted them as. The grand-total row resolves to no dimension at
  all — that would mean the whole version — and is skipped.

Highlight
- The datagrid already highlights whatever sits in its own
  model._selection_state.selected_areas, and wipes that list on every
  mousedown, so a multi-click selection only ever showed the last cell.
  Keep a sliceKey -> rectangles map parallel to `slices` and push the
  full set back after each change, which gets the native highlight for
  every selected cell without any styling of our own.
- Deselecting anywhere — ctrl-click, the panel's x, Clear selection —
  prunes the map by live slice key, so the grid and the panel cannot
  disagree.

Also: edit_mode is forced to SELECT_REGION on restore rather than
defaulted, since a saved layout's plugin_config could previously
override it and turn selection off entirely.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoxNi8cFsQLPUSw3obb5NH
2026-09-12 08:51:14 -04:00
119065ef59 Update spec and CLAUDE.md for multi-slice, tags, and the bridge
Documentation had drifted far enough to mislead: the spec described the
single-slice panel, the target/delta modes that no longer exist, and the
old `slice` request shape, while both files still claimed Perspective
4.4.0 from a CDN when it has been bundled inline at 5.2.0 since August.

pf_spec.md
- pf.log gains `tag`, with why it is written by a follow-up UPDATE
  rather than through the per-source templates in pf.sql.
- Operations envelope documents `slices`, `apply_mode`, and the OR-of-
  AND-groups WHERE clause (and why it cannot be flattened to IN lists).
- Scale documents per-measure resolution and `target_basis`, plus the
  two guards: non-selective slices, and proration across a ~zero pool.
- New routes: PATCH /log/:logid, table-info, bridge, source tags.
- Forecast View rewritten for the dockable panel and the ledger; adds
  the selection caveat around pf_iter and the expand-depth explanation.
- New Bridge View section; Log View gains inline tag editing.
- Status block refreshed, with a Fixed subsection recording the three
  correctness bugs and their causes.
- Open Questions: adds bridge drill-down and targeting one iter band;
  notes what the bridge partially answers.

CLAUDE.md
- Corrects the Perspective version and the CDN claim.
- Project layout now lists components/, including the two new files.
- Selection section covers multi-select and why duplicate effective
  slices are collapsed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1TQiBYZbbWWkMNoCtUd8M
2026-09-11 23:34:08 -04:00
55814ee0d5 Multi-slice operations, tagged bridge, and a reworked adjustment panel
Forecast operations could only act on one clicked row at a time, and the
panel that drove them separated the numbers you were reading from the
inputs that changed them. This reworks both, and adds initiative tags so
a version's history can be read as a bridge.

Operations
- Accept `slices` (array) alongside the legacy single `slice`, with
  apply_mode 'prorate' (one pool) or 'each' (independent per slice).
- buildWhereAny() ORs the slices into one predicate. A union of slices
  cannot be flattened into per-column IN lists without over-selecting,
  and the result is parenthesised so the appended exclude clause does not
  bind wrong.
- resolveIncrs() now resolves each measure independently: target, percent
  or change amount per measure, so a target on value and a percent on
  units can be submitted together. Replaces the single global `mode`.
- target_basis chooses what a target measures against: only the rows an
  operation can write, or everything the pivot shows for the slice.
  Excluded iters are visible in the grid but immovable, so a target set
  against the visible total previously overshot by their contribution.

Two latent bugs surfaced by the above, both pre-existing:
- A slice naming no filterable column reduced to TRUE and applied the
  operation to the entire version. Now rejected on all three operations.
- Prorating across a pool that nets to ~zero multiplies each row's share
  by an exploding factor, sending rows to extreme opposite values to hit
  the target. Refused when the net falls below 1% of gross.

Tags and the bridge
- pf.log gains a nullable `tag`, written by a follow-up UPDATE rather
  than through the generated SQL: those templates are stored per source
  in pf.sql, so a {{tag}} token would strand any source that had not
  re-run "Generate SQL".
- Tag is editable after the fact in the change log, with completion from
  tags already used on the source. PATCH branches on whether a field was
  sent, so a tag can be cleared as well as set.
- BridgeView renders the walk from baseline to current as a waterfall,
  one step per tag, scoped to the selection, the pivot's filters, or the
  whole version. Computed from the loaded Perspective table so the
  figures always reconcile with what is on screen; overlapping slices are
  deduped by pf_id to match the OR semantics operations use.
- Colour is a polarity job, so it uses the validated diverging pair
  (blue/red, CVD dE 21.6) with neutral anchors, not categorical hues.
  Every bar is directly labelled and a table view is available.

Panel
- Extracted to OperationPanel; the scale form is one continuous ledger:
  baseline, each adjustment, current, then New value / Change / % change
  as three interchangeable editable rows. Typing in any one derives the
  others, which removes the target/delta/percent mode toggle entirely.
- Dockable bottom, right, or floating (drag to move, grip to resize), and
  closable via header, Esc, or the toolbar. Placement persists.
- Controls no longer stretch to the dock width, and text contrast now
  clears WCAG AA against white throughout.

Also: the status bar names the physical table writes land in, with live
row counts; and the pivot's expand depth is re-applied when the tab
regains focus, since Perspective rebuilds its view on redraw and a
ROLLUP view with no depth set renders fully expanded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1TQiBYZbbWWkMNoCtUd8M
2026-09-11 23:30:56 -04:00
99375bb534 Load Perspective from bundle, not CDN; pin all packages at 5.2.0
The pivot stopped rendering with:

  LinkError: WebAssembly.instantiate(): Import #8 "env" "psp_opfs_load":
  function import requires a callable

Nobody changed anything. 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 -- with no
version. jsdelivr serves @latest, so when @perspective-dev/server@5.2.0 was
published on 2026-08-10 every page load began linking a 5.2.0 WASM against a
4.4.0 client. 4.4.1 and 4.5.2 carry the identical unversioned pattern, so no
4.x pin is safe over CDN. Beyond the outage, an unversioned URL means users
execute whatever that package publishes next, unreviewed.

Switch to the /inline entrypoints, which embed the WASM in the Vite build:
no runtime fetch, and the version is fixed by package-lock.json (verified:
zero `new URL(...perspective-server...)` in perspective.inline.js).

- pin client/viewer/viewer-datagrid/server exact at 5.2.0. The explicit
  `server` pin matters: client declares it as "" (an empty range), which npm
  also resolves to latest -- the same break, at install time instead.
- drop the viewer-d3fc import. pf_app never selects a chart plugin, and d3fc
  has no 5.x; loading 4.4.1 against a 5.x viewer only emits
  `get_static_config is not a function` per plugin.
- themes move from a CDN <link> to @perspective-dev/viewer/themes.

Verified end-to-end with every non-localhost request aborted: no external
requests are attempted, both custom elements register, the Arrow stream
ingests, and the pivot renders (TOTAL 17,235.97 = -7,573.97 + 30,907.47
- 6,097.53). apache-arrow 21.1.0 ingests cleanly against the 5.2.0 WASM.
Bundle grows 263 KB -> 11.6 MB (5.4 MB gzipped); that is the embedded WASM.

PERSPECTIVE.md also records findings from the same investigation:

- §3a: expression columns are row-level, evaluated before aggregation, so a
  ratio like "revenue"/"qty" summed per row is wrong under any pivot (not
  just split_by). Fix is a weighted-mean aggregate, whose weight column must
  be a NESTED array: ['weighted mean', ['qty']]. Works in 4.4.0 and survives
  incremental table.update().
- §2: withdraws the recommendation of the 4.5.1-core + 4.4.1-d3fc pair. It
  does not deliver charts, so the trilemma is really a dilemma: inline
  bundling XOR charts. dataflow is on that pair and needs the same migration.
- §5: cleanLayout() does not sanitize `aggregates`; guard it before adopting
  the weighted-mean pattern or a dropped column aborts the whole restore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SBt3EtKaP9D2mmWJ6Q4bov
2026-08-17 21:30:19 -04:00
15 changed files with 3551 additions and 507 deletions

View File

@ -14,7 +14,7 @@ Data transport architecture options: `pf_perspective_options.md`
- **Backend:** Node.js / Express (`server.js`) - **Backend:** Node.js / Express (`server.js`)
- **Database:** PostgreSQL — isolated `pf` schema - **Database:** PostgreSQL — isolated `pf` schema
- **Frontend:** React + Vite + Tailwind CSS in `ui/`; built output lands in `public/app/` - **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/` - **Dev:** `npm run dev` (nodemon) in root; `npm run build` in `ui/`
--- ---
@ -38,9 +38,12 @@ ui/src/
views/ views/
Setup.jsx DB browser, source registration, col_meta editor Setup.jsx DB browser, source registration, col_meta editor
Baseline.jsx Version management, baseline workbench, reference load 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 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 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 ## 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. **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.

View File

@ -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, - **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 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 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 pull executable WASM from a third party on every load.
`ui/src/views/Forecast.jsx` — migrating off it is the main open item.)
> **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 - The themes CSS is imported in JS (`@perspective-dev/viewer/themes`), **not** via a
`<link>` in `index.html` — so it's bundled and versioned too. `<link>` 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)**: **(verified against installed metadata, 2026-06)**:
- **`viewer-d3fc` caps at 4.4.1** — npm publishes no 4.5.x. The d3fc charts (Bar / Line / - **`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`, - **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 `@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. 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 | | Inline WASM bundling (`/inline`, `/themes`) | **4.5.x** viewer/client |
| One coherent single-version suite | **4.4.1** everything (d3fc ceiling) | | 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: 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 **+ > ### ⚠️ CORRECTION (2026-08): the mixed pair does NOT deliver charts
`^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 > This section previously recommended `^4.5.1` viewer/client/datagrid + `^4.4.1`
bumps on the smoke test (§7). Do **not** "fix" it by pinning everything to 4.4.1 — the > viewer-d3fc as "the only combo that keeps both." **That recommendation was wrong.**
build breaks (`"./inline" is not exported`). >
> - **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 - **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 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 `npm install`. Re-evaluate the whole policy only when `viewer-d3fc` ships a 4.5.x or
fully-coherent inline-capable 4.5.x suite becomes possible). 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<string>]` (`ts-rs/Aggregate.d.ts`), so the weight
column goes in *its own array*. The flat form `['weighted mean', 'qty']` is rejected
with the unhelpful `data did not match any variant of untagged enum Aggregate` — which
reads like "no such aggregate" and is easy to misread as the feature being absent.
Available since **4.4.0** — no version bump needed, and because the aggregate is
evaluated inside the engine, incremental `table.update()` stays correct (verified: a
`table.update()` on a live view re-derives the weighted mean from the merged rows
without a reload).
### Rules of thumb
- Column is a **sum of a measure** (incl. `if(...)` column-subtotal expressions, §below)
→ leave the default `sum`. Those are unaffected by any of this.
- Column is a **ratio, rate, price, or per-unit figure** → it *must* carry a
`['weighted mean', ['<denominator>']]` aggregate, or it is wrong under any pivot.
- Mixing both in one view is fine and was verified.
### Type inference can silently break expressions
`if("Year" == '2026', "Amount", 0)` returns 0 for every row if `Year` was **inferred**
as `integer` — which happens to numeric-looking strings when the table is created from
inferred JSON. With an explicit `string` schema the same expression is correct. Neither
literal form (`'2026'` or `2026`) works against a mis-inferred column, and there is no
error. Give period/year columns an explicit `string` type at table creation.
### Not fixed by any of this: column-axis expand/collapse
`view.expand()` / `view.collapse()` / `set_depth()` take a **row index** and act on the
row axis only; there is no column-axis equivalent in 4.4.0 **or** 5.2.0, contrary to the
docs' claim that both axes support it. Confirmed directly against the API. 5.2.0 adds
`split_rollup_mode: 'rollup'`, which *emits* subtotal and grand-total column groups
statically (no interactivity) — the nearest thing to Excel-style column subtotals, and
it would retire the `if(...)`-expression workaround. It costs the d3fc charts, though
(§2: `viewer-d3fc` still caps at 4.4.1).
---
## 4. Theming ## 4. Theming
- One toggle drives both app CSS and the viewer: - 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 exist in the current dataset (plus any `expressions`). dataflow's `cleanLayout()` is
the reference implementation; a stale layout referencing a dropped column otherwise the reference implementation; a stale layout referencing a dropped column otherwise
throws on restore. 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. (`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`; 2. Pin all four packages + `apache-arrow` to exact, matching versions; `npm install`;
commit the lockfile. 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. 3. `vite build` — no unresolved imports.
4. **Arrow apps:** load a real dataset and confirm `worker.table(buffer)` ingests 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 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 | | | pf_app | dataflow | Target |
|---|---|---|---| |---|---|---|---|
| Loader | CDN (runtime) | npm `/inline` | **npm `/inline`** | | Loader | **npm `/inline`** (was CDN until 2026-08-17) | npm `/inline` | **npm `/inline`** |
| Version | 4.4.0 (CDN URLs) | 4.5.1 viewer/client + 4.4.1 d3fc | depends on loader (§2) | | 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) | | 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`** | | 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 **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. bundling and d3fc charts (§2). Leave it; just keep the lockfile committed.
**Open items:** **Open items:**
- pf_app → move off CDN. Note this forces the §2 choice: going npm-`/inline` means - ~~pf_app → move off CDN.~~ **Done 2026-08-17** — npm `/inline`, all four packages
4.5.x viewer/client + 4.4.1 d3fc (same pair as dataflow); or stay coherent at 4.4.x and pinned exact at 5.2.0, lockfile committed. Verified end-to-end with every external host
load via the `.` entry instead of `/inline`. Either way, pin + commit the lockfile, and blocked: viewer + datagrid register, the real Arrow stream ingests, the pivot renders.
add deploy automation (systemd + nginx + `deploy.sh`). - **dataflow → same migration.** It is on the withdrawn 4.5.1/4.4.1 pair (§2 correction):
its d3fc charts do not work, so it is paying mixed-version complexity for nothing.
Either drop d3fc and go to 5.2.0, or go coherent 4.4.1 — but verify charts actually
render before choosing the latter, because nobody has confirmed they do.
- pf_app still has no deploy automation (systemd + nginx + `deploy.sh`).

View File

@ -251,6 +251,35 @@ function buildWhere(slice, dimCols) {
return parts.length ? parts.join('\nAND ') : 'TRUE'; 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 // build AND iter NOT IN (...) from a version's exclude_iters array
function buildExcludeClause(excludeIters) { function buildExcludeClause(excludeIters) {
if (!excludeIters || excludeIters.length === 0) return ''; if (!excludeIters || excludeIters.length === 0) return '';
@ -309,4 +338,4 @@ function esc(val) {
return String(val).replace(/'/g, "''"); 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 };

View File

@ -113,10 +113,20 @@ CREATE TABLE pf.log (
operation text NOT NULL, -- 'baseline' | 'reference' | 'scale' | 'recode' | 'clone' operation text NOT NULL, -- 'baseline' | 'reference' | 'scale' | 'recode' | 'clone'
slice jsonb, -- the WHERE conditions that defined the selection slice jsonb, -- the WHERE conditions that defined the selection
params jsonb, -- operation parameters (increments, new values, scale factor, etc.) 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) ### `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'`). 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'`).
@ -320,14 +330,29 @@ All operations share a common request envelope:
{ {
"pf_user": "paul.trowbridge", "pf_user": "paul.trowbridge",
"note": "optional comment", "note": "optional comment",
"slice": { "tag": "reduce_spend",
"channel": "WHS", "slices": [ { "channel": "WHS", "geography": "WEST" },
"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 #### Scale
`POST /api/versions/:id/scale` `POST /api/versions/:id/scale`
@ -336,17 +361,46 @@ All operations share a common request envelope:
{ {
"pf_user": "paul.trowbridge", "pf_user": "paul.trowbridge",
"note": "10% volume lift Q3 West", "note": "10% volume lift Q3 West",
"slice": { "channel": "WHS", "geography": "WEST" }, "tag": "volume_push",
"value_incr": null, "slices": [ { "channel": "WHS", "geography": "WEST" } ],
"units_incr": 5000, "apply_mode": "prorate",
"pct": false "target_value": 12000,
"units_pct": 10,
"target_basis": "selected"
} }
``` ```
- `value_incr` / `units_incr` — absolute amounts to add (positive or negative). Either can be null. Each measure is resolved **independently**, so a target on one and a percentage on the
- `pct: true` — treat as percentage of current slice total instead of absolute other can be sent together. Per measure, exactly one of:
- Excludes `exclude_iters` rows from the source selection
- Distributes increment proportionally across rows in the slice | 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'` - Inserts rows tagged `iter = 'scale'`
#### Recode #### Recode
@ -356,7 +410,7 @@ All operations share a common request envelope:
{ {
"pf_user": "paul.trowbridge", "pf_user": "paul.trowbridge",
"note": "Part discontinued, replaced by new SKU", "note": "Part discontinued, replaced by new SKU",
"slice": { "part": "OLD-SKU-001" }, "slices": [ { "part": "OLD-SKU-001" } ],
"set": { "part": "NEW-SKU-002" } "set": { "part": "NEW-SKU-002" }
} }
``` ```
@ -374,7 +428,7 @@ All operations share a common request envelope:
{ {
"pf_user": "paul.trowbridge", "pf_user": "paul.trowbridge",
"note": "New customer win, similar profile to existing", "note": "New customer win, similar profile to existing",
"slice": { "customer": "EXISTING CO", "channel": "DIR" }, "slices": [ { "customer": "EXISTING CO", "channel": "DIR" } ],
"set": { "customer": "NEW CO" }, "set": { "customer": "NEW CO" },
"scale": 0.75 "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 | | 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 | | 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 ### 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] │ │ [Layout…] [Expand 0 1 2 3] [Refresh] [Change log] [Bridge] │
├──────────────────────────────────────┬──────────────────────────┤ │ [Hide panel] │
│ │ │ ├─────────────────────────────────────────────────────────────────┤
│ Perspective Viewer │ Operation Panel │ │ │
│ (interactive pivot web component) │ (active when slice set) │ │ Perspective Viewer (interactive pivot web component) │
│ │ │ │ │
│ │ Slice: │ ├──────────────── drag to resize ─────────────────────────────────┤
│ │ channel = WHS │ │ SLICE 2 selected │ scale recode clone │ Amount │
│ │ geography = WEST │ │ channel=WHS │ Together | Each │ Baseline 1,000.00 │
│ │ │ │ channel=DIR │ │ ▪ reduce_spend -20.00 │
│ │ [ Scale ] [ Recode ] │ │ Clear selection │ │ ──────────────────── │
│ │ [ Clone ] │ │ │ │ Adjustable 1,070.00 │
│ │ │ │ │ │ reference·fixed 921.72 │
│ │ ... operation form ... │ │ │ │ ──────────────────── │
│ │ │ │ │ │ Selected total 1,991.72 │
│ │ [ Submit ] │ │ │ │ ──────────────────── │
│ │ │ │ │ │ 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:** **Large-dataset loading sequence:**
1. Client issues `GET /api/versions/:id/data` 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:** **Interaction flow:**
1. Click a cell or row in the pivot — the `perspective-click` event fires 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 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 3. A plain click replaces the selection; **ctrl/⌘/shift-click toggles** a slice in or out of
4. Submit → POST to API → new rows returned via `RETURNING *` are streamed directly into the Perspective table (`pspTable.update(rows)`) — no full reload needed it. The `CustomEvent` carries no modifier flags, so they are read from the `mousedown`
5. For recode, both the negative offset rows and positive replacement rows are returned and streamed 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. **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 ### Log View
AG Grid list of log entries — user, timestamp, operation, slice, note, rows affected. 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). "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 - **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) - **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) - **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 - **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) - **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. - **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. - **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 ### What's working
- Full backend: source registration, col_meta, SQL generation, versions, baseline segments, reference load, scale, recode, clone, undo - 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 - 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 - 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) - 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 - Setup view: DB table browser with preview modal, source registration, col_meta editor, SQL generation
- Baseline view: version management (create/close/reopen/delete), multi-segment baseline workbench, canvas timeline, filter builder - 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 - 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 via `pspTable.update()` — no full reload
- Incremental row streaming: operation results (`RETURNING *`) applied to Perspective table via `pspTable.update()` — no full reload - **Multi-slice operations**: ctrl/⌘-click accumulates slices; `apply_mode` prorate/each
- Status bar: shows current source · version · baseline row count · status - **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 ### Known issues / next focus
- **Forecast view** — operation panel SQL generation complete; UI wiring to API still needed - **`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.
- **Load progress bar** — jittery at high throughput; throttle to ~10 updates/sec - **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.
- **Default pivot layout** — per-source configurable layout not yet implemented; currently hardcodes first 2 dimensions - **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.
- **No "current version" persistence** — source/version selection resets on page reload - **Bridge has no drill-down** — clicking a step does not list its adjustments or select that slice back in the pivot.
- **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 - **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. - **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`.

View File

@ -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) => { router.patch('/log/:logid', async (req, res) => {
const logId = parseInt(req.params.logid); 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 { 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( 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' }); if (!result.rows.length) return res.status(404).json({ error: 'Log entry not found' });
res.json(result.rows[0]); res.json(result.rows[0]);

View File

@ -1,14 +1,189 @@
const express = require('express'); const express = require('express');
const { tableFromArrays, tableToIPC } = require('apache-arrow'); 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'); const { fcTable } = require('../lib/utils');
module.exports = function(pool) { module.exports = function(pool) {
const router = express.Router(); const router = express.Router();
async function runSQL(sql) { async function runSQL(sql, client) {
console.log('--- SQL ---\n', sql, '\n--- END SQL ---'); 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: // 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) => { router.post('/versions/:id/scale', async (req, res) => {
const { pf_user, note, slice, value_incr, units_incr, pct } = req.body; const { pf_user, note, apply_mode } = req.body;
if (!slice || Object.keys(slice).length === 0) { const slices = normalizeSlices(req.body);
return res.status(400).json({ error: 'slice is required' }); if (slices.length === 0) return res.status(400).json({ error: 'slice is required' });
}
const applyMode = apply_mode === 'each' ? 'each' : 'prorate';
try { try {
const ctx = await getContext(parseInt(req.params.id), 'scale'); const ctx = await getContext(parseInt(req.params.id), 'scale');
if (!guardOpen(ctx.version, res)) return; if (!guardOpen(ctx.version, res)) return;
assertSelective(slices, ctx);
const whereClause = buildWhere(slice, ctx.filterCols);
const excludeClause = buildExcludeClause(ctx.version.exclude_iters); const excludeClause = buildExcludeClause(ctx.version.exclude_iters);
let absValueIncr = value_incr || 0; // 'prorate' pools every slice into one WHERE and lets the SQL's
let absUnitsIncr = units_incr || 0; // 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 const client = await pool.connect();
if (pct && (value_incr || units_incr)) { let committed = false;
const totals = await pool.query(` try {
SELECT await client.query('BEGIN');
sum("${ctx.valueCol}") AS total_value, const allRows = [];
sum("${ctx.unitsCol}") AS total_units let applied = 0;
FROM ${ctx.table} const skipped = [];
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;
}
if (absValueIncr === 0 && absUnitsIncr === 0) { for (const unit of units) {
return res.status(400).json({ error: 'value_incr and/or units_incr must be non-zero' }); 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, { const sql = applyTokens(ctx.sql, {
fc_table: ctx.table, fc_table: ctx.table,
version_id: ctx.version.id, version_id: ctx.version.id,
pf_user: esc(pf_user || ''), pf_user: esc(pf_user || ''),
note: esc(note || ''), note: esc(note || ''),
params: esc(JSON.stringify({ slice, value_incr, units_incr, pct })), params: esc(JSON.stringify({
slice: esc(JSON.stringify(slice)), slices: unit.slices,
where_clause: whereClause, 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, exclude_clause: excludeClause,
value_incr: absValueIncr, value_incr: incr.value,
units_incr: absUnitsIncr units_incr: incr.units
}); });
const result = await runSQL(sql, client);
await tagLog(client, result.rows, req.body.tag);
allRows.push(...result.rows);
}
const result = await runSQL(sql); if (allRows.length === 0) {
const rows = result.rows.map(r => ({ ...r, pf_note: note || null, pf_op: 'scale' })); await client.query('ROLLBACK');
res.json({ rows, rows_affected: rows.length }); 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();
}
} catch (err) { } catch (err) {
console.error(err); console.error(err);
res.status(err.status || 500).json({ error: err.message }); 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 // inserts negative rows to zero out the original, positive rows with new dimension values
router.post('/versions/:id/recode', async (req, res) => { router.post('/versions/:id/recode', async (req, res) => {
const { pf_user, note, slice, set } = req.body; const { pf_user, note, set, apply_mode } = req.body;
if (!slice || Object.keys(slice).length === 0) return res.status(400).json({ error: 'slice is required' }); 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' }); if (!set || Object.keys(set).length === 0) return res.status(400).json({ error: 'set is required' });
try { try {
const ctx = await getContext(parseInt(req.params.id), 'recode'); const ctx = await getContext(parseInt(req.params.id), 'recode');
if (!guardOpen(ctx.version, res)) return; if (!guardOpen(ctx.version, res)) return;
assertSelective(slices, ctx);
const whereClause = buildWhere(slice, ctx.filterCols);
const excludeClause = buildExcludeClause(ctx.version.exclude_iters); const excludeClause = buildExcludeClause(ctx.version.exclude_iters);
const setClause = buildSetClause(ctx.dimCols, set); const setClause = buildSetClause(ctx.dimCols, set);
const units = sliceUnits(slices, ctx, apply_mode);
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, { const sql = applyTokens(ctx.sql, {
fc_table: ctx.table, fc_table: ctx.table,
version_id: ctx.version.id, version_id: ctx.version.id,
pf_user: esc(pf_user || ''), pf_user: esc(pf_user || ''),
note: esc(note || ''), note: esc(note || ''),
params: esc(JSON.stringify({ slice, set })), params: esc(JSON.stringify({ slices: unit.slices, set, apply_mode: unit.mode })),
slice: esc(JSON.stringify(slice)), slice: esc(JSON.stringify(loggedSlice)),
where_clause: whereClause, where_clause: unit.where,
exclude_clause: excludeClause, exclude_clause: excludeClause,
set_clause: setClause set_clause: setClause
}); });
const result = await runSQL(sql, client);
const result = await runSQL(sql); await tagLog(client, result.rows, req.body.tag);
const rows = result.rows.map(r => ({ ...r, pf_note: note || null, pf_op: 'recode' })); allRows.push(...result.rows);
res.json({ rows, rows_affected: rows.length }); }
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) { } catch (err) {
console.error(err); console.error(err);
res.status(err.status || 500).json({ error: err.message }); 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 // does not offset the original slice
router.post('/versions/:id/clone', async (req, res) => { router.post('/versions/:id/clone', async (req, res) => {
const { pf_user, note, slice, set, scale } = req.body; const { pf_user, note, set, scale, apply_mode } = req.body;
if (!slice || Object.keys(slice).length === 0) return res.status(400).json({ error: 'slice is required' }); 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' }); if (!set || Object.keys(set).length === 0) return res.status(400).json({ error: 'set is required' });
try { try {
const ctx = await getContext(parseInt(req.params.id), 'clone'); const ctx = await getContext(parseInt(req.params.id), 'clone');
if (!guardOpen(ctx.version, res)) return; if (!guardOpen(ctx.version, res)) return;
assertSelective(slices, ctx);
const scaleFactor = (scale != null) ? parseFloat(scale) : 1.0; const scaleFactor = (scale != null) ? parseFloat(scale) : 1.0;
const whereClause = buildWhere(slice, ctx.filterCols);
const excludeClause = buildExcludeClause(ctx.version.exclude_iters); const excludeClause = buildExcludeClause(ctx.version.exclude_iters);
const setClause = buildSetClause(ctx.dimCols, set); const setClause = buildSetClause(ctx.dimCols, set);
const units = sliceUnits(slices, ctx, apply_mode);
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, { const sql = applyTokens(ctx.sql, {
fc_table: ctx.table, fc_table: ctx.table,
version_id: ctx.version.id, version_id: ctx.version.id,
pf_user: esc(pf_user || ''), pf_user: esc(pf_user || ''),
note: esc(note || ''), note: esc(note || ''),
params: esc(JSON.stringify({ slice, set, scale: scaleFactor })), params: esc(JSON.stringify({ slices: unit.slices, set, scale: scaleFactor, apply_mode: unit.mode })),
slice: esc(JSON.stringify(slice)), slice: esc(JSON.stringify(loggedSlice)),
where_clause: whereClause, where_clause: unit.where,
exclude_clause: excludeClause, exclude_clause: excludeClause,
set_clause: setClause, set_clause: setClause,
scale_factor: scaleFactor scale_factor: scaleFactor
}); });
const result = await runSQL(sql, client);
const result = await runSQL(sql); await tagLog(client, result.rows, req.body.tag);
const rows = result.rows.map(r => ({ ...r, pf_note: note || null, pf_op: 'clone' })); allRows.push(...result.rows);
res.json({ rows, rows_affected: rows.length }); }
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) { } catch (err) {
console.error(err); console.error(err);
res.status(err.status || 500).json({ error: err.message }); res.status(err.status || 500).json({ error: err.message });

View File

@ -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 // update version name, description, or exclude_iters
router.put('/versions/:id', async (req, res) => { router.put('/versions/:id', async (req, res) => {
const { name, description, exclude_iters } = req.body; const { name, description, exclude_iters } = req.body;

View File

@ -55,9 +55,15 @@ CREATE TABLE IF NOT EXISTS pf.log (
operation text NOT NULL, -- baseline | reference | scale | recode | clone operation text NOT NULL, -- baseline | reference | scale | recode | clone
slice jsonb, slice jsonb,
params 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 -- generated operation SQL per source, stored after col_meta is configured
CREATE TABLE IF NOT EXISTS pf.sql ( CREATE TABLE IF NOT EXISTS pf.sql (
id serial PRIMARY KEY, id serial PRIMARY KEY,

View File

@ -5,7 +5,6 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Pivot Forecast</title> <title>Pivot Forecast</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@perspective-dev/viewer@4.4.0/dist/css/themes.css" crossorigin="anonymous">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

742
ui/package-lock.json generated
View File

@ -8,6 +8,10 @@
"name": "ui", "name": "ui",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "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": "^19.2.5",
"react-dom": "^19.2.5" "react-dom": "^19.2.5"
}, },
@ -266,9 +270,9 @@
} }
}, },
"node_modules/@emnapi/wasi-threads": { "node_modules/@emnapi/wasi-threads": {
"version": "1.2.1", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz",
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@ -549,6 +553,46 @@
"url": "https://github.com/sponsors/Boshen" "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": { "node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.17", "version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", "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": "^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": { "node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.0-rc.17", "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", "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" "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": { "node_modules/balanced-match": {
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
@ -1228,6 +1327,24 @@
"node": ">=6.0.0" "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": { "node_modules/brace-expansion": {
"version": "5.0.5", "version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", "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": "^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": { "node_modules/caniuse-lite": {
"version": "1.0.30001790", "version": "1.0.30001790",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001790.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001790.tgz",
@ -1297,6 +1452,40 @@
], ],
"license": "CC-BY-4.0" "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": { "node_modules/convert-source-map": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@ -1304,6 +1493,15 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -1330,7 +1528,6 @@
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ms": "^2.1.3" "ms": "^2.1.3"
@ -1361,6 +1558,20 @@
"node": ">=8" "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": { "node_modules/electron-to-chromium": {
"version": "1.5.344", "version": "1.5.344",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz",
@ -1382,6 +1593,36 @@
"node": ">=10.13.0" "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": { "node_modules/escalade": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@ -1588,6 +1829,12 @@
"node": ">=0.10.0" "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": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@ -1678,6 +1925,26 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "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": "^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": { "node_modules/gensync": {
"version": "1.0.0-beta.2", "version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@ -1703,6 +1979,43 @@
"node": ">=6.9.0" "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": { "node_modules/glob-parent": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@ -1729,6 +2042,18 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/graceful-fs": {
"version": "4.2.11", "version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@ -1736,6 +2061,48 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/hermes-estree": {
"version": "0.25.1", "version": "0.25.1",
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
@ -1753,6 +2120,71 @@
"hermes-estree": "0.25.1" "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": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@ -1809,7 +2241,6 @@
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"jiti": "lib/jiti-cli.mjs" "jiti": "lib/jiti-cli.mjs"
} }
@ -1878,6 +2309,16 @@
"json-buffer": "3.0.1" "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": { "node_modules/levn": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@ -2189,6 +2630,27 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@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": { "node_modules/minimatch": {
"version": "10.2.5", "version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
@ -2205,11 +2667,19 @@
"url": "https://github.com/sponsors/isaacs" "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": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
@ -2245,6 +2715,27 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/optionator": {
"version": "0.9.4", "version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@ -2336,6 +2827,19 @@
"url": "https://github.com/sponsors/jonschlinkert" "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": { "node_modules/postcss": {
"version": "8.5.10", "version": "8.5.10",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
@ -2375,6 +2879,20 @@
"node": ">= 0.8.0" "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": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@ -2385,6 +2903,22 @@
"node": ">=6" "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": { "node_modules/react": {
"version": "19.2.5", "version": "19.2.5",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
@ -2407,6 +2941,27 @@
"react": "^19.2.5" "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": { "node_modules/rolldown": {
"version": "1.0.0-rc.17", "version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz",
@ -2448,12 +3003,30 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/scheduler": {
"version": "0.27.0", "version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT" "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": { "node_modules/semver": {
"version": "6.3.1", "version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
@ -2487,6 +3060,78 @@
"node": ">=8" "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": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@ -2497,6 +3142,28 @@
"node": ">=0.10.0" "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": { "node_modules/tailwindcss": {
"version": "4.2.4", "version": "4.2.4",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz",
@ -2556,6 +3223,17 @@
"node": ">= 0.8.0" "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": { "node_modules/update-browserslist-db": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
@ -2597,6 +3275,12 @@
"punycode": "^2.1.0" "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": { "node_modules/vite": {
"version": "8.0.10", "version": "8.0.10",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", "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": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@ -2702,6 +3399,27 @@
"node": ">=0.10.0" "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": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
@ -2745,6 +3463,18 @@
"peerDependencies": { "peerDependencies": {
"zod": "^3.25.0 || ^4.0.0" "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"
}
} }
} }
} }

View File

@ -10,6 +10,10 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "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": "^19.2.5",
"react-dom": "^19.2.5" "react-dom": "^19.2.5"
}, },

View File

@ -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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
<div className="bg-white rounded-lg shadow-xl w-full max-w-5xl mx-4 flex flex-col max-h-[88vh]"
onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-200 shrink-0">
<div className="flex items-baseline gap-2">
<span className="font-medium text-gray-700 text-sm">Bridge</span>
{versionName && <span className="text-gray-600 text-xs">{versionName}</span>}
<span className="text-gray-600 text-xs">
· {scope === 'selection'
? `${slices.length} selected slice${slices.length === 1 ? '' : 's'}`
: scope === 'filtered' ? "pivot's filters" : 'whole version'}
</span>
</div>
<button onClick={onClose} className="text-gray-600 hover:text-gray-800 text-lg leading-none">×</button>
</div>
{/* controls — one row above the chart */}
<div className="flex items-center gap-3 px-5 py-2 border-b border-gray-100 shrink-0 text-xs flex-wrap">
<span className="text-gray-600">Scope</span>
<div className="inline-flex rounded border border-gray-200 overflow-hidden">
{[
['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]) => (
<button key={v} onClick={() => setScope(v)} title={title}
disabled={v === 'selection' && !hasSelection}
className={`px-3 py-1 disabled:opacity-40 disabled:cursor-not-allowed ${
scope === v ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50'}`}>
{l}
</button>
))}
</div>
<div className="w-px h-4 bg-gray-200" />
<button onClick={() => setAsTable(t => !t)}
className="border border-gray-200 rounded px-2 py-1 text-gray-700 hover:bg-gray-50">
{asTable ? 'Show chart' : 'Show table'}
</button>
<button onClick={compute} disabled={loading}
className="border border-gray-200 rounded px-2 py-1 text-gray-700 hover:bg-gray-50 disabled:opacity-40">
{loading ? 'Computing…' : 'Refresh'}
</button>
{/* legend — identity is never colour alone, but say it anyway */}
<div className="ml-auto flex items-center gap-3 text-gray-700">
{[['Increase', UP], ['Decrease', DOWN], ['Total', ANCHOR]].map(([l, c]) => (
<span key={l} className="inline-flex items-center gap-1.5">
<span className="inline-block w-2.5 h-2.5 rounded-sm" style={{ background: c }} />
{l}
</span>
))}
</div>
</div>
<div className="overflow-auto p-5" ref={boxRef}>
{error && <p className="text-red-600">{error}</p>}
{!error && !steps && <p className="text-gray-600">Computing</p>}
{!error && steps && steps.length <= 2 && (
<p className="text-gray-600">
No adjustments in scope the bridge shows the walk from baseline to current,
and this selection has only a baseline.
</p>
)}
{!error && steps && steps.length > 2 && !asTable && (
<div className="relative">
<svg width={width} height={H} role="img"
aria-label={`Bridge from baseline ${fmt(steps[0].end)} to current ${fmt(steps[steps.length - 1].end)}`}>
{/* recessive grid */}
{ticks.map(t => (
<g key={t}>
<line x1={PAD.l} x2={PAD.l + plotW} y1={y(t)} y2={y(t)}
stroke={t === 0 ? '#d1d5db' : GRID} strokeWidth={t === 0 ? 1.5 : 1} />
<text x={PAD.l - 8} y={y(t) + 3} textAnchor="end" fontSize="10" fill={INK_DIM}>
{fmtAxis(t)}
</text>
</g>
))}
{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 (
<g key={s.key}
onMouseEnter={() => setHover({ ...s, x: x + barW / 2, y: top })}
onMouseLeave={() => setHover(null)}>
{/* connector to the next bar, drawn behind */}
{i < steps.length - 1 && (
<line x1={x + barW} x2={xOf(i + 1)} y1={y(s.end)} y2={y(s.end)}
stroke="#cbd5e1" strokeWidth="1" strokeDasharray="2 2" />
)}
{/* hit target larger than the mark */}
<rect x={x - 6} y={PAD.t} width={barW + 12} height={plotH} fill="transparent" />
<rect x={x} y={top} width={barW} height={h} rx="4" fill={fill}
opacity={on ? 1 : 0.92}
stroke="#ffffff" strokeWidth="2" />
{/* direct label: few bars, so every one is labelled */}
<text x={x + barW / 2} y={top - 6} textAnchor="middle" fontSize="10"
fill={INK} fontWeight="500">
{isAnchor ? fmt(s.end, 0) : fmtSigned(s.delta, 0)}
</text>
<text x={x + barW / 2} y={PAD.t + plotH + 16} textAnchor="middle" fontSize="10" fill={INK}>
{s.label.length > 12 ? `${s.label.slice(0, 11)}` : s.label}
</text>
{!isAnchor && s.entries > 1 && (
<text x={x + barW / 2} y={PAD.t + plotH + 29} textAnchor="middle" fontSize="9" fill={INK_DIM}>
×{s.entries}
</text>
)}
{!s.tagged && !isAnchor && (
<text x={x + barW / 2} y={PAD.t + plotH + 29} textAnchor="middle" fontSize="9" fill={INK_DIM}>
untagged
</text>
)}
</g>
)
})}
</svg>
{hover && (
<div className="absolute pointer-events-none bg-white border border-gray-300 rounded shadow-lg px-2.5 py-1.5 text-xs"
style={{ left: Math.min(hover.x + 10, width - 190), top: Math.max(0, hover.y - 10) }}>
<div className="font-medium text-gray-800">{hover.label}</div>
<div className="text-gray-700 font-mono tabular-nums">
{hover.kind === 'anchor' ? fmt(hover.end) : fmtSigned(hover.delta)}
</div>
{hover.kind === 'step' && (
<div className="text-gray-600">
running <span className="font-mono tabular-nums">{fmt(hover.end)}</span>
</div>
)}
<div className="text-gray-600">
{hover.rows} row{hover.rows === 1 ? '' : 's'}
{hover.entries > 1 ? ` · ${hover.entries} adjustments` : ''}
</div>
</div>
)}
</div>
)}
{/* table view — the same numbers, at full precision */}
{!error && steps && steps.length > 2 && asTable && (
<table className="w-full text-xs">
<thead>
<tr className="text-gray-600 border-b border-gray-200">
<th className="text-left py-1.5 pr-3 font-medium">Step</th>
<th className="text-right py-1.5 px-2 font-medium">{valueCol}</th>
{unitsCol && <th className="text-right py-1.5 px-2 font-medium">{unitsCol}</th>}
<th className="text-right py-1.5 px-2 font-medium">Running</th>
<th className="text-right py-1.5 px-2 font-medium">Adjustments</th>
<th className="text-right py-1.5 pl-2 font-medium">Rows</th>
</tr>
</thead>
<tbody>
{steps.map(s => (
<tr key={s.key} className="border-b border-gray-100">
<td className="py-1.5 pr-3 text-gray-800">
{s.label}{!s.tagged && s.kind === 'step' && <span className="text-gray-600"> · untagged</span>}
</td>
<td className="py-1.5 px-2 text-right font-mono tabular-nums text-gray-800">
{s.kind === 'anchor' ? fmt(s.end) : fmtSigned(s.delta)}
</td>
{unitsCol && (
<td className="py-1.5 px-2 text-right font-mono tabular-nums text-gray-700">
{s.kind === 'anchor' ? fmt(s.units) : fmtSigned(s.units)}
</td>
)}
<td className="py-1.5 px-2 text-right font-mono tabular-nums text-gray-700">{fmt(s.end)}</td>
<td className="py-1.5 px-2 text-right text-gray-700">{s.kind === 'step' ? s.entries : '—'}</td>
<td className="py-1.5 pl-2 text-right text-gray-700">{s.rows}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
</div>
)
}

View File

@ -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 (
<section className={[
horizontal ? 'pl-5 border-l border-gray-200 first:pl-0 first:border-l-0' : '',
grow ? 'flex-1 min-w-0' : 'shrink-0',
].join(' ')}>
{title && (
<header className="flex items-baseline gap-1.5 mb-2">
<h3 className="font-semibold text-gray-600 uppercase tracking-wide" style={{ fontSize: '10px' }}>{title}</h3>
{hint && <span className="text-gray-600" style={{ fontSize: '10px' }}>{hint}</span>}
</header>
)}
{children}
</section>
)
}
function Button({ onClick, active, children, title }) {
return (
<button onClick={onClick} title={title}
className={`px-3 py-1 rounded text-xs whitespace-nowrap transition-colors ${
active ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50'}`}>
{children}
</button>
)
}
function Segmented({ options, value, onChange }) {
return (
<div className="inline-flex rounded border border-gray-200 overflow-hidden w-auto self-start">
{options.map(([val, label, title]) => (
<Button key={val} onClick={() => onChange(val)} active={value === val} title={title}>{label}</Button>
))}
</div>
)
}
function Submit({ onClick, children, disabled }) {
return (
<button onClick={onClick} disabled={disabled}
className="self-start px-4 py-1.5 rounded text-xs font-medium bg-blue-600 text-white hover:bg-blue-700
disabled:bg-gray-200 disabled:text-gray-600 disabled:cursor-not-allowed whitespace-nowrap">
{children}
</button>
)
}
// 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 (
<p className="text-gray-600 italic leading-relaxed">
Click a pivot row to select a slice.<br />
<span className="text-gray-500">Ctrl/-click to add more.</span>
</p>
)
}
return (
<div className="min-w-0">
<div className="overflow-auto max-h-36 -mx-1 px-1">
<table className="w-full">
<tbody>
{slices.map((s, i) => (
<tr key={i} className="align-top hover:bg-gray-50">
<td className="py-0.5 pr-2">
{multi
? <span className="font-mono text-gray-700">{sliceLabel(s)}</span>
: (
<div className="flex flex-col gap-0.5">
{Object.entries(s).map(([k, v]) => (
<div key={k} className="whitespace-nowrap">
<span className="text-gray-600">{k}</span>
<span className="text-gray-500"> = </span>
<span className="font-medium text-gray-700 font-mono">{v}</span>
</div>
))}
</div>
)}
</td>
{multi && valueCol && (
<td className="py-0.5 pl-2 text-right font-mono tabular-nums text-gray-600 whitespace-nowrap">
{fmtNum(perSlice[i]?.total?.value)}
</td>
)}
<td className="py-0.5 pl-1 text-right">
<button onClick={() => onRemove(i)} title="Remove from selection"
className="text-gray-500 hover:text-red-500 leading-none px-1">×</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<button onClick={onClear} className="text-gray-600 hover:text-red-500 mt-1.5">Clear selection</button>
</div>
)
}
// 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 (
<div className="mt-2">
<button onClick={() => setOpen(o => !o)} className="text-gray-600 hover:text-gray-600">
{open ? '▾' : '▸'} breakdown by iter
</button>
{open && (
<table className="mt-1 text-gray-500">
<tbody>
{rows.map(r => (
<tr key={r.iter}>
<td className="capitalize pr-3">{r.iter}</td>
{valueCol && <td className="text-right font-mono tabular-nums pl-2">{fmtNum(r.value)}</td>}
{unitsCol && <td className="text-right font-mono tabular-nums pl-2">{fmtNum(r.units)}</td>}
</tr>
))}
</tbody>
</table>
)}
</div>
)
}
// 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 (
<span className="inline-flex items-center gap-1">
<input
type="text" inputMode="decimal" value={value} onChange={e => 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 && <span className="text-gray-500" style={{ fontSize: '10px' }}>{suffix}</span>}
</span>
)
}
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 = <td className="p-0"><div className="border-t border-gray-300 my-1" /></td>
return (
<div className="min-w-0">
<table>
<thead>
<tr className="text-gray-600" style={{ fontSize: '10px' }}>
<th className="text-left font-normal pb-1 pr-3"></th>
{measures.map(m => (
<th key={m.key} className="text-right font-normal pb-1 px-2 whitespace-nowrap">
{m.label}
{m.hint && <span className="text-gray-500"> · {m.hint}</span>}
</th>
))}
</tr>
</thead>
<tbody>
{/* the walk from baseline to current, by initiative */}
{lines.map(e => (
<tr key={e.key} className="text-gray-600">
<td className="pr-3 whitespace-nowrap max-w-[14rem] truncate" title={e.label}>
{e.kind === 'tag' && <span className="text-blue-600"> </span>}
{e.label}
{e.kind === 'tag' && e.count > 1 && <span className="text-gray-500"> ×{e.count}</span>}
</td>
{measures.map(m => (
<td key={m.key} className={`${numCell} text-gray-600`}>
{m.key === 'price' ? '' : fmtNum(e[m.key], m.dp)}
</td>
))}
</tr>
))}
<tr>{rule}{measures.map(m => <td key={m.key} className="p-0 px-2"><div className="border-t border-gray-300 my-1" /></td>)}</tr>
<tr className={onTotal ? 'text-gray-600' : 'font-semibold text-gray-700'}>
<td className="pr-3 whitespace-nowrap">
{hasExcl ? 'Adjustable' : 'Current'}{perSlice ? ' (all)' : ''}
</td>
{measures.map(m => (
<td key={m.key} className={numCell}>{fmtNum(m.current, m.dp)}</td>
))}
</tr>
{/* Rows the pivot shows but operations cannot write. Listed so the
panel's figures reconcile with what the grid displays. */}
{hasExcl && (
<tr className="text-gray-600">
<td className="pr-3 whitespace-nowrap">
{exclName} <span className="text-gray-500">· fixed</span>
</td>
{measures.map(m => (
<td key={m.key} className={numCell}>
{m.key === 'price' ? '' : fmtNum(excl[m.key], m.dp)}
</td>
))}
</tr>
)}
{hasExcl && (
<tr className={onTotal ? 'font-semibold text-gray-700' : 'text-gray-600'}>
<td className="pr-3 whitespace-nowrap">Selected total</td>
{measures.map(m => (
<td key={m.key} className={numCell}>
{m.key === 'price'
? fmtNum(grand.units ? grand.value / grand.units : null, m.dp)
: fmtNum(grand[m.key], m.dp)}
</td>
))}
</tr>
)}
<tr>{rule}{measures.map(m => <td key={m.key} className="p-0 px-2"><div className="border-t border-gray-300 my-1" /></td>)}</tr>
{/* the edit — three equivalent ways to say the same thing */}
{FIELDS.map(([field, label]) => (
<tr key={field}>
<td className="pr-3 py-0.5 text-gray-500 whitespace-nowrap">
{label}{perSlice && field === 'new' ? ' (each)' : ''}
</td>
{measures.map(m => {
const active = scaleInputs[m.key]?.field === field
return (
<td key={m.key} className="px-2 py-0.5 text-right">
<LedgerInput
value={derived[m.key]?.[field] ?? ''}
active={active}
onChange={(raw) => setEdit(m.key, field, raw)}
onFocus={() => focusRow(m.key, field)}
suffix={field === 'pct' ? '%' : null}
/>
</td>
)
})}
</tr>
))}
</tbody>
</table>
{hasExcl && (
<div className="flex flex-col gap-1 mt-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-gray-600 whitespace-nowrap">Target applies to</span>
<Segmented
value={onTotal ? 'selected' : 'adjustable'}
onChange={setTargetBasis}
options={[
['adjustable', 'Adjustable', 'Measure against only the rows this operation can write'],
['selected', 'Selected total', 'Measure against everything the pivot shows, fixed rows included'],
]}
/>
</div>
<p className="text-gray-600 leading-snug max-w-md">
{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)}.`}
</p>
</div>
)}
{perSlice && (
<p className="text-gray-600 leading-snug mt-2 max-w-md">
Applied to each slice separately the figures above are combined totals,
so each slice's own change will differ.
</p>
)}
</div>
)
}
// 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 (
<div className="flex flex-col gap-2.5 min-w-0">
<table>
<thead>
<tr className="text-gray-600" style={{ fontSize: '10px' }}>
<th className="text-left font-normal pb-1 pr-3">dimension</th>
<th className="text-left font-normal pb-1 px-2">current</th>
<th className="text-left font-normal pb-1 pl-2">new value</th>
</tr>
</thead>
<tbody>
{dimCols.map(c => {
const cur = multi
? (new Set(slices.map(s => s[c.cname])).size > 1 ? '(varies)' : (first[c.cname] ?? '—'))
: (first[c.cname] ?? '—')
return (
<tr key={c.cname}>
<td className="pr-3 py-0.5 text-gray-500 whitespace-nowrap" title={c.cname}>{c.label || c.cname}</td>
<td className="px-2 py-0.5 font-mono text-gray-600 max-w-[10rem] truncate" title={String(cur)}>{cur}</td>
<td className="pl-2 py-0.5">
<input
value={setObj[c.cname] || ''}
onChange={e => 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} />
</td>
</tr>
)
})}
</tbody>
</table>
{extra}
</div>
)
}
// 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 (
<p className="text-gray-500">
{verb}{' '}
{valueCol && <span className="font-mono tabular-nums text-gray-700">{fmtNum(t.value)}</span>}
{valueCol && <span className="text-gray-600"> {valueCol}</span>}
{unitsCol && <>
<span className="text-gray-500"> · </span>
<span className="font-mono tabular-nums text-gray-700">{fmtNum(t.units)}</span>
<span className="text-gray-600"> {unitsCol}</span>
</>}
{scaled && valueCol && <>
<span className="text-gray-500"> </span>
<span className="font-mono tabular-nums text-gray-700">{fmtNum(t.value * factor)}</span>
</>}
</p>
)
}
// 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 (
<div className="flex flex-col gap-1.5">
<Segmented value={applyMode} onChange={setApplyMode}
options={options.map(([v, l, t]) => [v, l, t])} />
{active && <p className="text-gray-600 leading-snug max-w-xs">{active[2]}</p>}
</div>
)
}
function RequestPreview({ payload }) {
const [open, setOpen] = useState(false)
if (!payload) return null
return (
<div>
<button onClick={() => setOpen(o => !o)} className="text-gray-600 hover:text-gray-600">
{open ? '▾' : '▸'} request
</button>
{open && (
<pre className="mt-1 font-mono text-gray-600 bg-gray-50 border border-gray-100 rounded p-2 overflow-auto max-h-40 leading-relaxed">
{JSON.stringify(payload, null, 2)}
</pre>
)}
</div>
)
}
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 (
<div className={horizontal ? 'flex flex-row items-start p-3 gap-5 min-w-0' : 'flex flex-col p-3 gap-3 min-w-0'}>
<Block title="Slice" hint={hasSlice ? `${slices.length} selected` : null}
horizontal={horizontal} grow>
{/* 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 && (
<p className="text-amber-700 bg-amber-50 border border-amber-200 rounded px-2 py-1 mb-2 leading-snug">
{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.
</p>
)}
<SelectionList
slices={slices}
currentTotals={currentTotals}
onRemove={(i) => setSlices(prev => prev.filter((_, x) => x !== i))}
onClear={() => setSlices([])}
/>
</Block>
{hasSlice && (
<Block horizontal={horizontal} grow>
<div className="flex flex-col gap-2.5">
<div className="flex items-center gap-3 flex-wrap">
<div className="inline-flex rounded border border-gray-200 overflow-hidden">
{['scale', 'recode', 'clone'].map(op => (
<Button key={op} onClick={() => setActiveOp(op)} active={activeOp === op}>
<span className="capitalize">{op}</span>
</Button>
))}
</div>
{multi && (
<ApplyModeChooser op={activeOp} applyMode={applyMode} setApplyMode={setApplyMode} count={slices.length} />
)}
</div>
{activeOp === 'scale' && (
<ScaleLedger
currentTotals={currentTotals}
scaleInputs={scaleInputs} setScaleInputs={setScaleInputs}
targetBasis={targetBasis} setTargetBasis={setTargetBasis}
logMeta={logMeta}
multi={multi} applyMode={applyMode}
/>
)}
{activeOp === 'recode' && (
<DimForm dimCols={dimCols} setObj={recodeSet} setSet={setRecodeSet}
slices={slices} lookupDerivedCols={lookupDerivedCols}
extra={<MovingTotal currentTotals={currentTotals} verb="Moving" />} />
)}
{activeOp === 'clone' && (
<DimForm dimCols={dimCols} setObj={cloneSet} setSet={setCloneSet}
slices={slices} lookupDerivedCols={lookupDerivedCols}
extra={
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<span className="text-gray-500">scale cloned rows by</span>
<input type="number" step="any" value={cloneScale}
onChange={e => setCloneScale(e.target.value)} className={INPUT} />
</div>
<MovingTotal currentTotals={currentTotals} verb="Copying"
factor={parseFloat(cloneScale) || 1} />
</div>
} />
)}
</div>
</Block>
)}
{hasSlice && (
<Block horizontal={horizontal}>
<div className="flex flex-col gap-2.5 min-w-0">
{/* 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. */}
<div className="flex items-center gap-2">
<span className="text-gray-600 whitespace-nowrap w-9">tag</span>
<input
value={opTag} onChange={e => setOpTag(e.target.value)}
list="pf-tag-options" placeholder="initiative, e.g. reduce_spend"
className={`${TEXT} w-48`} />
{opTag.trim() && (
<button onClick={() => setOpTag('')} title="Clear tag"
className="text-gray-500 hover:text-red-500 leading-none px-1">×</button>
)}
</div>
{knownTags.length > 0 && (
<div className="flex items-center gap-1 flex-wrap">
{knownTags.slice(0, 6).map(t => (
<button key={t.tag} onClick={() => setOpTag(t.tag)}
title={`${t.uses} previous use${t.uses === 1 ? '' : 's'}`}
className={`px-2 py-0.5 rounded-full border text-xs whitespace-nowrap ${
opTag.trim() === t.tag
? 'bg-blue-600 border-blue-600 text-white'
: 'bg-white border-gray-300 text-gray-700 hover:border-blue-400 hover:text-blue-700'}`}>
{t.tag}
</button>
))}
</div>
)}
<div className="flex items-center gap-2">
<span className="text-gray-600 whitespace-nowrap w-9">note</span>
<input value={note} onChange={e => setNote(e.target.value)} placeholder="optional" className={TEXT} />
</div>
<Submit onClick={() => submitOp(activeOp)}>{OP_LABEL[activeOp]}</Submit>
<RequestPreview payload={buildPayload(activeOp)} />
</div>
</Block>
)}
</div>
)
}

View File

@ -1,3 +1,4 @@
import { useState, useEffect, useCallback } from 'react'
import useTheme from '../theme.jsx' import useTheme from '../theme.jsx'
export default function StatusBar({ view, sources = [], sourceId, setSourceId, versions = [], versionId, setVersionId }) { 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 showVersion = view === 'baseline' || view === 'forecast'
const selectedVersion = versions.find(v => String(v.id) === String(versionId)) 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 ( return (
<div className="bg-white border-b border-gray-200 px-3 h-9 flex items-center gap-3 shrink-0 text-xs"> <div className="bg-white border-b border-gray-200 px-3 h-9 flex items-center gap-3 shrink-0 text-xs relative">
<span className="text-gray-400">Source</span> <span className="text-gray-400">Source</span>
<select <select
value={sourceId || ''} value={sourceId || ''}
@ -38,6 +71,57 @@ export default function StatusBar({ view, sources = [], sourceId, setSourceId, v
{selectedVersion.status} {selectedVersion.status}
</span> </span>
)} )}
{/* write target — the physical table every operation appends to */}
{info && (
<>
<span className="text-gray-200">|</span>
<span className="text-gray-400" title="Operations append to this table">writes to</span>
<button
onClick={copyTable}
onMouseEnter={() => setShow(true)}
onMouseLeave={() => setShow(false)}
className={`font-mono px-1.5 py-0.5 rounded border hover:bg-gray-50 ${
info.exists ? 'text-gray-700 border-gray-200' : 'text-amber-700 border-amber-200 bg-amber-50'
}`}
title={info.exists ? 'Click to copy table name' : 'Table does not exist yet'}
>
{copied ? 'copied!' : info.fc_table}
</button>
<span className="text-gray-400 font-mono">
{info.exists ? `${fmt(info.rows)} rows` : 'not created'}
</span>
{showInfo && (
<div className="absolute top-9 left-0 z-30 bg-white border border-gray-200 rounded shadow-lg p-3 text-xs min-w-[260px]">
<div className="text-gray-400 uppercase tracking-wide mb-2" style={{ fontSize: '10px' }}>Write target</div>
<div className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
<span className="text-gray-400">table</span>
<span className="font-mono text-gray-700">{info.fc_table}</span>
<span className="text-gray-400">reads from</span>
<span className="font-mono text-gray-700">{info.source}</span>
<span className="text-gray-400">total rows</span>
<span className="font-mono text-gray-700">{fmt(info.rows)}</span>
</div>
{info.by_iter?.length > 0 && (
<>
<div className="text-gray-400 uppercase tracking-wide mt-3 mb-1" style={{ fontSize: '10px' }}>Rows by iter</div>
<table className="w-full">
<tbody>
{info.by_iter.map(r => (
<tr key={r.pf_iter}>
<td className="text-gray-500 capitalize pr-3">{r.pf_iter}</td>
<td className="text-right font-mono text-gray-700">{fmt(r.n)}</td>
</tr>
))}
</tbody>
</table>
</>
)}
</div>
)}
</>
)}
</> </>
)} )}

File diff suppressed because it is too large Load Diff