Commit Graph

19 Commits

Author SHA1 Message Date
62c815da63 Show the walk's running total in the ledger
The lines sum to Adjustable, and checking that they do meant adding
eight-digit numbers in your head. The column closes on the Adjustable row, so
the walk visibly lands where it says it does.

Value only. A cumulative price is meaningless -- prices do not add -- and a
second running column for units doubles the width to say what the value
column already implies. It appears only when there is more than one line to
accumulate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 12:57:50 -04:00
50f4c50b3a Group the digits as you type in the ledger
-2000000 and -20000000 are the same shape at a glance, and the ledger deals
in both.

Display only: what leaves the input is always the raw string, so nothing
upstream ever sees a comma. Partly-typed numbers survive intact -- "1." and
"-" and "1.50" are all states on the way to a value, and reformatting them
into something else mid-keystroke makes the field unusable.

The caret is restored by counting digits rather than remembering an offset,
since inserting a comma shifts every character after it and a remembered
position lands one place off for the rest of the number.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 12:28:31 -04:00
28aa7012f1 Put what cannot move at the top of the ledger
The immovable rows sat between Adjustable and Selected total, which made
them read as an afterthought to a figure they in fact constrain. They come
first now: this much is already booked and billed, this is what is left to
work with, and here is how that got to where it is.

The walk stays immediately above Adjustable, because it sums to it -- the
two are one statement and separating them would leave a column of numbers
adding up to nothing on the page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 09:43:12 -04:00
2f862a6c8b Name the ledger's lines the way everything else names them
The walk read from tag and note, and hardcoded the word "Baseline" for the
baseline load -- so a segment called 03 - New Orders in the pivot, in the
bridge and on the Baseline page read as "Baseline" in the one place you go
to check a number before changing it. label comes first now, the same
precedence pf_segment uses, in the ledger and the bridge alike. logMeta did
not carry label at all, which is why neither could reach it.

The immovable rows split one line per segment. Combined, "01 - YTD Sales ·
02 - Open Orders" said 1.6m was untouchable without saying how much of it was
billed and how much was booked -- different things a forecaster treats
differently. The FINAL badge also gains the space it was missing, having
rendered as "02 - Open Ordersfinal".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 09:38:37 -04:00
d983e2b1df Carry the pivot filter as a scope, operators and all
Refusing anything but == was safe and useless: a view bounded to
sseas_e <= 2027 is an ordinary way to scope a forecast, and it has no slice
form at all, a slice being {col: value}. The filter now travels beside the
slices as [col, op, value] triples and is ANDed onto every unit -- not folded
into the slices, since it applies to all of them equally and under
apply_mode 'each' would just repeat itself in every statement.

Operators are Perspective's, since that is where they come from, and the list
is a whitelist: anything outside it is refused rather than ignored, because a
scope silently dropped is a write wider than the panel that authorised it.
The scope goes into the log's params too, so the audit trail records what
bounded the write and not only what was clicked.

The panel prints it above the selection as "within sseas_e <= 2027". It
scopes every figure below it and every row the operation writes while
appearing in none of the slices, so without it the panel showed a selection
wider than the one it was acting on -- which is exactly what made the
ledger's 956,485.13 look plausible against a cell of 921,225.71.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 08:46:23 -04:00
50a0bb42aa Make a slice mean what it says, and say what cannot move
The phantom: pf_segment and pf_bucket are computed from pf.log when the rows
are served, so buildWhere had no column to compare and dropped them. Clicking
one bucket's cell and scaling therefore wrote every bucket at that dimension
intersection, while the panel showed only the bucket clicked. On the example
slice that is 350,524.74 displayed against 503,446.08 written.

They resolve exactly, without a new column: the name lives on the log row and
every forecast row carries the pf_logid that points at it, so the predicate is
pf_logid IN (SELECT id FROM pf.log WHERE <the same expression> = ...). Verified
against version 29 -- the clause returns 350,524.74 over 12 rows.

Any other pf_ key is now refused rather than skipped, since skipping is the
mechanism by which a selection silently widens. pf_iter stays exempt: the
client drops it deliberately, two cells differing only by iter band being the
same slice.

Client side they are ordinary columns in the loaded table, so both the
dispatch path and the panel's own totals filter on them directly -- the latter
matters as much, or the ledger reconciles against a wider selection than the
operation writes.

The ledger: excluded rows read "02 - Prior Year · FINAL" in amber rather than
"reference · fixed" -- named by the segment a forecaster recognises instead of
the iter band that happens to exclude it, and coloured because immovable is a
property worth seeing before reading a number. When the whole selection is
immovable it now says so in a sentence, where before it printed a row of zeros
and left the reason to be inferred from the edit rows failing below.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 00:17:25 -04:00
507cae4e51 Lay the adjustment panel out on a grid, and count what a clone will copy
The clone form was four controls whose labels ran to different lengths --
"copy rows from", "scale cloned rows by", "tag", "note" -- each row starting
wherever its label happened to end, with a preview sentence floating after
them and a row of unexplained chips between the tag and the note. Nothing
separated where the rows come from, what happens to them, and what the
change is called.

Every row now shares a label column, so the controls line up, and the three
groups carry headers in the same 10px uppercase the ledger headers already
use. The tag suggestions sit inside the tag's own column, where they read as
values for it. Hints appear under the control they qualify and only when
relevant.

Also: the preview said "Copying 0.00 sales_usd" while five AOP rows worth
68,904 were selected. It reported the adjustable total, which is zero when
the selection is entirely prior year or plan -- the case clone exists for,
now that it can read reference rows. It counts them for clone, and still
does not for recode, which cannot touch them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 02:46:05 -04:00
cc3d83268f Show the clone date offset without requiring a segment
The offset field was rendered only when a segment had been named, from when
that was the only way clone could reach reference rows. Once clone could read
them directly the field became unreachable in the ordinary case -- so
"shift dates by" was invisible and the only typeable-looking control was
"copy rows from", which is a dropdown.

It is shown whenever clone is open now, and the payload carries the offset
independently of from_logid: shifting a selection through time is the common
case, and narrowing to one segment is the occasional one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 02:40:58 -04:00
13e49c14b6 Offer negative clone offsets, and validate the interval before using it
Shifting backwards already worked -- the offset is added as a Postgres
interval, and '-90 days' is a perfectly good one -- but the suggestions only
listed forward shifts, so nothing said so. Pulling a plan back a quarter is
a normal thing to want.

A day-level shift is also the case that most needs the dim_period
derivation: 15 Mar 2027 less 90 days is 15 Dec 2026, which crosses from
2027/10 - Mar into 2027/07 - Dec. Copying the period columns across would
have labelled it March.

The offset is interpolated into the statement, so a typo surfaced as a
Postgres parse error from the middle of a CTE. It is parsed on its own
first, where the failure is cheap and can name the field.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 02:11:04 -04:00
1904428fbb Say that a clone's selection is the source, not the destination
The slice filters the rows being copied, so cloning prior year forward means
selecting last December and shifting it, not selecting the December you want
to fill. Selecting the destination matches nothing -- prior year's rows are
labelled with prior year's periods, so a 2027 slice and a 2026 segment share
no rows -- and it fails silently, as zero rows cloned.

Nothing in the form said which way round it was, and "copy from <segment>"
reads like the selection is the target. It now says the selection is what
gets copied, and the segment narrows it rather than replacing it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 01:37:33 -04:00
518a0ca5ba Clone from a named segment, shifted, with period dimensions re-derived
Three changes, one feature: a period with no baseline borrows a shape from
prior year or plan.

The generator handled one date group. sql_generator.js used find(), so a
source with order, requested and ship date groups derived the order one and
copied the other two raw -- shifted dates against unshifted period labels.
Every group now gets its own join, and they are LEFT rather than inner: an
order that has not shipped has no ship date, and an inner join would have
dropped the row from the load entirely rather than leaving its period
columns empty. dateGroupsOf() is now shared, like grainOf, so the routes and
the generator agree on membership and on join aliases.

Clone carries every date column rather than only the primary one, since it
is the operation that moves rows through time, and re-derives the period
dimensions from pf.dim_period against the shifted date instead of copying
them from the row being cloned.

from_logid names the segment to copy from, replacing the exclude clause for
that one entry rather than widening it. Rows written stay pf_iter = 'clone',
so scale applies to them afterwards as a second step.

set is no longer required: copying a segment forward unchanged is a
reasonable thing to ask for.

Needs dim_period_col set in Setup (fisc_year on oseas/rseas/sseas_e,
fisc_month_abbr on omon/rmon/smon_e) and Generate SQL.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 01:21:29 -04:00
07d92ccd74 Keep master data for a dim_group instead of re-deriving it from the source
Every question about a part -- what values exist, what attributes go with
one -- was answered by querying the source, and the source is the wrong
place to ask. It is a view over a transaction table, so the query is slow
(76s for one ILIKE against 6.9M rows), it describes only what was
transacted, and it cannot express intent: there is no way to say a part is
discontinued, or to name one that has not sold yet.

pf.dim_member holds the app's own list: one row per key value per group,
siblings in jsonb, keyed on (source_id, dim_group, key_value). Refresh is a
merge rather than a replace, so curation survives it -- members absent from
the source are marked source_seen = false, not deleted. Triggered from
Setup, next to Generate SQL, because it reads the whole source and the
answer only changes when the catalogue does.

A key can carry several attribute sets across history -- 11,290 parts
against 13,662 combinations on osm_skinny -- so the refresh takes the most
recent by the source's date column. That also fixes the sibling autofill,
which used to run a DISTINCT ... LIMIT 2 against the source and silently
fill nothing whenever a part came back ambiguous. A member row is one
definition by construction.

The client fetches each group's list once per source and does both
completion and autofill against it in memory, so neither costs a request.
Columns outside a group, or a group never refreshed, still fall back to the
version's values endpoint.

Run 01_schema.sql to create the table.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 00:19:39 -04:00
812678bb7f Complete recode values from the version, not the source view
Pointing completion at the source made every keystroke a 76-second query:
gs.osm_skinny is a plain view over rlarp.osm_stack, so ILIKE '%1601%'
scanned 6.9M rows and read 1.3M buffers off disk, 64s of it I/O. Nothing had
called that endpoint before, so the cost only appeared once a debounced
input was wired to it.

The source is the wrong list anyway. It reaches back over all of history and
would offer parts discontinued years ago; the version holds what was
actually loaded, which is what the forecast is being written against.

So GET /versions/:id/values/:col reads the version's own forecast table --
2.0s for 11,290 parts on fc_osm_skinny_29 -- and holds the result in memory,
keyed on that version's latest pf.log id. Any load, adjustment or undo moves
the id and the next request rebuilds, so nothing has to remember to
invalidate. Filtering happens over the cached array, so typing costs one
small max(id) query.

The column name is interpolated into the DISTINCT, so it is checked against
col_meta first.

The source-side endpoint keeps its new q/limit but no longer has a caller;
its ILIKE path against a view is the expensive one and should stay unused.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 00:03:05 -04:00
b65da53360 Complete key dimension values as you type them in recode and clone
Recoding to a part meant typing a code from memory into a plain text box,
with the only feedback being that the sibling autofill either fired or
silently did nothing.

The values endpoint already existed but returned every distinct value with
no filter and no limit, which for part on osm_skinny is 11,290 rows -- too
slow to open and no easier to read than a short list. It now takes ?q= and
?limit=, so the field fetches matches for what has been typed so far,
debounced 200ms, and offers them through a native datalist.

Only key columns get it, which is the same condition the endpoint already
enforced, and the same one that decides whether the dim_group sibling
lookup runs on blur. So on osm_skinny it is part and customer: type 1601
and the eight parts containing it are offered; pick one and the nine
part-group columns fill themselves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 23:55:37 -04:00
e0e097d31a Whole units for the measures, five places for price
sales_usd and qty run to eight digits here, where two decimals are noise.
Price is the opposite case: it sits around 0.27, so two places barely show a
move and four still round away part of one.

derive() hardcoded two decimals, so the editable rows would have disagreed
with the lines above them. It now takes the measure's own precision. The
percentage row keeps one place, being its own scale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 23:25:25 -04:00
8726543e34 Show the implied price on every ledger line, not just the totals
Price was blanked on the bridge lines and on the excluded row, so a ledger
that had both measures on every line showed a price on two of them. Baseline
sat there as 2,242,180.67 over 8,111,717.10 with the column empty.

A bridge line's price is the implied price of that initiative's own
contribution -- Scale #116 above works out to 0.2397 against a current
0.2735 -- which is the number that says whether the initiative was a price
move or a volume move. That is the same question the plug toggle asks about
the edit, answered for the adjustments already made.

A line with no units is left blank, since its price is undefined rather
than zero -- the "price" bridge line in that example moved dollars alone.

The price column's hint said "holds units", which stopped being true when
plug arrived: it holds units under plug = price and moves them under plug =
volume. It now just says what the column is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 23:24:02 -04:00
de6064b4f2 Show where a scale edit lands, for all three measures at once
Each ledger row derived only from its own input, so typing a dollar figure
left the units and price rows blank -- which are precisely the two numbers
that say whether you just asked for a volume change or a price change. The
plug toggle decided it and then showed you nothing.

Adds a Result line under the edit rows carrying value, units and price
together, resolved the same way the server resolves the increments: a price
input multiplies through volume, a dollar input with plug = volume scales
units in proportion, anything else holds units. Measures that actually move
are picked out in blue against the ones that hold, so plug = price reads as
price moving alone and plug = volume as price standing still.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 23:21:53 -04:00
546af67639 Ask whether a dollar change is price or volume
A sales figure on its own does not say which of price or volume moved, and
scale was answering silently: it wrote the value increment and left units
alone, so volume stayed flat and price absorbed everything. That is one of
the three behaviours the Excel form offered, picked by default rather than
by the user.

/opt/forecast_api/VBA/fpvt.frm has the semantics. Edit Sales, then plug one
side:

    plug volume:  pchange = fVal/(pVal+bVal); fVol = (pVol+bVol)*pchange
    plug price:   fVol = pVol + bVol

So 'volume' moves units in the same proportion as the dollars, holding
price; 'price' leaves units alone, as now. Checked against those formulas:
+100 on 1000/500 gives units 500 -> 550 with price held at 2.0000, and
-400 on 2000/800 gives 800 -> 640 at 2.5000.

Price still defaults, so nothing changes for an existing caller that does
not send `plug`. The control only appears once the edit is dollars-only --
naming units or price has already settled the question. A price target now
also honours a units target alongside it, which is the form's Edit Price
mode where both are inputs and dollars fall out.

Holding price when the selection has no value is refused rather than
divided by zero, the same case the form guarded with "Zero times any number
is zero".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 23:11:05 -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