An audit against the code found every doc had drifted. pf_spec.md mostly described behaviour the app no longer has and missed auth, territory, layouts, load plans, segment naming and scope entirely; CLAUDE.md had grown to carry the history as well as the rules, and loads into every session. - CLAUDE.md: rules, invariants and single-definition pointers only, with stale claims corrected (project layout, routes, schema, the pf_ slice columns, the price/volume and footing rules, known issues, deferred). Each section that lost its reasoning links to the spec. - docs/pf_spec.md: rewritten as the current design doc under twelve fixed headings, holding the history and rationale moved out of CLAUDE.md, the code issues found on the way, and a proposed (not built) design for additional measures -- cost, rebates, surcharges -- as rates on a driver. - docs/PERSPECTIVE.md: cut to the cross-app lessons that still hold; the fork and its rebuild are in ui/vendor/README.md. - docs/pf_perspective_options.md: marked as a dated decision record. - ui/vendor/README.md: depth is sent floored at 1, not d + 1; rebuild steps. - ui/README.md: removed (Vite template boilerplate). - Two stale code comments (server.js, Forecast.jsx). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1590 lines
82 KiB
Markdown
1590 lines
82 KiB
Markdown
# Pivot Forecast — Design
|
||
|
||
What the app does, how the pieces fit, and why — including what each design
|
||
replaced, where that history still explains the current shape. `CLAUDE.md`
|
||
holds the rules an agent needs while editing; this holds the reasoning. Where
|
||
either disagrees with the code (`routes/`, `lib/`, `ui/src/`, `setup_sql/`),
|
||
the code is the truth.
|
||
|
||
Related: `pf_perspective_options.md` (transport options, the DuckDB spike, why
|
||
the pre-aggregated path won), `PERSPECTIVE.md` (bundling), `ui/vendor/README.md`
|
||
(the patched Perspective build and how to rebuild it).
|
||
|
||
---
|
||
|
||
## Overview
|
||
|
||
A web app for building named forecast scenarios against a PostgreSQL table:
|
||
|
||
1. **Load actuals as a baseline** — segments pulled from the source,
|
||
optionally date-shifted into the forecast period, so the starting point is
|
||
"no change from last year".
|
||
2. **Load reference rows** — prior year, plan, open orders — for comparison.
|
||
Operations do not adjust them.
|
||
3. **Adjust** — select pivot cells and scale, recode or clone them.
|
||
|
||
Every write is **append-only** and produces exactly one `pf.log` entry. An
|
||
adjustment inserts new rows carrying its entry's id; undo deletes those rows
|
||
and the entry. So a version is always the sum of its log, every figure is
|
||
attributable to the entry that wrote it, and any entry can be reversed alone.
|
||
|
||
Postgres holds the raw rows and is the single source of truth. The browser
|
||
holds a copy in [Perspective](https://github.com/perspective-dev/perspective)
|
||
(WASM), pre-aggregated to the display grain, and updates it incrementally from
|
||
each operation's response rather than reloading.
|
||
|
||
### Lineage
|
||
|
||
A port of an Excel model: a data tab of pre-aggregated rows, a pivot cache over
|
||
it, a double-click on a cell opening a VBA form (`/opt/forecast_api/VBA/fpvt.frm`)
|
||
that appended incremental rows, and a cache refresh. The append-and-let-the-pivot-sum
|
||
model follows that directly, and the price/volume rules in §Operations come from
|
||
the form's `calc_val` / `calc_price`.
|
||
|
||
### Stack
|
||
|
||
Node/Express (`server.js`, one `pg` pool, type parsers coercing `bigint` and
|
||
`numeric` to numbers so Arrow does not infer them as dictionary text);
|
||
PostgreSQL, everything in schema `pf`; React + Vite + Tailwind in `ui/`, built to
|
||
`public/app/`; Perspective 5.4.0 from a vendored patched fork (§The pivot).
|
||
|
||
### Pages and navigation
|
||
|
||
Three steps in a collapsible sidebar: **Setup** (register sources, assign column
|
||
roles, Generate SQL), **Baseline** (versions, load segments, version defaults,
|
||
tags), **Forecast** (the pivot and the operation panel). Setup and Baseline are
|
||
admin-only to write (§Access).
|
||
|
||
**The forecasts are listed in the sidebar**, above the steps, each with its
|
||
status and a star for the default. They used to be two dropdowns in the status
|
||
bar, which is otherwise read-only, so the one pair of controls in it did not read
|
||
as controls. The list is *not* nested under the Forecast step, where the idea
|
||
started: Baseline works on the same version, so hanging it off one step would
|
||
misrepresent its scope or duplicate it. The sidebar reads *what am I working on*,
|
||
then *what step am I on*.
|
||
|
||
**The source is not in the nav.** A raw table name means nothing to someone
|
||
editing a forecast. Picking a forecast sets the source, since
|
||
`pf.version.source_id` comes with it; the source picker appears only on Setup.
|
||
`versions` (the selected source's, which Baseline and Forecast need) and
|
||
`allVersions` (the sidebar's, a client-side fan-out over `/sources/:id/versions`)
|
||
are kept apart so widening one does not change the other.
|
||
|
||
The status bar keeps what follows from the selection: the forecast's name and row
|
||
count, with the physical table in a click-to-copy hover panel — pasting it into
|
||
psql is why it was ever on screen.
|
||
|
||
**Known collision:** "forecast" names both the scenario and the third step. The
|
||
scenario should own the word, leaving the step wanting another (`Adjust`).
|
||
Undecided.
|
||
|
||
### Where a browser lands
|
||
|
||
`App.jsx` restores the last source and version from `localStorage`, re-validated
|
||
against the live list so a deleted version re-points rather than 404ing every
|
||
call. With nothing stored it falls back to **`pf.version.is_default`** (one per
|
||
source, partial unique index). It used to fall back to `versions[0]` — newest
|
||
first, so whatever somebody created last, a scratch copy as readily as the plan —
|
||
and accounts that never pick a version, which is every sales account, followed
|
||
the forecasting work around.
|
||
|
||
Setting the default is **admin only**, since it decides where everyone else
|
||
lands; the Baseline checkbox is greyed for others. It is two statements (clear
|
||
old, set new) in one transaction, because the index refuses the instant between.
|
||
**A stored selection still wins** — the default is a fallback, so an account that
|
||
landed in the wrong scenario keeps landing there until it switches once.
|
||
|
||
### Renaming a version
|
||
|
||
Safe at any time, including closed. Nothing derives from the name: the table is
|
||
`fc_{tname}_{version_id}`, `pf.log` and `pf.layout` key on the id, and client
|
||
storage keys are id-based. The Baseline page's **This forecast** panel saves name
|
||
and description on blur via `PUT /versions/:id`; a name collision answers 409,
|
||
and a blank name is refused client-side because the route COALESCEs it away.
|
||
|
||
### API surface
|
||
|
||
All under `/api`; all but the auth routes need a session.
|
||
|
||
| Area | Routes |
|
||
|---|---|
|
||
| Auth | `POST /login`, `POST /logout`, `GET /me` |
|
||
| Browser | `GET /tables`, `GET /tables/:schema/:tname/preview` |
|
||
| Sources | `GET/POST /sources`, `GET/PUT /sources/:id/cols`, `POST /sources/:id/generate-sql`, `GET /sources/:id/sql`, `GET /sources/:id/values/:col`, `GET /sources/:id/dim/:group`, `POST /sources/:id/dim/:group/refresh`, `GET /sources/:id/lookup`, `GET /dim-period/cols`, `DELETE /sources/:id` |
|
||
| Versions | `GET/POST /sources/:id/versions`, `PUT/DELETE /versions/:id`, `POST /versions/:id/{close,reopen}`, `GET /versions/:id/table-info`, `GET /versions/:id/values/:col`, `GET/PATCH /versions/:id/tags`, `GET /versions/:id/bridge` |
|
||
| Data | `GET /versions/:id/data`, `GET /versions/:id/agg` |
|
||
| Loads | `POST /versions/:id/{baseline,reference}`, `PUT /versions/:id/baseline/:logid`, `DELETE /versions/:id/baseline` |
|
||
| Plans | `GET/POST /versions/:id/plans`, `PATCH/DELETE /plans/:pid` |
|
||
| Operations | `POST /versions/:id/{scale,recode,clone}` |
|
||
| Log | `GET /versions/:id/log`, `GET /log/:logid/debug`, `PATCH/DELETE /log/:logid` |
|
||
| Layouts | `GET/POST /versions/:id/layouts`, `PATCH/DELETE /layouts/:lid` |
|
||
|
||
`DELETE /sources/:id` answers 409 while the source has versions
|
||
(`pf.version.source_id` is `ON DELETE RESTRICT`) and never drops forecast tables.
|
||
|
||
---
|
||
|
||
## Data model
|
||
|
||
Install scripts in `setup_sql/`: `01_schema.sql` (metadata tables, with
|
||
`ADD COLUMN IF NOT EXISTS` backfills and one-off data migrations inline),
|
||
`02_auth.sql` (`pf.app_user`, `pf.session`), `gen_dim_period.sql` (creates and
|
||
fills `pf.dim_period`; fiscal start month set at the top; re-runnable).
|
||
|
||
`01_schema.sql` is idempotent but **not a migration runner**. Schema changes are
|
||
applied to the live database directly and mirrored into `setup_sql/` for fresh
|
||
installs.
|
||
|
||
### `pf.source`
|
||
|
||
`schema`, `tname`, `label`, `status`, `created_by`. `default_layout` is dead —
|
||
migrated into `pf.layout`, read by nothing. Registration seeds one `col_meta` row
|
||
per column as `dimension`.
|
||
|
||
**Columns come from `pg_catalog`, not `information_schema`**, which omits
|
||
materialized views — they are not in the SQL standard — and the app's primary
|
||
source is one. It looked columnless: registration seeded nothing and version
|
||
creation failed "No usable columns in col_meta" while col_meta plainly held them.
|
||
`RELATION_COLUMNS_SQL` in `lib/utils.js` returns the shape `information_schema`
|
||
did (`format_type` minus modifier; precision and scale unpacked from
|
||
`atttypmod`), so `mapType` and callers did not change. The table browser lists
|
||
from `pg_class` by `relkind` for the same reason.
|
||
|
||
### `pf.col_meta`
|
||
|
||
| Column | Meaning |
|
||
|---|---|
|
||
| `role` | `dimension` \| `value` \| `units` \| `date` \| `filter` \| `ignore` |
|
||
| `label`, `opos` | display name; ordinal, which orders everything generated |
|
||
| `is_key` | four separate uses, below |
|
||
| `dim_group` | groups functionally dependent columns — a part and its attributes, a date and its periods |
|
||
| `dim_period_col` | the `pf.dim_period` column this dimension is derived from at load |
|
||
| `in_grain` | part of the display grain (§Display-grain pre-aggregation) |
|
||
| `is_territory` | the column territory is expressed in; at most one per source, enforced on save |
|
||
|
||
Roles: `dimension` is categorical and sliceable. `value` (required) and `units`
|
||
(optional) are the measures — several may be marked, loads carry all, but the
|
||
operations use only the first of each by `opos` (§Proposed: additional
|
||
measures). `date` (at least one) — every date column is stored, shifted and
|
||
carried by every operation, and sliceable. `filter` feeds the baseline filter
|
||
builder and is not stored. `ignore` is not stored. Slices are validated against
|
||
every `dimension` and `date` column regardless of `is_key`.
|
||
|
||
**`dim_group` + `dim_period_col`.** A `date` column that is `is_key` with a
|
||
`dim_group` anchors the group: its sibling dimensions with a `dim_period_col` are
|
||
looked up in `pf.dim_period` against the *shifted* date at load time instead of
|
||
copied raw, so fiscal periods follow a baseline moved forward a year. Every such
|
||
group is honoured; this used to be a `.find()`, so a source with order, requested
|
||
and ship dates derived one set and copied the other two raw — shifted dates under
|
||
unshifted labels.
|
||
|
||
**`is_key`** is read in four places, none of them what the name suggests:
|
||
|
||
1. **The key of a `dim_group`** — `resolveGroup()` in `routes/sources.js` takes
|
||
`members.find(c => c.is_key)` as the column `pf.dim_member` is keyed on
|
||
2. **Value completion** — `/sources/:id/values/:col` serves only `is_key`
|
||
columns (the operation panel now completes from `/versions/:id/values/:col`
|
||
instead, which takes any dimension or date)
|
||
3. **Sibling autofill** — `/sources/:id/lookup`, for `is_key && dim_group`
|
||
4. **The `dim_period` anchor** — `role = 'date' && is_key && dim_group`
|
||
|
||
Uses 2 and 3 want several flagged; use 1 needs exactly one per group, and with
|
||
more `.find()` silently takes the lowest `opos`. That has happened: a column null
|
||
throughout outranked its group's real key, so a member refresh matched nothing
|
||
and reported success with zero members. The meanings want separating — a
|
||
per-group key choice, or the rule that the key is the column the group is named
|
||
after — and until then a refresh finding two keys should refuse rather than guess.
|
||
|
||
### `pf.dim_period`
|
||
|
||
One row per month keyed on `sdat`, a GiST-indexed `drange`, and calendar and
|
||
fiscal year/quarter/month columns in several spellings. Joined as
|
||
`LEFT JOIN pf.dim_period dpN ON dpN.drange @> (date + offset)::date` — left,
|
||
because an unshipped order has no ship date and must keep its row.
|
||
|
||
### `pf.dim_member`
|
||
|
||
Master data per `dim_group`: one row per key value, siblings in `attrs`, with
|
||
`is_active` and `source_seen`. The source is transactional, often a view over all
|
||
history, so deriving members from it is slow (a full scan) and wrong (it describes
|
||
only what has been transacted — it cannot say a part is discontinued, or that a
|
||
new one exists before it sells). This is the app's own list, refreshed from the
|
||
source by an admin action but curatable apart from it. A refresh **merges**:
|
||
members gone from the source become `source_seen = false`, not deleted, so
|
||
curation survives; a key with several attribute sets across history takes the
|
||
most recent. It stamps an explicit run time rather than `now()`, which inside a
|
||
transaction is the transaction start.
|
||
|
||
### `pf.version`
|
||
|
||
| Column | Meaning |
|
||
|---|---|
|
||
| `name`, `description` | `(source_id, name)` unique |
|
||
| `status`, `closed_at`, `closed_by` | a closed version refuses every write but annotation |
|
||
| `exclude_iters` | jsonb, default `["reference"]` — `pf_iter` values scale and recode will not write |
|
||
| `is_default` | where a browser with no saved selection lands |
|
||
| `adjustment_segment`, `adjustment_bucket` | names a new adjustment is stamped with (§Naming) |
|
||
| `unlabeled_load`, `bucket_order` | dead — read by nothing |
|
||
|
||
Creating a version inserts the row and creates its forecast table and `pf_logid`
|
||
index in one transaction. Deleting drops the table; `pf.log`, `pf.layout` and
|
||
`pf.load_plan` cascade.
|
||
|
||
### `pf.fc_{tname}_{version_id}`
|
||
|
||
One table per version holding operational rows (`pf_iter` = `baseline`, `scale`,
|
||
`recode`, `clone`) and reference rows (`reference`):
|
||
|
||
```sql
|
||
CREATE TABLE pf.fc_{tname}_{id} (
|
||
pf_id bigserial PRIMARY KEY,
|
||
-- every non-ignored col_meta column, typed from the source by mapType
|
||
pf_iter text NOT NULL,
|
||
pf_logid bigint NOT NULL,
|
||
pf_user text,
|
||
pf_created_at timestamptz NOT NULL DEFAULT now()
|
||
);
|
||
CREATE INDEX fc_{tname}_{id}_logid_idx ON pf.fc_{tname}_{id} (pf_logid);
|
||
```
|
||
|
||
No `version_id` column: the table is the version. **`pf_logid` is not a foreign
|
||
key** — undo deletes rows then log row in one transaction, and those are the only
|
||
two paths that remove either. **The index** is how undo, the change-log recount
|
||
and the grain key find an entry's rows; without it each is a sequential scan.
|
||
Tables created before it was added lack it.
|
||
|
||
### `pf.log`
|
||
|
||
| Column | Meaning |
|
||
|---|---|
|
||
| `version_id` | `ON DELETE CASCADE` |
|
||
| `pf_user`, `stamp` | the session account; when |
|
||
| `operation` | `baseline` \| `reference` \| `scale` \| `recode` \| `clone` |
|
||
| `slice`, `params` | the slice(s) written to (null on loads); intent plus what the server resolved |
|
||
| `label`, `bucket` | segment name and what it counts toward; required by the `log_named` CHECK |
|
||
| `tag`, `note` | initiative name, which the bridge groups on; free commentary |
|
||
| `row_count`, `value_total`, `units_total`, `measure_cols` | what the entry wrote, stamped at write time |
|
||
| `env`, `sql_text` | what it ran against; the statement as executed |
|
||
| `seq` | dead |
|
||
|
||
**Who writes what.** Load templates insert `label`, `bucket` and `tag` in their
|
||
`ilog` CTE. Adjustment templates insert `label` and `bucket`; their `tag` goes on
|
||
in a follow-up `UPDATE` in the same transaction (`tagLog()`), a hold-over from
|
||
when a new `{{tag}}` token would have stranded sources that had not re-run
|
||
Generate SQL. Totals, `env` and `sql_text` are written after commit by
|
||
`stampLogTotals()`.
|
||
|
||
**Stamped totals are not a cache.** An entry's rows never change after it is
|
||
written — only its own operation inserts them and only undo removes them, taking
|
||
this row too — so the totals are fixed. The change log used to join the whole
|
||
forecast table on every open to recompute them. `measure_cols` records which
|
||
columns they are in, since roles can be reassigned. Stamping is best-effort: a
|
||
failure does not roll back a write that succeeded, since totals can be recomputed
|
||
and the adjustment cannot. `GET /versions/:id/log?recount=1` recomputes by join
|
||
and writes back — the repair for rows deleted by hand, and the backfill for
|
||
entries older than the columns.
|
||
|
||
**`params` and `sql_text` are both kept on purpose.** One is the intent (slices,
|
||
scope, resolved increments), the other what it became. Keeping only one assumes
|
||
the translation is correct, which is exactly what is in doubt when rows look
|
||
wrong. `env` holds what cannot be reconstructed afterwards — the territory, the
|
||
version's `exclude_iters`, and the template's `generated_at`, a fingerprint (not
|
||
a version) showing whether an entry ran under a different template.
|
||
`GET /log/:logid/debug` returns the whole row; the list omits `sql_text` and
|
||
reports `has_sql`.
|
||
|
||
### `pf.sql`
|
||
|
||
One generated template per `(source_id, operation)`: `get_data`, `get_agg` (only
|
||
with a grain), `baseline`, `reference`, `scale`, `recode`, `clone`, `undo`.
|
||
`generateSQL()` in `lib/sql_generator.js` bakes in column names and leaves
|
||
`{{token}}`s — `fc_table`, `version_id`, `pf_user`, `note`, `tag`, `label`,
|
||
`bucket`, `params`, `slice`, `filter_clause`, `date_offset`, `where_clause`,
|
||
`exclude_clause`, `value_incr`, `units_incr`, `set_clause`, `scale_factor`,
|
||
`territory_clause` — for `applyTokens()` at request time. Generation deletes any
|
||
operation it no longer produces, since a stale `get_agg` after clearing the grain
|
||
would still be picked up. The stored `undo` is documentation; the route runs its
|
||
own statements.
|
||
|
||
Values are escaped (`esc()` doubles quotes), not parameterised — consistent with
|
||
the codebase, and the executed SQL reads as-is in the server log and
|
||
`pf.log.sql_text`.
|
||
|
||
**Editing the generator changes nothing until Generate SQL runs**, since the
|
||
templates are stored; a template carrying a token the running code does not
|
||
substitute fails at the database, reading as an unrelated client error. Restart
|
||
first, then Generate SQL, or the old code writes the templates.
|
||
|
||
### Other tables
|
||
|
||
`pf.layout` (§The pivot), `pf.load_plan` (§Loads and segments), `pf.app_user` and
|
||
`pf.session` (§Access).
|
||
|
||
---
|
||
|
||
## Loads and segments
|
||
|
||
A version's rows start as **segments**: independent queries against the source,
|
||
each appended as one log entry, each undoable alone. Two kinds, differing only in
|
||
`pf_iter`:
|
||
|
||
- **baseline** — the rows operations adjust
|
||
- **reference** — kept out of scale and recode by `exclude_iters`
|
||
|
||
`pf_iter` answers *can operations write these rows*. Whether rows belong in the
|
||
forecast number is the segment's **bucket** (§Naming): open orders load as
|
||
reference so nothing adjusts them, and are still part of the forecast. Neither
|
||
can be derived from the other. All load routes are admin-only.
|
||
|
||
### How a load's WHERE is built
|
||
|
||
The Baseline page's filter builder takes **groups** of conditions — AND within,
|
||
OR between — over `date` and `filter` columns (`=`, `!=`, `IN`, `NOT IN`,
|
||
`BETWEEN`, `IS NULL`, `IS NOT NULL`). A period is a filter like any other. A
|
||
toggle swaps the builder for a raw WHERE body.
|
||
|
||
**The client builds the clause; the route injects it.** `buildFilterClause()` in
|
||
`Baseline.jsx` renders `(a AND b) OR (c)` and sends it as `where_clause` beside
|
||
the structured `filters` (or `raw_where`). The route substitutes
|
||
`raw_where || where_clause` as `{{filter_clause}}` verbatim and keeps `filters` /
|
||
`raw_where` in `params` only so a segment can be re-opened and a plan derived.
|
||
Nothing server-side validates columns or escapes values, and the client does not
|
||
escape them either; `lib/sql_generator.js`'s own `buildFilterClause()`, which
|
||
does, is called by no route. The protection is `requireAdmin`: whoever can reach
|
||
the route can already send raw SQL, so validating the structured path would not
|
||
narrow what they can do. An empty clause becomes `TRUE` — the whole source — and
|
||
the client refuses to send one.
|
||
|
||
### Date offsets
|
||
|
||
`date_offset` is a Postgres interval (`1 year`, `-90 days`) applied to **every
|
||
date column**, so order and ship dates stay in step, with period dimensions
|
||
looked up against the shifted dates. Filter columns are never shifted: the offset
|
||
moves where rows land, not which are chosen.
|
||
|
||
It must be **whole days** (`assertInterval()`): shifted dates are cast back to
|
||
dates, so anything finer is rounded away, and Postgres reads a bare number as
|
||
seconds — `"30"` used to be accepted and move nothing. `30 days` is not a month
|
||
either; use `1 month`. Checked on baseline loads, segment edits, recode and clone
|
||
— **not** on reference loads, where a bad offset surfaces as a Postgres error.
|
||
|
||
The offset is typed as an interval string. It replaced year/month spinners,
|
||
which could not express days and clamped at zero, so a segment could only move
|
||
forward in whole months. The timeline preview parses it only far enough to draw
|
||
the source and shifted periods; Postgres is the authority.
|
||
|
||
### Naming, editing, clearing
|
||
|
||
A load's **label is required** (400 without) — the segment name the pivot shows
|
||
and reports group on — and its bucket defaults to it (`loadNames()`).
|
||
|
||
`PUT /versions/:id/baseline/:logid` edits a segment by deleting its rows and log
|
||
row and re-running it in one transaction, under a **new** logid. Refused (409)
|
||
once any adjustment exists, since those were sized against the old totals.
|
||
Annotations carry over with `??`, not `||`: the form has no tag input, so an
|
||
absent tag must survive, while an explicit `''` clears.
|
||
|
||
`DELETE /versions/:id/baseline` removes every `baseline` row and entry (not
|
||
reference). Not undoable.
|
||
|
||
### Load plans — `pf.load_plan`
|
||
|
||
A segment used to exist only once run, so cloning a definition meant re-running
|
||
every load, and there was nowhere to hold "this cut, but with the dates changed
|
||
first". A plan is the definition alone — `kind`, `label`, `bucket`, `tag`, `note`,
|
||
`filters`, `raw_where`, `date_offset`, `seq` — with `run_logid` naming the entry
|
||
it produced, or null.
|
||
|
||
- **Deliberately not `pf.log`**: every row there means a write happened, and
|
||
unexecuted intent would cost it that meaning.
|
||
- **`run_logid` is `ON DELETE SET NULL`**, so undoing a load returns its plan to
|
||
unrun instead of deleting the definition. The list reads the entry's
|
||
`row_count` through the FK to tell run from unrun.
|
||
- **Running is two round trips** — the ordinary load POST with the plan's fields,
|
||
then `PATCH /plans/:pid` with the `log_id`. The alternative was a second copy of
|
||
the load route's substitution; a plan that reads unrun while its rows exist is
|
||
visible and fixable, a divergent copy of the load SQL is neither.
|
||
- **Run all stops at the first failure.** A definition's segments are not
|
||
independent — references date-shift off a baseline — so carrying on past a
|
||
broken one builds something nobody described.
|
||
|
||
Known issue: `runPlan()` does not send the plan's `tag`, so a tagged plan loads
|
||
untagged.
|
||
|
||
### Cloning a version's definition
|
||
|
||
`POST /sources/:id/versions` with `clone_from` copies the template's settings
|
||
(description unless given, `exclude_iters`, adjustment names), its
|
||
**version-scoped** layouts, and its segments as plans — and **runs nothing**. The
|
||
point of cloning a definition is to change the dates before anything loads; a
|
||
clone that fetched the baseline first would have to be undone to be edited.
|
||
|
||
Not copied: **adjustments**, in any form — a scale's target was sized against the
|
||
rows that were there, so replaying it against another load is arithmetic nobody
|
||
asked for; and **source-wide layouts**, which already apply and would duplicate
|
||
by name.
|
||
|
||
`cloneDefinition()` reads the template's plans, or derives them from its
|
||
`baseline`/`reference` entries when it has none — every version built before
|
||
plans existed. `params` already stores `filters`, `raw_where` and `date_offset`,
|
||
so this is a read, not a reconstruction.
|
||
|
||
### Replay
|
||
|
||
Re-running a version's adjustments against a restated baseline is **not built**.
|
||
Nothing accepts a `replay` flag and nothing answers 501, whatever earlier specs
|
||
said. See §Open questions.
|
||
|
||
---
|
||
|
||
## Naming: segments, buckets, notes
|
||
|
||
Four pivot columns come from `pf.log`, joined on `pf_logid`, not from the
|
||
forecast table:
|
||
|
||
| Column | From |
|
||
|---|---|
|
||
| `pf_segment` | `l.label` — which load, or which kind of adjustment |
|
||
| `pf_bucket` | `l.bucket` — what it counts toward: Actual, Plan, Forecast, Prior Year |
|
||
| `pf_note` | adjustments: the tag, else the note; loads: null |
|
||
| `pf_op` | `l.operation` — `/agg` and pushed-back rows only |
|
||
|
||
Separate on purpose: commingling a segment name with an adjustment's note makes
|
||
neither pivotable. In grain mode `pf_logid` is in the grain, so the join adds no
|
||
rows. `SEGMENT_EXPR` / `BUCKET_EXPR` / `NOTE_EXPR` in `lib/sql_generator.js` are
|
||
the single definition, shared by the generated `/agg` and the hand-written
|
||
`/data` cursor, which must agree. The operation routes stamp the same fields on
|
||
the rows they return, since `RETURNING *` lacks them. The `/data` cursor does not
|
||
emit `pf_op`, so a raw-mode pivot cannot group on it.
|
||
|
||
### Every log entry is named
|
||
|
||
`label` and `bucket` are **stored, not derived**, and every entry has both —
|
||
`log_named` refuses one without. A report gets the pivot's segment and bucket by
|
||
joining `pf.log` and selecting two columns, with no rule of its own. Stamped at
|
||
write time:
|
||
|
||
- **A load** — `label` required, `bucket` defaulting to it.
|
||
- **An adjustment** — no name inputs, so the version's `adjustment_segment` /
|
||
`adjustment_bucket`, else `ADJUSTMENT_SEGMENT = '99 - Adjustments'` and
|
||
`ADJUSTMENT_BUCKET = '04 - Forecast'`. `adjustmentNames()` resolves them once
|
||
per request, for the INSERT and the returned rows alike.
|
||
|
||
The version defaults are set on the Baseline page (**Defaults for new
|
||
adjustments**) and name the *next* adjustment; existing entries keep theirs.
|
||
Renaming an entry is `PATCH /log/:logid` — from the Baseline page's segment list —
|
||
which renames but refuses to clear, owner or admin only because the names are the
|
||
pivot's columns for everyone. Relabelling needs a page reload to show, since the
|
||
label is part of the aggregated row. A source registered before names were
|
||
stored needs Generate SQL once so its templates write them.
|
||
|
||
**What this replaced.** The names were derived at read time: a load's `label`,
|
||
else `tag`, else `note`, else the version's `unlabeled_load`, else `Unlabeled`;
|
||
an adjustment's `label`, else the version default, else the constant — through a
|
||
join on `pf.version`. That made the pivot the only reader that knew a row's
|
||
bucket: the CASE was copied into `/agg`, the `/data` cursor and a reporting
|
||
procedure in `/opt/forecast`, and every direct query had to copy it again. And
|
||
editing a version's fallback silently renamed every past adjustment. The backfill
|
||
in `01_schema.sql` stamped existing entries with that rule, so nothing on screen
|
||
changed name.
|
||
|
||
### Column order is stored text
|
||
|
||
Perspective orders column groups by the value string, and `asc`/`desc` only
|
||
reverses that, so Prior Year → Plan → Actual → Forecast is expressible in neither
|
||
direction. A `"01 - "` prefix is the only lever, and it lives in the stored label
|
||
and bucket, typed by whoever names the segment. Nothing derives it.
|
||
|
||
The one hardcoded ordinal is `99 - Adjustments`, keeping unnamed adjustments
|
||
after every *numbered* segment. That proviso is the scheme: ordering is string
|
||
ordering, so `99` lands last only while loads carry `01`–`0n`, and an unnumbered
|
||
segment sorts after it (digits precede letters). The old `'(adjustment)'` sorted
|
||
*first* for the same reason read the other way. Relabelling an adjustment's log
|
||
row is how one kind of adjustment is split out from the rest.
|
||
|
||
**What this replaced.** The prefix was computed client-side as Perspective
|
||
expression columns (`pf_bucket_ord`, `pf_segment_ord`) from `pf.log.seq` and
|
||
`pf.version.bucket_order`. It ordered the pivot and nothing else, so every other
|
||
reader disagreed; `restore()` replaces `expressions` wholesale, so it had to be
|
||
re-applied after every layout load; and ExprTK's scanner tests each *byte* with
|
||
`isprint()`, so a label with anything outside printable ASCII could not be
|
||
ordered at all. `DEAD_ORDER_EXPRS` in `Forecast.jsx` strips those names from old
|
||
layouts.
|
||
|
||
### Tags
|
||
|
||
A tag names an **initiative** — a line in the ledger and the bridge. It never
|
||
affects rows, so it stays editable, even on a closed version. It is not a row
|
||
anywhere, only a value repeated across a version's entries; it exists because
|
||
somebody typed it.
|
||
|
||
**Completions are per version.** `GET /versions/:id/tags` returns this version's
|
||
tags with `uses`, `last_used`, `owners`, `can_edit`, refetched on version change
|
||
and after every tagged write. It used to be `GET /sources/:id/tags`, across every
|
||
version of the source — which put last year's initiatives in front of this
|
||
year's and reported use counts earned elsewhere. The cost is that a fresh version
|
||
completes from nothing until it has a tag, which is honest: cloning a definition
|
||
copies no adjustments.
|
||
|
||
**Managed on the Baseline page.** `PATCH /versions/:id/tags` `{ from, to }`
|
||
rewrites every entry carrying `from`. A `to` that already exists **merges** —
|
||
usually the point, since "Pull Forward" and "Pulled Forward" are one initiative
|
||
reported as two. An empty `to` clears. Both are confirmed first, being invisible
|
||
as a diff afterwards. Permission is ownership of *every* entry the tag is on — a
|
||
half-applied rename leaves the initiative split, the state being fixed — so rows
|
||
the reader cannot rename are greyed; the route locks the rows before checking, so
|
||
check and rewrite see the same set. The Tags panel sits outside the page's
|
||
read-only fieldset: a rep may rename what they named.
|
||
|
||
**Completion matches on words.** `TagInput` in `OperationPanel.jsx` lowercases,
|
||
strips punctuation and stopwords and lightly stems both sides, so "Pulled
|
||
Forward" finds "Pull Forward" and one inserted word does not hide an existing
|
||
tag. Something that reduces to an existing tag without being it says so
|
||
(*"Already used as … — use that"*). Nothing merges automatically. The change
|
||
log's inline tag cell still uses a plain datalist.
|
||
|
||
The panel used to carry six quick-pick pills under the tag field; they wrapped to
|
||
two rows once tags were longer than a word and pushed the docked form past its
|
||
border. The list shows the tag alone — an option's text renders as a second
|
||
column, so a use count widened every row for a number nobody picks on.
|
||
|
||
---
|
||
|
||
## Selection
|
||
|
||
A selection is a list of **slices** (`{column: value}`) plus the pivot's own
|
||
filter as **scope**. The server turns them into a WHERE clause; the ledger totals
|
||
the loaded table through the same filters, so the panel and the write agree.
|
||
|
||
### From a click to a slice
|
||
|
||
`perspective-click`'s own filter list cannot be trusted: the datagrid's
|
||
`getCellConfig` builds it with `pivot_value ? [pivot, '==', pivot_value] :
|
||
undefined`, so **the engine drops a blank group key** — and, being a falsy test,
|
||
a `0`. A dropped key widens the write: clicking the blank node under a customer
|
||
selected the whole customer, and the ledger agreed, totalling through the same
|
||
list. So `cellFilters()` in `Forecast.jsx` re-derives both axes:
|
||
|
||
- **Rows** from `detail.row.__ROW_PATH__` — raw values, so dates stay epoch
|
||
millis. A subtotal's shorter path leaves deeper levels absent.
|
||
- **Columns** from the clicked column's name (taken from the mousedown's cell;
|
||
the event names only the measure). The name is the visible split values joined
|
||
with `|`, measure last, so the measure is popped before mapping `split_by`
|
||
positionally. Mapping every segment used to read the measure as a value of the
|
||
first collapsed dimension — a slice matching no row.
|
||
|
||
A plain click replaces the selection; ctrl/⌘/shift toggles a slice. The event
|
||
carries no modifier flags, so they are read off the preceding mousedown.
|
||
`buildEffectiveSlice()` keeps dimension, date and `pf_` keys, turns dates into
|
||
ISO strings, and drops the rest; duplicates are collapsed, since under `each` a
|
||
repeat would apply twice. A Perspective computed column does not map back to
|
||
stored rows, so only native columns slice.
|
||
|
||
### Dragged regions
|
||
|
||
The datagrid runs in `edit_mode: SELECT_REGION`, forced on every restore so a
|
||
layout cannot switch it off. A drag fires `perspective-select` with a
|
||
**ViewWindow** — `{ start_row, end_row, start_col, end_col }` — on every
|
||
mouseover, so the handler keeps the latest and a window `mouseup` commits it,
|
||
each cell going through `cellFilters()`. A single-cell region is ignored:
|
||
`perspective-click` owns clicks, and handling both would undo a ctrl-click.
|
||
|
||
### Blanks: null and `''` are different values
|
||
|
||
Both occur in real data as different rows, so they stay distinct from click to
|
||
WHERE; only `undefined` means absent.
|
||
|
||
- `buildWhere()` renders null as `IS NULL`. It used to fall through `esc()` to
|
||
`= ''`, selecting *the other blank* — the slice named one node and the
|
||
operation wrote the other. In a list the null arm is ORed on, since `IN (NULL)`
|
||
is never true.
|
||
- The ledger filters null with `is null`, not `== null`: the engine matches on
|
||
type, and a null threshold on a string column is another dropped predicate.
|
||
- On the column axis, `t_tscalar::to_string` renders `''` as `''` and a null as
|
||
the text `"null"`, so `"null"` is read back as null. This was believed
|
||
impossible — a null was thought to join as `''` too — so `''` cells were
|
||
refused and `"null"` was taken literally, a slice matching nothing. The one
|
||
ambiguity left is a value that really is the text `null`.
|
||
- The panel prints `(null)` / `(blank)`, and `—` only for a column not in the
|
||
slice: isolating the blank node and ignoring the column are opposite
|
||
statements and used to look identical.
|
||
|
||
### The pivot's `pf_` columns are part of the slice
|
||
|
||
- **Table columns** — `pf_iter`, `pf_user`, `pf_logid` (`TABLE_SLICE_COLS`) —
|
||
compared directly.
|
||
- **Log columns** — `pf_segment`, `pf_bucket`, `pf_note`, `pf_op`
|
||
(`COMPUTED_SLICE_COLS`) — resolved to `pf_logid IN (SELECT id FROM pf.log WHERE
|
||
version_id = … AND <expr> …)`, exact because the name lives on the log row.
|
||
- **Any other `pf_` key is refused** — skipping is how selections widened.
|
||
|
||
They **narrow a slice but do not make it selective** (`pf_iter = scale` alone is
|
||
every adjustment): `assertSelective()` needs a dimension, date, `pf_segment` or
|
||
`pf_bucket` key.
|
||
|
||
**What this replaced.** `pf_iter` was stripped on the theory that cells differing
|
||
only by iter band are one slice; `pf_note` and `pf_op` were refused, and the
|
||
client pre-empted that by stripping; `pf_segment` and `pf_bucket` were dropped
|
||
before that. The ledger stripped none of them. So under the default layout,
|
||
grouped by `pf_iter`, a click on a customer's `scale` row totalled the scale rows
|
||
and wrote to all the customer's rows; a click on one bucket's cell scaled every
|
||
bucket there.
|
||
|
||
### Several slices, and total cells
|
||
|
||
Several slices become an OR of AND-groups, not per-column `IN` lists —
|
||
`{A:1,B:1}` + `{A:2,B:2}` as `A IN (1,2) AND B IN (1,2)` would also match
|
||
`A:1,B:2` — parenthesised, since callers append `AND pf_iter NOT IN (…)`.
|
||
|
||
**A total cell is its displayed amount.** The grand-total row names no row
|
||
dimension, so its slice is whatever the column axis and the pivot filter leave —
|
||
possibly `{}`, *everything the filter shows*. `{}` is accepted only with a
|
||
non-empty scope (`normalizeSlices()`, `assertSelective()`); alone it would be the
|
||
whole version and is refused on both sides. `buildWhereAny()` reads a union
|
||
containing `{}` as `TRUE`, the scope then ANDed on — dropping it would narrow the
|
||
write to the other cells. The panel names it "Total".
|
||
|
||
### Scope: the pivot's filter travels with the slices
|
||
|
||
A slice can only mean equality, so a bounded season, an in-list or a substring
|
||
has no slice form. The filter is read off `viewer.save()` — in the table's own
|
||
types, with the user's operators — and sent as `scope`, `[col, op, value]`
|
||
triples; the ledger totals against the same list. Without it the ledger counted
|
||
rows the grid was hiding and the operation wrote them. It is ANDed onto each
|
||
statement rather than folded into the slices, which under `each` would repeat it
|
||
per statement.
|
||
|
||
`SCOPE_OPS` in `lib/sql_generator.js` is the whole allowed list, under
|
||
**Perspective's** names: `==`, `!=`, `>`, `>=`, `<`, `<=`, `in`, `not in`,
|
||
`is null`, `is not null`, and the substring family. Anything else is refused —
|
||
a scope silently dropped is a write wider than the panel that authorised it.
|
||
`pf_` log columns take `==` or `in` only.
|
||
|
||
- **Substring operators map to `ILIKE`**, because the engine's
|
||
`t_tscalar::contains` / `begins_with` / `ends_with` lowercase both sides. `LIKE`
|
||
would write a fraction of what the grid showed, and narrower is no safer than
|
||
wider — neither is the set the ledger counted. The engine matches the term
|
||
literally, so `%`, `_` and `\` are escaped. Residual difference: the engine
|
||
folds ASCII only, `ILIKE` follows the collation.
|
||
- **`matches` / `not matches` stay refused.** RE2 in the engine, not in Postgres —
|
||
`\b` is a word boundary in one and a backspace in the other — so a pattern
|
||
valid in both could match different rows, which is worse than a 400.
|
||
- **An empty term is refused.** An unfinished filter row arrives as
|
||
`[col, '==', null]`, and `ILIKE '%%'` or `= ''` would read as every row or none
|
||
rather than as the mistake it is.
|
||
|
||
The ledger used to drop a slice's column whenever the scope named it, assuming
|
||
they could not disagree; under a substring filter, clicking one part then totalled
|
||
every matching part. It now drops only an exact `==` repeat. Territory (§Access)
|
||
is ANDed on after the scope, from the session.
|
||
|
||
### Selection highlight
|
||
|
||
The datagrid highlights `model._selection_state.selected_areas` and wipes it on
|
||
every mousedown, so a ctrl-click selection would show only its last cell.
|
||
`areasRef`, a `sliceKey → rectangles` map beside `slices`, is pushed back after
|
||
every change and pruned by live slice key on any deselect, so grid and panel
|
||
cannot disagree.
|
||
|
||
Rectangles are **re-derived when the grid moves**. A sort reorders rows and a new
|
||
segment column shifts every column; tracked coordinates would then highlight
|
||
whoever took the position while `slices`, being value-based, still named the
|
||
right row — the grid would be the only thing lying. `relocateAreas()` sweeps the
|
||
displayed rows as a drag would and keeps cells whose slice is still selected. A
|
||
slice can therefore light more cells than were clicked (a slice not naming the
|
||
segment matches the customer under old and new segments alike), which is what it
|
||
covers. Past `MAX_RELOCATE_ROWS` the highlight is cleared rather than left wrong.
|
||
|
||
---
|
||
|
||
## Operations
|
||
|
||
`POST /api/versions/:id/{scale|recode|clone}`, common body:
|
||
|
||
```json
|
||
{ "slices": [ { "channel": "WHS" } ], "scope": [ ["season", "<=", 2027] ],
|
||
"apply_mode": "prorate", "tag": "volume_push", "note": "optional" }
|
||
```
|
||
|
||
`slices` — one or more (a legacy single `slice` is accepted). `apply_mode` —
|
||
`prorate` (default) is one statement over the union and one entry; `each` is one
|
||
per slice, so each reaches its target alone and undoes separately (for recode and
|
||
clone it only decides how many entries). `pf_user` is the session's. A closed
|
||
version answers 403. Operations are not admin-gated; territory and ownership
|
||
govern them. The response is the new rows — whole in raw mode, aggregated in
|
||
grain mode — for `table.update()`; no reload.
|
||
|
||
### SQL shape
|
||
|
||
An `ilog` CTE inserts the log row and the insert CTEs reference its id;
|
||
`{{where_clause}}` is the selection and `{{exclude_clause}}` keeps
|
||
`exclude_iters` out.
|
||
|
||
**Every date column travels** — the written columns are every dimension, every
|
||
date and the first value and units column. Scale and recode used to carry only
|
||
the first date, so on a source with order, requested and ship dates they wrote
|
||
the others **null**. The period dimensions were fine, being dimensions — which
|
||
hid it: labels right, dates gone. Those dates are sliceable, so an adjustment
|
||
scoped to a ship date matched no adjustment ever written. Clone and loads were
|
||
always right. Rows written before the fix keep the nulls; deleting and replaying
|
||
those entries is the honest repair, since re-deriving them would be inference.
|
||
|
||
**One row per coordinate, not per row read.** Reads GROUP BY every dimension and
|
||
date. One-for-one, every operation inherited the row count of everything before
|
||
it at that coordinate, and the table grew super-linearly with the work done.
|
||
Only `pf_logid` and `pf_iter` collapse, so nothing goes null or unsliceable; the
|
||
maths is unchanged, `sum(sum(x)) OVER ()` being the aggregate of aggregates.
|
||
|
||
### Scale
|
||
|
||
Distributes an increment in proportion to each coordinate's current amount:
|
||
|
||
```sql
|
||
round(("value" / NULLIF(total_value, 0)) * {{value_incr}}, 2)
|
||
round(("units" / NULLIF(total_units, 0)) * {{units_incr}}, 5)
|
||
```
|
||
|
||
`resolveIncrs()` in `routes/operations.js` computes the two increments per
|
||
statement, which is what makes `each` land per slice. Value and units each take
|
||
at most one of a target (`target_value`/`target_units`), a percentage
|
||
(`value_pct`/`units_pct`) or a change (`value_incr`/`units_incr`), resolved
|
||
independently; `target_price` is a third input; legacy `pct: true` is honoured.
|
||
|
||
**`target_basis`** — what a target or percentage measures against:
|
||
|
||
- `adjustable` — only the rows the operation can write. The **server default**.
|
||
- `selected` — everything the pivot shows, `exclude_iters` rows included. Those
|
||
cannot move, so the adjustable rows absorb the difference and the pivot lands
|
||
on the number asked for; otherwise a target set against a visible total
|
||
overshoots by the fixed rows.
|
||
|
||
The panel defaults to `selected` and sends it only when the selection holds
|
||
excluded rows (otherwise they are identical). The basis row is tinted and tagged
|
||
"target", and clicking either row switches.
|
||
|
||
#### Price does not move unless it is named
|
||
|
||
A price move is invisible in the figure being edited — the dollars land either
|
||
way, and only the implied price says whether volume followed. So:
|
||
|
||
**`plug`** answers the one ambiguous case, dollars alone: `volume` (default,
|
||
price holds, units scale in proportion) or `price` (volume holds). From the Excel
|
||
form:
|
||
|
||
```
|
||
plug volume: pchange = fVal/(pVal+bVal); fVol = (pVol+bVol)*pchange
|
||
plug price: fVol = pVol + bVol
|
||
```
|
||
|
||
**A qty input follows the same rule**: with dollars unnamed they follow at the
|
||
current price, `value = (curValue / curUnits) * unitsIncr`. Holding them made
|
||
every qty edit a price edit — more cases at the same revenue is a price cut
|
||
nobody asked for. A slice with no units has no price, so its dollars stay put.
|
||
|
||
**The price held is the adjustable rows' own**, never blended with the fixed
|
||
rows. The SQL prorates over the adjustable rows only, so an increment sized at a
|
||
blended price moved qty by a different factor than dollars wherever reference
|
||
rows priced differently — and taking a selection to its fixed total drove qty past
|
||
zero, grains with positive sales and negative qty. The ledger preview uses the
|
||
same price.
|
||
|
||
**Naming the price** (`target_price`, the Excel form's Edit Price) holds it, and
|
||
the other named measure decides the rest, on the chosen basis:
|
||
|
||
| Named | Result |
|
||
|---|---|
|
||
| price alone | qty holds; dollars = price × qty |
|
||
| price + qty | qty lands as named; dollars = price × that qty |
|
||
| price + dollars | dollars land as named; qty = dollars / price |
|
||
| all three | refused — two degrees of freedom, three numbers |
|
||
|
||
This branch used to read only a qty *target* (a qty change sized dollars on the
|
||
old qty), took the fixed qty off a target but not the fixed dollars, and let a
|
||
dollar figure outrank the price. Worse, the panel sends no `plug` with a price,
|
||
so the server read `volume` and scaled qty by the dollar change too, putting
|
||
price back where it started. A named price now bypasses the plug.
|
||
|
||
So the deliberate ways to move price are **dollars and qty together** and
|
||
**naming the price**; both outrank the plug.
|
||
|
||
**What this replaced.** `plug` defaulted to `price`, being what the API did
|
||
before the flag existed — so an unqualified dollar edit and every qty edit moved
|
||
price, and only a hand-picked `volume` did not. The client's preference is keyed
|
||
`pf_scale_plug_v2` so a browser carrying the old `price` choice does not go on
|
||
silently moving price.
|
||
|
||
#### The ledger and its three boxes
|
||
|
||
The scale form is one continuous statement: baseline, adjustments grouped by tag
|
||
(untagged ones collapsed), fixed segments, total, then the edit, with a running
|
||
total closing on the selected total. Value, qty and price each have `New` /
|
||
`Change` / `% change` boxes; typing in one derives the others, and the box typed
|
||
in is what is sent — the row you type in *is* the mode. A **Result** line
|
||
previews all three under the server's rules and marks the column that cannot
|
||
move `· holds`.
|
||
|
||
**The boxes foot** — price = value / qty — so at most two are inputs.
|
||
`effectiveScaleInputs()` in `OperationPanel.jsx` decides which when all three are
|
||
typed: value typed last lets the plug decide (`volume` drops qty, `price` drops
|
||
price), since a dollar change says nothing about price or volume; otherwise the
|
||
more recently touched of the other two stays. The dropped box keeps its text, so
|
||
flipping the plug brings it back. `buildPayload()` in `Forecast.jsx` uses the same
|
||
function, so what is posted is what is shown. Clicking into a derived figure pins
|
||
it — "units are this" rather than "units may move" — and **unpin** releases it.
|
||
Rough edge: all three typed, dollars last, the choice is really which earlier
|
||
number to keep, and "Volume" there moves price too.
|
||
|
||
**The Moves control** is the plug. It lights whatever the edit moves — both if
|
||
both do — and takes a click only while it is the one deciding. It used to render
|
||
only for a dollars-only edit, so it was absent before anything was typed and
|
||
vanished when a measure was pinned, even by an accidental click — two states that
|
||
both looked like a missing control. It now stays, greyed when the inputs have
|
||
answered it, showing their answer.
|
||
|
||
A price change or percentage is resolved client-side to an absolute
|
||
`target_price`; the server has no price increment form.
|
||
|
||
#### What the log records
|
||
|
||
`params` carries the intent (`pickIntent()`: inputs, `target_basis`, `scope`,
|
||
`plug`) plus:
|
||
|
||
- **`resolved`** — `value_incr`, `units_incr` and **`method`**: `price` (named),
|
||
`both` (dollars and qty named), `volume` (qty named), else the plug. The
|
||
request alone cannot say which moved — the plug is a per-browser preference —
|
||
so a price edit used to be findable only by noticing qty had not moved.
|
||
- **`expected`** — the panel's Result line as a change on its basis (under
|
||
`each`, combined and flagged so), beside `resolved` so a panel/server
|
||
disagreement shows in the log. Nothing compares them yet.
|
||
|
||
#### Refusals
|
||
|
||
- **A pool netting to ~zero** (below 1% of gross): shares are `row / total`, so
|
||
as the net nears zero rows fly to extreme opposite values. Offsetting slices
|
||
are the usual cause; `each` handles them.
|
||
- **Nothing to scale** — every unit already on target or empty; individually
|
||
skipped slices come back in `slices_skipped`.
|
||
- **Holding price on a selection with no value** — no price to hold.
|
||
|
||
Rounding each row to 2dp can leave a target a cent off; landing exactly would
|
||
need a correction row.
|
||
|
||
### Recode
|
||
|
||
Moves the selection to other dimension values in one entry: **negative rows**
|
||
zero it out where it was, **positive rows** put it back with `{{set_clause}}`
|
||
replacing the named dimensions.
|
||
|
||
**A date shift moves the put-back leg only**, so a shifted recode is a timing move
|
||
— pull forward, push out — and `set` may be empty. Moved rows' period dimensions
|
||
are re-derived against the shifted date, as clone's are; an explicit `set` wins;
|
||
a row with a null date keeps its label, since a lookup on nothing would blank it.
|
||
Without a shift nothing is derived.
|
||
|
||
**Coordinates already netting to zero are skipped** (`HAVING sum(x) <> 0`),
|
||
rather than writing two empty rows each — which a second recode over cells a
|
||
first one emptied would do for all of them. If nothing is left the request is
|
||
refused ("Nothing to recode") rather than committing an empty entry. A scoped
|
||
account cannot set the territory column (§Access).
|
||
|
||
### Clone
|
||
|
||
Copies the selection as new business: `set` overrides dimensions, `scale`
|
||
multiplies (default 1), `date_offset` shifts every date; the original is
|
||
untouched. Period dimensions come from the calendar against the shifted date —
|
||
else a mix moved forward a year keeps last year's labels — and `set` wins. Unlike
|
||
recode, a null date blanks them.
|
||
|
||
**`exclude_iters` does not apply to clone.** It stops operations *modifying*
|
||
reference rows — scale would attribute movement to prior-year rows, recode would
|
||
write negatives against them. Clone does neither; copying a plan or prior year
|
||
out of reference is the operation working as intended, and excluding them made a
|
||
deliberate selection silently produce nothing. **`from_logid`** narrows instead,
|
||
for a selection spanning segments where only one is wanted.
|
||
|
||
Known issue: a clone matching nothing commits an empty entry with
|
||
`rows_affected: 0`, where scale and recode refuse.
|
||
|
||
### Undo
|
||
|
||
`DELETE /log/:logid` deletes the entry's rows then its log row in one transaction
|
||
and returns `pf_gkeys` (grain mode) or `pf_ids` (raw) for `table.remove()`; the
|
||
view re-sums. Undo is by **ownership**, not territory — your own entries or an
|
||
admin's override — because it removes rows wholesale, and half-undoing one would
|
||
leave a state nothing describes (and two accounts can share a territory). Closed
|
||
versions refuse. Undoing a load returns its plan to unrun.
|
||
|
||
Known issues: the `pf_gkeys` come from **live** col_meta, so after a grain change
|
||
they match nothing in the loaded table and the pivot keeps the rows (the database
|
||
is still right); recode and clone log `apply_mode: unit.mode`, never set, so
|
||
their `params` omit it.
|
||
|
||
---
|
||
|
||
## Display-grain pre-aggregation
|
||
|
||
**Built, with a static grain.** Path B of `pf_perspective_options.md` — a
|
||
pre-aggregated extract in a **native** Perspective table — rather than Path A,
|
||
live aggregation through a DuckDB virtual server (parked on
|
||
`spike/duckdb-virtual-server`), which loses expand, collapse, depth, sort and
|
||
filter.
|
||
|
||
**The problem.** Shipping raw rows makes the browser pivot all of them and load
|
||
time grows with raw rows. Aggregating server-side to the grain the pivot
|
||
displays cuts the row count by orders of magnitude while Perspective still does
|
||
all the rollup locally.
|
||
|
||
### Grain definition — `pf.col_meta.in_grain`
|
||
|
||
The flagged `dimension`/`date` columns, plus always `pf_iter` and `pf_logid`.
|
||
`grainOf()` in `lib/sql_generator.js` is the single definition (`Setup.jsx` and
|
||
`routes/log.js` mirror it), baked into the templates at Generate SQL so load and
|
||
operations agree by construction.
|
||
|
||
- **Additive measures only.** Sums are exact; an average would ship as
|
||
sum + count; `count(DISTINCT …)` cannot be pre-aggregated. Forecast measures
|
||
are sums.
|
||
- **An unflagged dimension cannot be pivoted or filtered on.** Leaving
|
||
high-cardinality columns (part numbers, raw dates) out is what keeps the grain
|
||
from exploding back toward raw; the price is that they are unavailable. Dates
|
||
usually enter through their derived period dimensions.
|
||
- Generation refuses a grain column the table never populates, or a grain with
|
||
no measure.
|
||
|
||
`in_grain` means *eligible*; today the grain is exactly the eligible set.
|
||
|
||
### The model: append deltas, let the view sum
|
||
|
||
The Excel pivot cache again: pre-aggregated rows go to the table, the pivot cuts
|
||
them locally, and each adjustment is **appended**, never a recomputed total. Rows
|
||
are aggregated **per `pf_logid`** — one per grain tuple × `pf_iter` × entry — and
|
||
the view sums them. It was also the smallest change from raw mode, which already
|
||
appended results with `table.update()`.
|
||
|
||
**Synthetic key.** Perspective indexes on one column, so each row carries
|
||
|
||
```sql
|
||
md5(concat_ws(chr(31),
|
||
COALESCE(col1::text, chr(30)), …, pf_iter, pf_logid::text)) AS pf_gkey
|
||
```
|
||
|
||
`pf_logid` keeps each entry's contribution distinct, so an append accumulates
|
||
rather than replacing a bucket and undo removes exactly that entry. Plain
|
||
`concat_ws` skips NULLs, so `('a', NULL)` and `(NULL, 'a')` would collide and
|
||
silently merge two groups; `chr(30)` also keeps NULL apart from `''`. The md5
|
||
because the key is an opaque handle read only by `update()`/`remove()`: raw, it
|
||
was long and, unique per row, defeated Arrow's dictionary encoding — the largest
|
||
column in the payload. 128 bits keeps collisions unreachable.
|
||
|
||
### Initial load — `GET /api/versions/:id/agg`
|
||
|
||
```sql
|
||
SELECT
|
||
t.<grain cols>, t.pf_iter, t.pf_logid, <pf_gkey>
|
||
,SUM(t.<value>) AS <value>, SUM(t.<units>) AS <units>
|
||
,l.label AS pf_segment, l.bucket AS pf_bucket
|
||
,<NOTE_EXPR> AS pf_note, l.operation AS pf_op
|
||
FROM {{fc_table}} t
|
||
LEFT JOIN pf.log l
|
||
ON l.id = t.pf_logid
|
||
WHERE {{territory_clause}}
|
||
GROUP BY <grain cols>, pf_iter, pf_logid, l.operation, l.label, l.tag, l.note, l.bucket
|
||
```
|
||
|
||
The `pf.log` columns depend on `pf_logid`, but Postgres will not infer it, so
|
||
they are grouped (`LABEL_GROUP_COLS`). **Territory applies before the GROUP BY**,
|
||
since its column need not be in the grain. The route sets `LOCAL enable_sort` and
|
||
`enable_incremental_sort` off: seeing `pf_logid` pre-sorted off its index, the
|
||
planner picks a sort-based GroupAggregate for the "free" sort, but comparing
|
||
dozens of keys per row makes it several times slower than a HashAggregate,
|
||
whatever `work_mem` or statistics say. The client loads the result with
|
||
`worker.table(buffer, { index: 'pf_gkey' })`.
|
||
|
||
### Write and delete paths
|
||
|
||
Operations insert raw rows under a new logid as always; in grain mode the final
|
||
select aggregates **that entry's rows** to grain (`FROM ins`, or recode's
|
||
`neg UNION ALL ins`) — safe because they all carry the new logid. The keys are
|
||
therefore always new, `update()` **appends**, and nothing is recomputed.
|
||
|
||
Undo removes an entry's keys and lets the view re-sum — no re-aggregation, no
|
||
emptied-bucket handling. `RETURNING` does not take `DISTINCT`, so:
|
||
|
||
```sql
|
||
WITH del AS (
|
||
DELETE FROM {{fc_table}} WHERE pf_logid = $1
|
||
RETURNING <grain cols>, pf_iter, pf_logid
|
||
)
|
||
SELECT count(*)::int AS rows_deleted, array_agg(DISTINCT <pf_gkey>) AS pf_gkeys
|
||
FROM del;
|
||
```
|
||
|
||
### Raw mode
|
||
|
||
With nothing flagged, no `get_agg` exists and the client uses `/data`: every row
|
||
plus `pf_segment`, `pf_bucket` and `pf_note`, indexed on `pf_id`; operations
|
||
return whole rows and undo returns `pf_ids`.
|
||
|
||
### Transport
|
||
|
||
Both endpoints return Arrow IPC in **one record batch** with `X-Row-Count`.
|
||
`/data` reads a cursor in batches into column arrays (row objects multiplied peak
|
||
heap) and builds one table with `tableFromArrays`; `/agg` does the same from its
|
||
result. One batch because per-batch encoding creates independent dictionaries,
|
||
and Perspective's WASM reader crashes on the replacement messages. An earlier
|
||
plan to stream batches into `update()` behind a large-dataset banner was never
|
||
built, for that reason.
|
||
|
||
**Load time is dominated by row count, not payload size.** Even a good grain on a
|
||
wide source leaves enough rows that getting them out of Postgres and into JS
|
||
dwarfs the aggregation and the Arrow build; halving the payload (the md5 key)
|
||
barely moved it. The remaining lever is the dynamic grain.
|
||
|
||
### The dynamic variant
|
||
|
||
The static grain means an unflagged dimension is never pivotable and every
|
||
flagged one is paid for even when the pivot uses none of them. The additive next
|
||
step: group by the fields the current pivot uses — the viewer's `group_by`,
|
||
`split_by` and `filter` intersected with the eligible set — and re-fetch when they
|
||
change. That needs the grain passed per request rather than baked into `pf.sql`,
|
||
with operation responses and undo keys computed at the loaded table's grain.
|
||
|
||
**Grain drift.** Changing `in_grain` after a load needs Generate SQL and a page
|
||
reload, since the loaded table's index and columns are fixed; until then undo's
|
||
keys, from live col_meta, match nothing.
|
||
|
||
---
|
||
|
||
## The pivot
|
||
|
||
### Perspective, vendored
|
||
|
||
Perspective 5.4.0, `@perspective-dev/*` — **not** FINOS `@finos/perspective` —
|
||
built from a patched fork and committed as tarballs in `ui/vendor`, whose README
|
||
covers the patches (column-axis expand/collapse, `split_by_depth`, depth applied
|
||
on config update), provenance and rebuild. Bundled through the `/inline`
|
||
entrypoints, **never a CDN**: the 4.x CDN bundle resolved its server WASM to an
|
||
unversioned path and silently pulled whatever was newest.
|
||
|
||
### Layouts — `pf.layout`
|
||
|
||
A named `ViewConfig` owned by an account. **Published** — everyone on the
|
||
forecast lists and can apply it, only owner or admin may change it — or
|
||
**private**. Scope is the **version** by default, where people enter;
|
||
`version_id IS NULL` applies to every version of the source. `is_default` (one
|
||
per scope, published only) is restored on a first load, version-scoped beating
|
||
source-wide — the narrower answer is the one somebody chose for this forecast.
|
||
With no default: `group_by: ['pf_iter']` over the first value column.
|
||
|
||
Load order: this version's last-used unnamed config in `localStorage`
|
||
(`pf_layout_v{vid}`) if any — per-browser continuity, not something anyone names
|
||
or shares — else the default. The old named list (`pf_layouts_v{vid}`) is lifted
|
||
into `pf.layout` as private rows once and cleared.
|
||
|
||
**Permissions are the `pf.log` rule** and `can_edit` rides on every row so the
|
||
**Layout ▾** menu greys the rest. Applying is never restricted: the guarantee is
|
||
that a published layout cannot be *changed* under people, not that it cannot be
|
||
adapted — Save is withheld on others' layouts, Save as… forks. **No territory
|
||
clause**: layouts are display config and territory restricts rows.
|
||
|
||
`cleanLayout()` gates every stored config before `restore()`, which is
|
||
all-or-nothing: it drops unknown columns from the axes and `aggregates`
|
||
(including a multi-arg weight column) entry by entry rather than dropping the
|
||
layout, and strips `theme`, the dead ordering expressions and `pf_totals`.
|
||
|
||
**The dirty dot is a comparison, not a flag.** `restore()` itself fires
|
||
`perspective-config-update`, so a flag set there would light the moment a layout
|
||
was applied. `sameConfig()` compares against `activeConfigRef`, ignoring `table`
|
||
(per-load) and `theme`.
|
||
|
||
**Grand totals ride in the layout as `pf_totals: { row, col }`** — the Total
|
||
toggles. The engine has no "subtotals but no grand total" mode, so both are
|
||
hidden in the grid's DOM; `captureConfig()` adds the key and `cleanLayout()`
|
||
strips it. Without it: row shown, column hidden.
|
||
|
||
**What this replaced.** Named layouts lived only in `localStorage`, invisible to
|
||
others and gone on another machine. The one server-side layout was
|
||
`pf.source.default_layout`, an anonymous blob `PUT /sources/:id/default-layout`
|
||
let *any* account overwrite for *every* account — a published layout with no
|
||
owner. The route is gone; the column is migrated and unread.
|
||
|
||
### Axis depth
|
||
|
||
Both axes collapse by a **depth in `ViewConfig`** set through `restore()` —
|
||
`group_by_depth` (`applyDepth()`) and `split_by_depth` (`applySplitDepth()`). In
|
||
the config it survives every rebuild, rides into layouts via `viewer.save()`, and
|
||
needs nothing re-applied.
|
||
|
||
Each axis is a **stepper** from `DepthButtons.jsx` — `◂ level ▸`, the middle
|
||
opening the level list for a jump — three controls whatever the depth. It was one
|
||
button per level, growing with the pivot, the current one a blue among greys so
|
||
reading it meant scanning; before that a fixed `0 1 2 3` that named nothing and
|
||
offered levels a two-field `group_by` lacks. It shows an em dash until a layout
|
||
has been read back, and its middle is sized to the longest level so the toolbar
|
||
holds still.
|
||
|
||
**Depths are 1-based counts of levels to show**, on the stepper and in the
|
||
config, so the value crosses unchanged; `server.cpp` sets `set_depth(n - 1)` and
|
||
`t_traversal::set_depth` adds 1 back. The toolbar used to send `d + 1` and
|
||
subtract it on read-back — self-consistent, and one level off the grid
|
||
everywhere. The `+1` dodged depth 0, which reaches the engine as
|
||
`set_depth(-1)` on an unsigned `t_depth`, underflows and clamps to fully
|
||
expanded. `applyDepth` now floors at 1, so the **Total slot still shows the first
|
||
level, not a bare total** — a wart kept rather than traded for the underflow. A
|
||
real total needs `group_rollup_mode: 'total'`, unverified on this engine.
|
||
|
||
**This required patching the engine.** `ViewConfig::apply_update` applied ten
|
||
fields and neither depth, so a depth through `restore()` was accepted and
|
||
dropped — and `Session::update_view_config` returns early when nothing changed,
|
||
so no view was rebuilt. `ui/vendor/0001-apply-depth-fields-on-config-update.patch`.
|
||
|
||
**What this replaced:**
|
||
|
||
- Row depth was imperative — `view.set_depth()` on an object the viewer discards
|
||
whenever it re-renders — so restoring it meant guessing when that happened: an
|
||
`observerShim.js` patching `IntersectionObserver` and `ResizeObserver`, focus
|
||
and visibility listeners, a retry loop for `getView()` throwing `No table set`,
|
||
a flag for whether the viewer had "gone away". That produced three visible
|
||
faults: the tree fully expanding on refocus, snapping on any reflow, and
|
||
snapping when the settings sidebar opened.
|
||
- Column collapse restored a **truncated `split_by`**, so discarded levels were
|
||
remembered separately (`splitFull`, persisted as `split_full`), our collapse
|
||
had to be told apart from the user rearranging, and the selection was cleared
|
||
on every collapse. `split_full` and `expand_depth` are still read from old
|
||
layouts.
|
||
|
||
**Depth changes rebuild the view** — `restore()` makes the session tear down and
|
||
rebuild it, a full traversal, where a manual collapse mutates in place.
|
||
Noticeable on a large grain. Detecting a depth-only change and calling
|
||
`view.set_depth()` while still writing the config would fix it, at the risk of the
|
||
two drifting.
|
||
|
||
**Per-node expansion is not solved.** It is view state with no config form, and
|
||
the API is set-only (`expand(row_index)` / `collapse(row_index)`, no getter), so
|
||
it cannot be captured and replayed and is lost on every rebuild. The honest route
|
||
is a `ViewConfig` field of expanded row *paths* (indices shift as the tree
|
||
opens), applied in `server.cpp` beside the depths — needing node enumeration in
|
||
the C++ traversal, a proto field and the apply step. `expand_column` /
|
||
`collapse_column` share the limitation. And depth is whole-axis: Excel can
|
||
collapse one year while the next stays open.
|
||
|
||
### Auto-pause is off
|
||
|
||
`<perspective-viewer>` auto-pauses by default — an `IntersectionObserver` plus
|
||
`visibilitychange` — and pausing **deletes the view**, so returning to the tab
|
||
means `restore_and_render()`: a fresh traversal of the whole grain, a
|
||
multi-second chug per tab switch on a large one, losing per-node expansion.
|
||
`initViewer()` calls `viewer.setAutoPause(false)` after `load()`. Nothing updates
|
||
the table while hidden — every operation is driven from this page — so the pause
|
||
bought nothing.
|
||
|
||
### Toolbar
|
||
|
||
Two zones. Left, **view**: `Layout ▾` and the Rows and Columns steppers with
|
||
their Total chips — all `ViewConfig`, nothing reaching the server. Right,
|
||
**forecast**: Refresh data, Change log, Bridge, Operations — re-running the load,
|
||
opening something over the pivot, or writing.
|
||
|
||
The distance between them is the separation. Each was once an outlined box too,
|
||
the view one tinted too faintly to see and, in dark mode, reading as a sibling;
|
||
before that everything was one row of identical hairlines, making a
|
||
column-width control and the version's history look like peers. Three weights do
|
||
the separating: `.pf-section-label` names a group and is heavier than its
|
||
controls, `.pf-group-rule` divides groups within a zone, the gap divides zones.
|
||
Each axis is one joined control, `◂ | level | ▸`, then its Total chip.
|
||
|
||
**Blue is for actions, pressed grey for state.** Save and Post are blue; the
|
||
Total chips and an open Operations use `.pf-pressed`. Operations used to relabel
|
||
itself "Hide panel" in a blue outline, looking like a second Save.
|
||
|
||
**Fit widths is gone**, for three independent reasons: auto-size measures over
|
||
the rows *in the viewport*, so anything below the fold never counted;
|
||
`draw({invalid_columns})` passes an option `DrawOptions` lacks, and `draw()` is
|
||
throttled, so a click during a pending draw cleared widths without re-measuring;
|
||
and the `persistLayout` after it saved nothing, since `saveColumnSizes()` returns
|
||
only overrides, which `resetAutoSize()` had emptied — and this fork discards
|
||
`column_size_override` whenever `split_by` is set, which here is always.
|
||
Recoverable from git if ever worth fixing.
|
||
|
||
The operation panel docks **bottom**, **right** or **floats**, persisted locally;
|
||
the toolbar toggle shows the selection count while it is shut.
|
||
|
||
### Calculating indicator
|
||
|
||
**Calculating…** shows in the status bar while the pivot rebuilds. Perspective's
|
||
beaker renders only while its settings panel is open, vanishing exactly when
|
||
nothing else says to wait. A counter — rebuilds overlap — rises in `applyDepth`,
|
||
`applySplitDepth` and `applyLayout`, falls after `viewer.flush()`, and travels as
|
||
`pf-pivot-busy` on the window, like `pf-data-changed`.
|
||
|
||
**Not** driven by `perspective-config-update`, the obvious hook: on a depth
|
||
change over a large grain it arrives *after* the rebuild, seconds late, and the
|
||
flush after it resolves at once. The cost is that sorting or filtering from the
|
||
viewer's own chrome shows nothing, since the app is never told they began.
|
||
|
||
### Light and dark
|
||
|
||
`ui/src/theme.jsx` holds a context stored as `pf_dark`, defaulting to
|
||
`prefers-color-scheme`; the status-bar toggle sets `.dark` on `<html>`.
|
||
`index.css` defines properties under `:root` and `.dark`, with Tailwind overrides
|
||
written as `.dark .bg-white { … }` — no Tailwind dark config. Dark uses
|
||
Perspective's Pro Dark palette.
|
||
|
||
The pivot takes a **partial** `viewer.restore({ theme: 'Pro Dark' | 'Pro Light' })`
|
||
on load and on toggle. It used to be `setAttribute('theme', …)`, which does
|
||
nothing in 5.4.0 — the theme is a `ViewerConfig` field — so the pivot stayed light
|
||
whatever the toggle said. The other half is `cleanLayout()` dropping `theme`:
|
||
`viewer.save()` includes it, so every layout reasserted its author's theme. It is
|
||
the reader's choice, so a published layout cannot impose one.
|
||
|
||
---
|
||
|
||
## Access
|
||
|
||
### Authentication and sessions
|
||
|
||
Everything under `/api` but `/login`, `/logout`, `/me` sits behind
|
||
`requireAuth`; the React app mounts only with a session (`Gate` in `main.jsx`),
|
||
since its effects call the API at once, and `auth.jsx` wraps `fetch` so a 401
|
||
returns to login.
|
||
|
||
- **Accounts** — `pf.app_user`, scrypt hashes whose stored form carries its
|
||
parameters, so they can be raised without invalidating rows. `./pf.sh add-user
|
||
| passwd | list-users | disable-user | enable-user`; passwords go in on stdin
|
||
through `node lib/auth.js hash`, never argv or psql.
|
||
- **Sessions** — `connect-pg-simple` in `pf.session`, so restarts keep people
|
||
signed in and a session is revoked by deleting its row (`disable-user` does).
|
||
Cookie `pf.sid`: httpOnly, SameSite=Lax, Secure unless `COOKIE_SECURE=false`,
|
||
12h rolling.
|
||
- **Config** — the server exits without `SESSION_SECRET` rather than using a
|
||
guessable default. `TRUST_PROXY` (default 1) keeps `req.ip` and secure cookies
|
||
right behind the TLS proxy. CORS is enabled only by `CORS_ORIGIN`, by name —
|
||
a wildcard origin plus a session cookie is CSRF by construction.
|
||
- **Login hardening** — ten failures per IP per fifteen minutes (in memory), one
|
||
message for unknown/wrong/disabled, session id regenerated on success.
|
||
|
||
**Identity is server-side.** `pf_user`, `created_by`, `closed_by` come from
|
||
`sessionUser(req)`, never the body. The UI used to send a hardcoded
|
||
`pf_user: 'admin'`, which any client could set to anything.
|
||
|
||
### Setup and Baseline are admin-only to write
|
||
|
||
`requireAdmin` sat exported in `lib/auth.js` and applied to nothing, so every
|
||
non-admin could call every Setup and Baseline endpoint. It now guards source
|
||
writes (register, cols, Generate SQL, member refresh, deregister), version writes
|
||
(create, update, close, reopen, delete), loads (baseline, reference, segment edit,
|
||
clear) and every plan write. `is_default` is further admin-checked inside
|
||
`PUT /versions/:id`.
|
||
|
||
**Not gated, each for a reason.** No read, including the table browser — the
|
||
pages stay visible, and gating what a page needs to *render* turns it into a 403.
|
||
Not the forecast operations, which carry territory and ownership rules. Not
|
||
`PATCH /versions/:id/tags`, whose permission is ownership.
|
||
|
||
**The pages go read-only rather than hidden**: one `<fieldset disabled>` per page
|
||
instead of a flag threaded through every control — Baseline alone has dozens, and
|
||
the one missed would be the one that mattered — plus a `ReadOnlyNotice` banner.
|
||
Setup wraps a real box because the fieldset *is* its flex row; Baseline uses
|
||
`display: contents` so its layout is untouched. **None of this is the
|
||
permission**; it only stops offering buttons that would answer 403.
|
||
|
||
**A 403 used to blank the whole app.** Setup put responses straight into state
|
||
and `.map`ped them; an error body is `{error}`, and throwing in render unmounts
|
||
React. Its list fetches now coerce to arrays, so a bad response costs an empty
|
||
panel, not the session. The fragility predated the gating, which only made it
|
||
reachable.
|
||
|
||
### Territory scoping
|
||
|
||
An account sees and changes only its territory: `pf.app_user.territory` (jsonb
|
||
list), `is_admin` for the unrestricted, and `col_meta.is_territory` naming which
|
||
column of a source the values belong to — flagged, not coded, so another source
|
||
can be divided by something other than a sales rep.
|
||
|
||
**Fail closed.** No territory and not admin means no rows:
|
||
`buildTerritoryClause()` returns `FALSE`, not `TRUE`, for an empty list or an
|
||
unflagged source, so a forgotten account sees nothing rather than the book.
|
||
|
||
**Built from the session, never the request** — the difference from `scope`,
|
||
which the browser rightly sends as a filter the user chose. A permission cannot
|
||
come from the thing it restrains, so the predicate is ANDed on last, in
|
||
`sliceUnits()` for writes and per route for reads. Enforced at:
|
||
|
||
- `/data` — cursor *and* the `X-Row-Count` count
|
||
- `/agg` — `{{territory_clause}}`, before the GROUP BY
|
||
- `/versions/:id/table-info` — unscoped, it told an account its load was
|
||
fetching the whole table
|
||
- every operation (`pf.log.env` records the territory in force)
|
||
- `/sources/:id/values/:col` — it reads the *source*, untouched by any scope
|
||
- recode's `set` — a scoped account cannot set the territory column. That is
|
||
reassignment, not forecasting, and the row would vanish from the view that
|
||
would have shown what happened.
|
||
|
||
**Ownership, not territory**, governs `DELETE /log/:logid`, `PATCH /log/:logid`
|
||
and `PATCH /versions/:id/tags`. The log PATCH looks like a private annotation and
|
||
is not: `label` and `bucket` name the pivot's columns for everyone, so unguarded
|
||
it let any account rename the company's segments, including on loads whose rows
|
||
it cannot see.
|
||
|
||
Known gaps, verified in code:
|
||
|
||
- **Completion now uses unscoped routes.** Recode and clone complete from
|
||
`GET /versions/:id/values/:col` (the version's own table, cached in memory
|
||
keyed on the latest logid), the member list from `/sources/:id/dim/:group`,
|
||
sibling autofill from `/sources/:id/lookup`. None applies the territory clause.
|
||
- **Territory is read onto the session at login**, so a change reaches a
|
||
signed-in account only when it signs in again. Re-reading it in `requireAuth`
|
||
would also make disabling immediate.
|
||
|
||
`./pf.sh set-territory | set-admin | orphan-territory`; the last lists values in
|
||
the data that no account owns — work under one is invisible to all but admins,
|
||
which a typo causes easily and nothing in the app reveals.
|
||
|
||
---
|
||
|
||
## Bridge and change log
|
||
|
||
### Bridge
|
||
|
||
A waterfall of *how this forecast got from where it started to where it stands*,
|
||
one step per initiative tag. `BridgeView.jsx`; the arithmetic is in exported pure
|
||
`buildSteps()` / `layoutSteps()`, checkable against real data without a browser.
|
||
|
||
**Computed from the table already in the browser**, not `GET /versions/:id/bridge`,
|
||
so it reconciles with the pivot. The endpoint remains for API consumers, grouping
|
||
by tag over rows outside `exclude_iters`, with no notion of buckets.
|
||
|
||
**Scope** is the selection (the union of its slices, an operation's reach) or the
|
||
pivot's filter, following the live selection until a scope button is picked.
|
||
There is **no whole-version scope**: the filter is part of what the figures
|
||
mean, and dropping it moves one end of the walk and not the other — rows outside
|
||
a season filter sat wholly in the forecast loads with no counterpart in the plan,
|
||
inflating the endpoint without explanation. View filters are AND-only, so
|
||
selection scope builds a view per slice, each with the pivot's filter.
|
||
|
||
**Membership comes from `pf_bucket`, not `pf_iter`**: open orders load as
|
||
reference and are still forecast, and an iter-keyed bridge dropped them. The
|
||
forecast bucket is the version's `adjustment_bucket` — the value unbucketed
|
||
adjustments carry, so the two agree by construction. It used to be the literal
|
||
`'Forecast'`; once buckets carried their sort prefix nothing matched, every row
|
||
became comparison, and the basis cancelled itself to zero. With no match it
|
||
falls back to whichever bucket holds the adjustments. Selection scope does not
|
||
filter on `pf_bucket`/`pf_segment` even when a slice names one, since Compare to
|
||
needs rows from every bucket.
|
||
|
||
**Compare to** sets a bucket as basis — Plan when building a forecast, Prior Year
|
||
when building the plan — resting on
|
||
|
||
Forecast − Basis = (Forecast loads − Basis) + adjustments
|
||
|
||
The opening step is the forecast's loads against the basis and the tagged
|
||
adjustments explain the rest: no residual, no plug, the bars summing to the
|
||
endpoint by construction. With no basis it shows the forecast's composition.
|
||
|
||
**Steps group by tag alone**, case-insensitively, showing the commonest spelling.
|
||
They grouped by label first once, to match `pf_segment`, but every adjustment
|
||
carries the same default label, so no tag ever showed — the label is the segment,
|
||
the tag the initiative. Untagged adjustments collapse to one "N untagged
|
||
adjustments" step, as in the ledger, rather than a wall of identical bars.
|
||
|
||
**Colour encodes polarity**: increase and decrease as two poles of a validated
|
||
blue/red diverging pair (green/red is the classic colour-vision failure), neutral
|
||
grey anchors. Everything not a bar follows the theme via CSS variables set
|
||
through `style`, where `var()` is reliable. Every bar is labelled, a table view
|
||
gives full precision, and the panel can be dragged.
|
||
|
||
Known issues:
|
||
|
||
- **Overlapping slices are deduplicated by `pf_id`**, which grain rows lack, so
|
||
in grain mode a row matching two selected slices counts twice; it should key on
|
||
`pf_gkey`.
|
||
- **Selection scope keeps only dimension and date keys**, dropping the `pf_iter` /
|
||
`pf_note` / `pf_op` / `pf_user` / `pf_logid` keys operations honour, so it
|
||
bridges a wider set than the operation would write; a total-cell `{}` slice is
|
||
skipped outright.
|
||
- **No drill-down** from a step to its entries.
|
||
|
||
### Change log
|
||
|
||
A modal of the version's entries, newest first — time, operation, slice, tag,
|
||
note, rows and totals, author — with undo per row and tag and note edited inline
|
||
(Enter saves, Esc cancels). `PATCH /log/:logid` branches on whether a field was
|
||
sent, so `""` clears rather than meaning "leave alone" — except `label` and
|
||
`bucket`, renamed from the Baseline page and never cleared. A saved tag regroups
|
||
the ledger and bridge at once, so history can be reclassified. Multi-slice entries
|
||
list a line per slice, pulling out shared fields when only one dimension varies
|
||
(a dragged region); `{}` reads "Total within the pivot filter". An entry with
|
||
stored SQL opens its debug record — intent, environment, executed statement.
|
||
|
||
**It shows an entry's full impact**, not the reader's share: stamped totals are
|
||
company-wide, so an admin's version-wide scale reads the same everywhere —
|
||
deliberately, and labelled, rather than re-aggregated per territory.
|
||
|
||
**Everyone / Mine.** Everyone's entries are listed, with the reader's own marked
|
||
in place — a blue rule down the row and a blue `By` cell. With several active
|
||
accounts, finding your own meant reading `By` down the list. Marked *and*
|
||
filterable: the mark tells without hiding anybody, the filter is for wanting only
|
||
yours. It opens on Mine every time, unpersisted — the log is almost always opened
|
||
to check your own recent work, and a stale "Everyone" is the worse default.
|
||
`isMine()` is strict authorship, not `canUndo()`: an admin can undo everything,
|
||
and marking every row as theirs would say nothing. The fetch stays unfiltered,
|
||
since the control's counts need the whole set. Rows the reader cannot change are
|
||
greyed rather than offering a 403.
|
||
|
||
`?kind=adjustments` drops load entries, which own nearly every row, from the
|
||
recount join.
|
||
|
||
Known issue: the log does not show an entry's id, so there is no way to name one
|
||
when asking about it.
|
||
|
||
---
|
||
|
||
## Proposed: additional measures
|
||
|
||
Status: **proposed — not built.** Nothing below exists in code yet.
|
||
|
||
### The problem
|
||
|
||
The app forecasts exactly one dollar measure (`role = value`) and one quantity
|
||
(`role = units`). `col_meta` lets a second column be marked `value`, but only
|
||
the loads carry it (`generateSQL` in `lib/sql_generator.js`: "the adjustment
|
||
operations are single-measure … and use only the first of each"). So a second
|
||
measure is right on the baseline and wrong everywhere an adjustment has been
|
||
made:
|
||
|
||
- scale, recode and clone write adjustment rows with that column **null**
|
||
- a recode moves the sales and leaves the cost behind
|
||
- in grain mode `/agg` sums only the first value and the units column, so it
|
||
is not in the pivot at all
|
||
|
||
Extended cost is the first case wanted — margin is sales minus cost — and the
|
||
same shape recurs for rebates, surcharges, freight and the like.
|
||
|
||
### The model: every extra measure is a rate on a driver
|
||
|
||
An extra measure is almost never forecast on its own. It is a rate applied to
|
||
something already being forecast:
|
||
|
||
| Measure | Driver | Rate held by an ordinary edit |
|
||
|--------------------|--------|-----------------------------------|
|
||
| Extended cost | qty | unit cost |
|
||
| Surcharge per unit | qty | surcharge per unit |
|
||
| Rebate | sales | rebate as a % of sales |
|
||
| Freight (fixed) | none | — carried, not moved by scale |
|
||
|
||
Declared once, in Setup: `col_meta.role = 'measure'`, plus a new
|
||
`col_meta.driver` of `units` | `value` | `none`. The existing `value` and
|
||
`units` roles are unchanged and remain the two the ledger edits.
|
||
|
||
### How each operation treats a measure
|
||
|
||
The rule is that **an edit never changes a measure's rate unless the rate is
|
||
what is being edited** — the same rule the scale already applies to price.
|
||
|
||
| Operation | Measure driven by qty | by sales | none |
|
||
|--------------------|---------------------------------|--------------------------------|----------------------|
|
||
| Load | copied | copied | copied |
|
||
| Scale | moves by the qty factor | moves by the dollar factor | untouched |
|
||
| Recode | both legs, like the other cols | both legs | both legs |
|
||
| Clone | × scale factor | × scale factor | × scale factor |
|
||
| Undo | removed with its rows | removed with its rows | removed |
|
||
|
||
So a price-only edit moves no qty-driven cost (qty did not move) but does
|
||
move a sales-driven rebate (dollars did). The scale SQL already prorates
|
||
value and units by one factor per coordinate; each measure is prorated by its
|
||
driver's factor the same way, per row, so the rate holds per row and not only
|
||
in total.
|
||
|
||
Because scale moves a measure at the slice's **current** rate, a rate set
|
||
earlier is carried forward by every later edit, which is what makes Set rate
|
||
(below) stick.
|
||
|
||
### Setting a rate: its own operation, not the ledger
|
||
|
||
The ledger stays three boxes — value, qty, price — and the measures stay out of
|
||
it. The ledger is already the hardest part of the panel to read, and a rate is
|
||
a different question from a price-or-volume edit.
|
||
|
||
The case that needs it: a part used as a proxy for a new part not yet set up,
|
||
where the new part's cost is roughly known. The workflow is clone or recode the
|
||
proxy's volume onto the new part, then set the new part's cost.
|
||
|
||
Set rate · measure ext_cost · per qty · new rate 0.31
|
||
|
||
- Writes adjustment rows that change **only that measure**: the slice's cost
|
||
goes to rate × its qty; sales and qty do not move. The same shape as a price
|
||
edit, on a different measure.
|
||
- A sales-driven measure takes its rate as a % of sales.
|
||
- A `none` measure takes a plain target or change amount.
|
||
- One `pf.log` entry, `operation = 'rate'`, `params` recording the measure,
|
||
the rate and the resolved change; undoable, listed in the change log, and a
|
||
line in the bridge like any other adjustment.
|
||
- Measured against the same basis choice as scale (adjustable rows or the
|
||
selected total), with the same territory and scope rules.
|
||
|
||
### In the pivot
|
||
|
||
Each measure is summed like sales and qty — `/agg` and the `/data` cursor carry
|
||
every `measure` column. Rates and margins are not stored: they are Perspective
|
||
expression columns computed from the sums (unit cost = cost / qty, rebate % =
|
||
rebate / sales, margin = sales − cost, margin % = margin / sales), so they are
|
||
right at every level of the tree.
|
||
|
||
### What has to change
|
||
|
||
- `col_meta`: `role = 'measure'`, new `driver` column; Setup's editor offers both.
|
||
- `lib/sql_generator.js`: loads and `/agg` carry every measure; scale prorates
|
||
each by its driver's factor; recode and clone carry them; a new `rate`
|
||
template.
|
||
- `routes/operations.js`: a `rate` route; scale passes the factors through.
|
||
- `OperationPanel.jsx`: a Set rate form beside Scale / Recode / Clone.
|
||
- Existing versions: the forecast table needs the measure columns, so a version
|
||
created before a measure was added needs them added (or recreating) — the
|
||
same col_meta drift noted under §Open questions.
|
||
|
||
### Open questions
|
||
|
||
- Should a measure be allowed more than one driver (a surcharge that is part
|
||
per-unit, part percentage)? Proposed: no — split it into two measures.
|
||
- Does Set rate on a slice with no qty (a qty-driven measure) refuse, as the
|
||
price hold does on a slice with no value? Proposed: yes.
|
||
|
||
---
|
||
|
||
## Open questions
|
||
|
||
- **Replay** — re-running a version's adjustments against a restated baseline.
|
||
Not built: no route takes `replay` and nothing returns 501. The hard part is
|
||
the one cloning a definition declines: a scale's target was sized against the
|
||
rows that were there, so a replay must choose between the *figure* and the
|
||
*intent* (`params` holds both inputs and `resolved`). `env`'s template
|
||
fingerprint exists partly so a replay can tell an entry ran under another
|
||
template.
|
||
- **Approval workflow** — a submission an admin approves before others see it.
|
||
Deferred.
|
||
- **Export** — CSV, or a push to a reporting table. The bridge's table view is a
|
||
partial stand-in; there is no download. Stored names (§Naming) mean a reporting
|
||
query needs only a join to `pf.log`.
|
||
- **Cross-version comparison** — side by side, or a bridge between versions; the
|
||
isolated tables make it a UNION. Compare to answers the within-version form
|
||
(against a plan bucket loaded into the same version); across versions is open.
|
||
- **Bridge drill-down** — a step listing its entries, or selecting them back in
|
||
the pivot.
|
||
- **Multi-connection support** — one database via `.env` today. The vision: a
|
||
`pf.connection` table (host, port, dbname, user, password as an env-var
|
||
reference), `connection_id` on `pf.source`, per-connection pools; `pf` stays on
|
||
a home connection and sources live anywhere, with a Connections panel in Setup.
|
||
It changes the source schema, so it wants a clean install.
|
||
- **Col_meta / version schema drift** — roles changed after a forecast table
|
||
exists put the generated SQL and the table's columns out of sync, unnoticed.
|
||
Setup should compare col_meta with each table's columns, warn, and offer to
|
||
rebuild (recreate the table, keep the version and log). Today: delete and
|
||
recreate the version. Additional measures would hit this on every version.
|
||
- **Separating `is_key`'s meanings** — §Data model → `pf.col_meta`.
|
||
- **A per-source default layout** beyond the `pf_iter` fallback, built from
|
||
col_meta when nothing is published.
|
||
- **Dynamic grain** — §Display-grain pre-aggregation.
|
||
- **Per-node expansion that survives a rebuild** — §The pivot → Axis depth.
|
||
- **Tests** — SQL generation is token substitution against append-only tables,
|
||
exercised only by hand.
|