Commit Graph

261 Commits

Author SHA1 Message Date
ad291a67ea Merge branch 'op-panel-layout' into selection-follows-sort 2026-09-23 22:28:57 -04:00
91b88cd809 Name every section, and give the buttons some weight
- Button labels are capitalised and font-medium, the segmented groups
  carry a border and a shadow of their own. An inactive segment used to
  be white text-on-white inside a group border, which is why none of
  them read as something you could press.
- Each section says what it is: the operation strip, LEDGER (or RECODE
  TO / CLONE TO, which is what the form under those two operations is),
  SETTINGS over the toggles, LABEL THIS CHANGE.
- Together/Each moves down beside Price/Volume and Forecast/Total, in a
  wrapping row. Three one-line settings stacked cost three rows of a
  dock that is 260px tall to begin with, and they are read together
  anyway: what absorbs the change, what it is measured against, and
  whether it applies once or per slice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 22:28:57 -04:00
b4e7604dd1 Keep the selection on its rows when the sort moves them
A highlight is a rectangle of grid coordinates. Post a decrease on a
grid sorted by sales descending and the customer drops down the list,
while the highlight holds the coordinate and comes to rest over whoever
took that position. `slices` is value-based, so the panel goes on
writing to the customer it always meant -- the grid is the only thing
that lies, which is the worst place for the lie to be.

The rectangles are re-derived from the slices now rather than tracked:
after a post or an undo, sweep the grid the way a drag does
(slicesFromRegion, quietly) and keep the cells whose slice is still
selected. That also survives the column axis shifting, which it does on
almost every post -- a new segment column group moves every x by one.

- Bounded at 5,000 displayed rows. The sweep reads what the pivot shows,
  not the grain, so at any ordinary depth it is a handful; fully
  expanded it is the whole traversal and not worth a highlight.
- Past the bound, or on any failure, the highlight is cleared rather
  than left in place: losing it says "I don't know where it went", which
  is true, and leaving it says "it is here", which is not.
- A slice may now land on more cells than it did -- one that never named
  the segment column matches the customer under the old segment and
  under the adjustment's own. That is what the slice covers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 22:18:52 -04:00
4bce3bbd17 Open the request in a window, and name the endpoint
The preview was a 10rem box in the corner of a docked panel: enough to
see that there is JSON, not enough to read it. It opens as a modal with
the endpoint line and a copy button (with an execCommand fallback,
since navigator.clipboard is undefined on this app's plain-http origin).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 22:11:27 -04:00
065d1c2571 Post sits after the tag, not under the ledger
A footer across the panel put Post directly below the ledger in the
bottom dock, since the ledger is the tall column -- so you could read a
figure, hit Post and never pass the tag. An untagged adjustment is
invisible to the bridge and ungrouped in the change log, so the layout
has to route you through that field rather than leave it beside the
work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 22:08:44 -04:00
6af4902849 Drop the selection line; Post goes last
The summary printed back the cells you had just clicked in the grid --
count, total and filter, none of it news. It is a peek button now, and
the peek still carries the pivot filter and the duplicate-slice warning,
which are the parts that are not visible in the grid.

Post moves to a footer of its own, last in the flow: pick the operation,
read the ledger, name the change, post it. It sits on the same label
column as every other button, and sticks to the bottom of the dock --
a fixed-height dock with its own scrollbar would otherwise put the
primary action below the fold.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 22:05:58 -04:00
20ed8e7484 Line the panel's buttons up, and cut the prose around them
Every button group now reads `label  [ A | B ]` on one label column, so
nothing is staggered: change, apply, absorbs, target, per slice. The
strip and the ledger zone share the same left edge, so the alignment
holds across the two.

- Together/Each sits under the operation it qualifies rather than loose
  in the middle of the strip, where it read as a peer of the operation
- The apply-mode explanation is gone; the detail is on each option's
  tooltip. A sentence under a control is read once and skipped after,
  and this one cost the strip its height on every render
- The plug, target, per-slice and clone hints are a few words each

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 22:01:48 -04:00
d01dd2dbc8 Rebuild the operation panel around the ledger
The panel said what it was doing only on the button that did it. The
operation tabs sat unlabelled in the middle column, the slice list held
a third of the dock to repeat cells you had just clicked, and the
ledger -- the thing being read and edited -- got whatever width was
left over.

- One strip across the top: operation, apply mode, slice summary, Post
- The slice list is a peek; the summary keeps the two facts not visible
  elsewhere, the count and the pivot filter scoping the write
- The body is two grid tracks, ledger first, label second. Both used to
  carry flex-1, so the ledger's table overflowed its shrunken box and
  painted over the label column on a narrow window; the tables scroll
  inside their own zone now
- "Apply Scale" is "Post". It renamed itself whenever the operation
  changed, so the primary action never looked the same twice
- Target on: "Forecast" / "Total" rather than "Adjustable" / "Selected
  total" -- what a rep is choosing is whether the number is the forecast
  alone or the whole year including what has shipped
- The minor actions (clear, peek, unpin, request, collapse) were bare
  buttons in the surrounding grey; they share one text-button style now,
  and the segmented groups have rules between their segments

Also drops the bridge's "Whole version" scope.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 21:55:34 -04:00
6c26872443 Stamp a new adjustment with the version's own segment name
The loads read ADJUSTMENT_SEGMENT / ADJUSTMENT_BUCKET through VERSION_JOIN,
so pf.version's overrides applied; the rows pushed back incrementally from
scale, recode and clone stamped the built-in constants instead. An
adjustment therefore appeared under '99 - Adjustments' and moved to the
version's own name on the next refresh.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 09:38:29 -04:00
7d09ab2245 Document the nav, the admin gating, and what left the toolbar
Three sections were describing an app that no longer exists: the
project layout still called Sidebar a 3-step nav and StatusBar a source
and version picker, and the toolbar section still listed Fit widths.

Adds "Choosing a forecast" (the sidebar list, why it is not nested under
the Forecast step, why the source is absent and inferred, and the
forecast/step naming collision that is still undecided) and "Setup and
Baseline are admin-only to write" (which routes carry requireAdmin,
which deliberately do not and why, the fieldset approach, and the note
that a 403 used to blank the app because an error body reached .map
during render).

The toolbar section gains why Fit widths was removed rather than fixed,
and how the Calculating indicator is driven -- including that
perspective-config-update is the obvious hook and reports the end of
the work rather than the start.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 02:02:16 -04:00
3426f3525b Merge branch 'nav-forecast-list'
Forecasts are chosen from the sidebar rather than a status-bar dropdown,
the source is inferred from the one picked, and the Setup and Baseline
pages are admin-only to write and read-only to everyone else -- enforced
by requireAdmin on the routes, not just greyed out in the UI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 01:57:30 -04:00
915981b4a7 Put the forecasts in the nav, and lock the admin pages down
Nav. The source and version dropdowns sat in a status bar that is
otherwise read-only, so they did not read as the controls they were.
The forecasts are now listed in the sidebar above the three steps, each
row carrying its status and a star for the default. Not nested under
the Forecast button, which is where this started: Baseline works on the
same version, so hanging the list off one step would either
misrepresent the scope or duplicate it under two.

The source is gone from the nav entirely -- gs.osm_skinny means nothing
to someone editing a forecast. The sidebar lists every source's
versions and sets the source from the one picked, since a version
carries its source_id; `versions` stays scoped to the selected source,
because Baseline and Forecast are, so the sidebar gets its own list
rather than widening what versions means everywhere. The picker only
appears on Setup now, which is the one place the raw table name is the
right label.

The status bar keeps what follows FROM the selection -- the forecast's
name and its row count. The table it writes to moved into the hover
panel, still click-to-copy, since pasting it into psql is why it was
ever on screen.

Permissions. requireAdmin existed in lib/auth.js and was applied to
nothing, so all 26 non-admin accounts could call every Setup and
Baseline endpoint directly. It is now on source register/deregister,
col_meta writes, Generate SQL, dim refresh, version create/delete/
close/reopen, PUT /versions/:id, all of plans.js, and the baseline and
reference loads. Deliberately not on any read, nor on the forecast
operations, which carry their own owner and territory rules.

Both pages stay visible and go read-only, via one <fieldset disabled>
each rather than a flag threaded through forty-five controls, with a
banner saying why. The Tags panel on Baseline is excluded: its
permission is ownership, not admin, and a rep may rename the
initiatives they named themselves.

The table browser reads are open to everyone, because gating what a
page needs in order to render turned Setup into a 403 -- which it then
rendered as a crash, since the error body was passed to .map during
render and unmounting React blanks the app. Setup's list fetches now
coerce to an array, so an unexpected response costs an empty panel and
not the session. That fragility predated the gating.

Toolbar. The zone labels were the same visual weight as the buttons
they name, and the rules between groups were the same weight as the
zone borders, so nothing separated at the level it belonged to. Labels
are heavier and darker, group rules are stronger and taller, and the
forecast zone has a container of its own instead of floating.

Fit widths is removed, button and function. It measured the leaf header
over viewport rows only, so anything below the fold never counted; it
passed an option draw() does not have and raced draw()'s throttle,
leaving columns cleared but unmeasured at their floor widths; and the
persist that followed saved nothing, because saveColumnSizes returns
only overrides, which resetAutoSize had just emptied. Recoverable from
git if it is ever worth fixing rather than dropping.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 01:57:24 -04:00
b378ab7639 Merge branch 'pivot-busy-indicator'
Show a Calculating indicator in the app's status bar while the pivot
rebuilds, since Perspective's own beaker is only rendered while the
settings panel is open. Also make the light/dark toggle actually reach
the pivot: the theme is a ViewerConfig field, not an attribute, and
stored layouts were reasserting their author's theme on every restore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 01:18:32 -04:00
5b11222e09 Set the pivot theme through restore(), not setAttribute
The previous commit stopped stored layouts overriding the theme, which
was necessary and not sufficient: nothing was setting it either.
setAttribute('theme', ...) does nothing in 5.4.0 -- the theme is a
ViewerConfig field (ViewerConfig.theme, a theme name) and the element
observes no such attribute -- so the pivot stayed light whatever the
toggle said. Presumably it worked on an older Perspective; CLAUDE.md
documented it and is corrected here too.

A partial restore, so it does not rebuild the view the way applyDepth's
whole-config restore does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 01:18:26 -04:00
a651c58dc3 Show when the pivot is rebuilding, and stop layouts overriding the theme
Two unrelated changes, in one commit because both touch Forecast.jsx in
ways that cannot be staged apart without an interactive add.

Calculating indicator. Perspective shows a beaker while the view
rebuilds, but it lives in the viewer's status bar, which is only
rendered while the settings panel is open -- so the signal disappears
exactly when the panel is closed and you have nothing else to go on.
Forecast.jsx now raises a counter around the calls that *start* a
rebuild and lowers it after viewer.flush(), broadcasting `pf-pivot-busy`
on the window; StatusBar listens and shows a spinner, following the
`pf-data-changed` pattern already used between the two.

Deliberately not driven from `perspective-config-update`, which was the
obvious hook and is wrong: measured on a depth change over a large
grain, the event arrives *after* the rebuild -- seconds late -- and the
flush that follows resolves in 6ms. It reports the end of the work, not
the start. This therefore covers what the toolbar drives (depth on both
axes, applying a layout) and not sorting or filtering from the viewer's
own chrome, which the app is never told has begun.

Theme. viewer.save() includes `theme`, so every persisted layout had one
baked in, and cleanLayout passed it through -- each restore reasserted
the stored theme and silently undid the light/dark toggle. It is the
reader's choice, not a property of the layout, so it is dropped on the
way in; a published layout can no longer impose its author's theme
either. sameConfig ignores it too, or toggling would light the dirty dot
and offer to save a change nobody made.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 01:15:35 -04:00
8ab6dd91e0 Merge branch 'row-label-wall'
Floor the last row-label column so the deepest level's labels are not
squashed against the numbers when the tree is expanded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 00:47:13 -04:00
bd524aff8c Put a floor under the row-label wall
The last row-label column is where the deepest level's labels land once
the tree is opened, and nothing was sizing it, so expanding squashed
them against the numbers.

It is column group_by.length. The geometry is now written down, because
the old comment had it wrong and that error steered several failed
attempts: tree_header_offset is group_by.length + 1 and is where the
DATA starts, so the row labels are columns 0..group_by.length -- column
0 the indent gutter, 1..N the N levels. tree_header_offset - 1 is the
last label column, not a data column, and the hidden grand-total column
is the data one at tree_header_offset itself.

A constant rather than a measurement, because only the levels currently
on show have cells: at group_by_depth 1 the deeper label columns are not
in the DOM at all, so the wall is sized before there is anything to
measure. 218px was measured by hand on a rep/customer/month/part tree.

A floor, like the neighbouring 3-char one, so a wall dragged wider
survives the next layout apply.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 00:44:51 -04:00
3257ac2639 Put the row-label floor back to 3
The floor is the indentation: every row-header column sits at it, and a
level's inset is the previous column's width. Raising it to 12 to give
clipped labels room widened every level's indent by the same amount,
which is the opposite of the compact tree that was wanted. Making a
label readable means letting the text overflow its column, not growing
the column to hold it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 00:06:39 -04:00
818aa5e4fc Stop fitting row labels on restore; raise the floor instead
A level's indent is the previous column's width, so fitting a
row-header column to its content deepens the inset of everything below
it. Which column got fitted depended on how deep the tree happened to
be drawn when the layout loaded, so one level per layout came back
deeply inset while the rest stepped evenly -- deterministic, and
unreadable as a pattern from outside. fitRowLabels is unwired again and
carries a note saying why, so the next attempt starts from the reason
rather than the idea.

The squashed labels it was meant to fix are addressed by raising
ensureRowLabelWidth's floor from 3 characters to 12. A floor is safe
where a fit is not: it goes under every row-header column equally, so
the levels still step by a constant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 00:05:34 -04:00
ad5e61d4d2 Fit only the outermost row-label column
Fitting every row-header column widened the indentation bands too, and
since each group_by level is its own column, widening any but the last
moves the start of the one after it -- the deeper bands stepped right.
Only the last column, the wall before the data columns start, can grow
without disturbing the levels above it, so that is the only one resized
now. Every level is still measured, because which column is outermost
depends on the depth on show.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 23:48:50 -04:00
87ad5b1997 Fix the depth steppers' off-by-one, and fit row labels on restore
The stepper's depth and ViewConfig's group_by_depth/split_by_depth are
the same unit -- a count of levels to show -- but applyDepth and
applySplitDepth sent d + 1 and every readback subtracted the same 1.
Self-consistent, so the toolbar always agreed with itself, and off by
one against the grid at every position: picking partgroup showed one
level past it. server.cpp turns group_by_depth into set_depth(n - 1)
and t_traversal::set_depth adds the 1 back, so n asks for n levels.

The +1 existed to dodge depth 0, which reaches the engine as
set_depth(-1) on an unsigned t_depth, underflows, and clamps to fully
expanded. The value is floored at 1 instead, so the stepper's "Total"
slot goes on showing the first level as it always has -- the wart is
kept deliberately rather than traded for the underflow. A bare total
needs group_rollup_mode.

Separately: fitRowLabels() has been written and unused since it was
judged to read worse than the default spacing. That judgement has
changed -- row labels come back clipped after a restore -- so it is now
called after both restore paths, initViewer and applyLayout. It is not
routed through the Fit widths button on purpose: that path goes via
regular-table's auto-size, which measures the leaf header's rendered
box over viewport rows only, while fitRowLabels canvas-measures the
label text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 23:44:07 -04:00
cf8b72d737 Default the change log to Mine instead of Everyone
Opening the log is almost always to check your own recent work, not
the whole version's, so it now starts on 'mine' -- still resets on
every open rather than persisting, same reasoning as before, just the
other default.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 23:09:08 -04:00
bf791cbc14 Merge branch 'fix-bridge-selection-scope'
Fixes Bridge's ending point not matching the pivot's current selection
(missing view-level scope, over-narrow bucket filtering) and brings its
untagged-adjustment grouping in line with the ledger's.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 23:03:16 -04:00
6018001f4e Collapse untagged adjustments into one bridge step
buildSteps keyed every untagged log entry by its own pf_logid, so a
version with two dozen routine untagged scale/recode passes drew two
dozen separate bars -- one per operation, none of them named. The
ledger in OperationPanel.jsx already collapses these into a single
"N untagged adjustments" line; Bridge now does the same, and groups
tagged entries case-insensitively too, carrying the most-used spelling
as the label, matching the ledger's own grouping exactly.

The old per-row "untagged"/"×N" captions are dropped where they'd now
just repeat what the collapsed label already says.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 22:55:49 -04:00
c5993d5f88 Bridge selection: apply the pivot's scope, drop bucket from the filter
Two corrections to the previous commit, both found from a real example:
a slice with pending_rep + pf_bucket, and a pivot scoped to sseas_e <= 2027.

The ledger and Bridge's ending point disagreed by a constant 1,495,062
across every step -- exactly the rows sseas_e <= 2027 excludes. Bridge's
"Selection" scope built filters from the slice alone and never picked up
the pivot's own view-level filter (what CLAUDE.md calls scope), the same
gap totalsFor in Forecast.jsx was written to close for the ledger. It's
now read off viewerRef.current.save() and applied alongside every
per-slice view.

Including pf_bucket in the per-slice filter (previous commit) was wrong
for Bridge specifically, unlike for the ledger: it fetched only the one
bucket the slice landed on, so "Compare to" had no other bucket's rows
left to compare against and always collapsed to one option. Which
bucket the walk starts and ends on is buildSteps's job, from
forecastBucket -- not something to pre-filter out of the fetch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 22:50:09 -04:00
bc5ce496b7 Fix Bridge's ending point to reflect the selected slice
BridgeView mounts once, up front, before anything has ever been
selected on the pivot -- so its scope defaulted to "Pivot's filters"
(effectively the whole version) regardless of a selection made
afterward. Scope now tracks the live selection until the user picks a
scope button themselves.

Even when scope was "Selection", the filter it built dropped
pf_segment/pf_bucket-keyed slice entries entirely and matched a blank
dimension value with `== null`, which this engine treats as a dropped
predicate rather than IS NULL -- both widen the bridged rows past what
was actually selected, the same failure mode the ledger's totalsFor in
Forecast.jsx was hardened against. Bridge's selection filter now
mirrors that logic.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 22:34:44 -04:00
748e301464 Steer /agg off the sort-based GroupAggregate plan
pf_logid arrives pre-sorted off its index, so the planner picks an
Incremental Sort GroupAggregate on the strength of that "free" sort —
but comparing 30+ grouping columns per row makes it 5-7x slower in
practice than a HashAggregate. Confirmed via EXPLAIN ANALYZE on
fc_osm_skinny_34: 34-50s sorted vs ~7-10s hashed, independent of
work_mem or stats (ANALYZE didn't change the plan choice).

Scoped to this transaction only, since disabling incremental sort
globally could hurt some other query's plan.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 22:32:24 -04:00
8bc7d65fce Say which change-log entries are yours
The version this was built against carries 127 adjustments from five accounts,
one of them the reader's, so finding your own meant reading the By column down
the whole list.

Own entries are now marked in place -- a blue rule down the left of the row, the
By cell in blue -- and the modal header carries an Everyone / Mine control with
a count on each. Marked as well as filterable on purpose: the mark tells you
without hiding anyone, the filter is for when you want only yours.

The filter resets to Everyone on every open and is not persisted. A filter that
hides other people's work is fine as a thing you asked for and a trap as a thing
you inherited from last week.

`isMine()` is strict authorship, deliberately not `canUndo()` -- an admin can
undo everything, so marking every row as theirs would say nothing. The fetch
stays unfiltered, since the counts on the control need the whole set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 14:30:03 -04:00
7fd07e52d2 Hold price unless somebody names it
Two defaults both moved price when nobody asked:

A qty edit held the dollars, so price absorbed the whole difference -- 10% more
cases at the same revenue is a 9% price cut, and nothing in the figure being
edited shows it. Dollars now follow qty at the current price:
`value = (curValue / curUnits) * unitsIncr`, on the server in resolveIncrs() and
mirrored in the ledger's Result line. A slice with no units has no price to
hold, so there the dollars stay put and the edit is a pure qty change.

`plug` defaulted to 'price' -- volume holds, price moves -- because that is what
the API did before the flag existed. It now defaults to 'volume'. The client's
stored preference is keyed pf_scale_plug_v2, since a browser carrying the old
'price' choice would otherwise go on moving price silently.

The deliberate routes are unchanged and both outrank the above: name the dollars
and the qty together and price falls out of the two, or name a target_price,
which is the Excel form's Edit Price mode. The ledger labels the column that
cannot move `· holds`, which now includes price on a qty edit.

One behaviour to know about: holding price is impossible on a slice whose value
is zero, so a dollar edit there answers 400 with an explanation where before it
quietly moved price. Fail-loud is the point.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 13:43:06 -04:00
9e880a65be Keep the price/volume choice on screen when it isn't the one choosing
"Absorbed by" was rendered only while the edit was dollars-only, which made it
absent in two quite different states: before anything had been typed, and after
a measure had been pinned. Pinning includes a *click* -- clicking a cell pins
whatever it reads -- so an accidental click in the qty column had no visible
effect except that the control disappeared, and the way back was a small x in a
table header.

It now stays put and greys out, still showing which measure absorbs the change,
because the pinned inputs answer the same question the toggle does:

    qty pinned   -> price is what moves
    price pinned -> qty is what moves

so the inert control reads as the truth rather than a stale choice. The hint
says which case it is in, and a pinned measure gets an "unpin" next to the
toggle rather than only the x in the header.

Nothing about what gets written changes: `plug` is still sent only when the edit
is dollars-only, which is still the only case where price and volume are both
unknown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 13:36:44 -04:00
7006014cc3 Isolate a slice on a blank dimension value instead of widening it
Clicking the blank `partgroup` node under ALTMAN PLANTS selected the customer's
whole $27,588,393.62 (6,851 rows) rather than the $6,198,860.42 (2,421) under
the node clicked -- and the ledger agreed, because the panel totals through the
same filter list the operation writes from. The key was not narrowed, it was
absent, and an absent key means every value of it.

The drop is the engine's: `getCellConfig` builds the click's filter list with
`pivot_value ? [pivot, '==', pivot_value] : undefined`, so a blank group key is
gone before `perspective-click` is dispatched -- and being falsy, so is a
dimension whose value is 0. `cellFilters()` re-derives both axes from
`detail.row.__ROW_PATH__` and the clicked column name instead; the click and a
dragged region now share that one derivation rather than keeping two copies of
the same falsy test.

Both blanks are real here and they are different rows: `partgroup` is null on
51,964 and '' on 111,696. So they stay distinct end to end. `buildWhere` renders
a null as IS NULL, where it used to fall through `esc()` to `= ''` and select
*the other blank* -- the slice naming one node while the operation wrote the
other. In a list the null arm is ORed on, since `IN (NULL)` is never true. The
ledger filters with `is null` rather than `== null`, the engine matching on type
and a null threshold against a string column being the same dropped predicate.

Only `undefined` now means absent, which keeps a subtotal row (a shorter row
path) and a collapsed column level reading as "not part of the slice".

A blank on the *column* axis still cannot be isolated: a column name is its
values joined with '|' and a null joins as the empty string, so the two blanks
are the same text by the time it reaches the client. Those cells are refused
with a message rather than widened.

The panel prints (null) and (blank), and keeps em dash for a column genuinely
absent from the slice -- isolating the blank node and ignoring the column are
opposite statements and read identically before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 12:21:06 -04:00
766ccc52bb Scope an operation by a substring filter, case-insensitively
A view narrowed with `partd contains 'Keysto'` could be totalled but not
written: `SCOPE_OPS` carried the comparison and list operators and refused
everything else, so the panel showed a figure the operation answered 400 on.
Refusing is the right default -- a scope silently dropped is a write wider than
the panel that authorised it -- but `contains` is how a text dimension is
actually filtered, the alternative being an IN list of the 180 descriptions
that happen to match today.

ILIKE, not LIKE, because that is what the engine does: `t_tscalar::contains`
lowercases both sides before `std::string::find`, as do `begins_with` and
`ends_with`. On `fc_osm_skinny_34` the difference is 16,864 rows against 35, so
LIKE would have written a fortieth of what the grid displayed. Narrower is no
safer than wider here -- either way it is not the set the ledger counted.

The term is literal in the engine, so `%`, `_` and `\` are escaped out of it
before it becomes a pattern; `50%` means those three characters.

`matches` stays refused. It is RE2 in the engine and Postgres regexes are not
RE2 -- `\b` is a word boundary in one and a backspace in the other -- so a
pattern both accept could match a different set of rows, which is worse than a
400.

Every operator that takes a term now refuses an empty one. An unfinished filter
row in the pivot arrives as `[col, '==', null]`, which used to compile to
`= ''`; as a substring op it would have been `ILIKE '%%'`, which is every
non-null row and therefore no scope at all.

Runtime code, not template: nothing to regenerate, only a restart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 11:53:18 -04:00
99d7fe72d8 Manage a version's tags as a list, not one entry at a time
A tag was only ever a value typed on a pf.log entry, so the only way to
change one was to find every entry carrying it in the change log and edit
each. Nothing showed what the list was, and the bridge groups on this
column -- so "Pull Forward" and "Pulled Forward" read as two initiatives
with no way to make them one.

PATCH /versions/:id/tags rewrites every entry carrying `from`. A `to` that
already exists merges the two, an empty one clears the tag; the panel
confirms both, since neither is visible as a diff afterwards.

Permission is the pf.log rule applied to every entry the tag is on rather
than most of them: a half-applied rename leaves the initiative split, which
is what the rename was for. GET /versions/:id/tags now returns owners and
can_edit so the panel can grey the row out instead of offering a click that
answers 403.

On the Baseline page, next to the other version-level settings -- the
Forecast page edits one entry's tag, this edits the column.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 09:00:27 -04:00
91c53ac6d8 Land on the forecast somebody chose, not the newest one
App.jsx fell back to versions[0] when localStorage held no selection, and
the version list is ordered created_at DESC -- so a fresh browser landed on
whatever version was created last. That is a scratch scenario as readily as
the plan, and sales accounts, which never pick a version themselves, had no
way back to the one they were meant to read.

pf.version.is_default names the landing version, at most one per source by
partial unique index, the way pf.layout.is_default already works. Setting it
is admin-only and enforced in the route: it decides where everyone else
lands, so it follows the pf.log rule rather than being the version's own
setting like its name.

A stored selection still wins -- the default is the fallback, not an
override, so nobody building scenarios is thrown back to the plan on every
load.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 08:34:46 -04:00
41e76978f4 Let a blank tree label take the click as well as draw the caret
The class fix rendered the `+` but the node still would not open, and the
caret did not light on hover. `tbody th:empty { pointer-events: none }`
makes the padding cells left of a tree label inert, and a label cell whose
group key is null or "" is `:empty` too — its caret is a `::before`, which
does not count as content — so the cell received no pointer events at all.

Exclude the tree classes from that rule. `row-hover.css` reads `:empty` the
same way in six border selectors; those are a hover tint, left alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 00:19:15 -04:00
a95d663076 Let a tree node with a blank group key expand
`partgroup` is null on 93k rows of `gs.osm_skinny` and empty on another
111k, and the pivot showed those groups with no `+`: no way to drill into
them at all, while every named sibling opened.

The cause is upstream, in the datagrid's `cell_style_row_header`. A tree
row's header is the label cell padded on its left by one blank cell per
level above it, and it told padding from label by testing the cell's own
text — so a node whose group key is blank read as padding, got no
`psp-tree-label`, and therefore no caret. The click handler gates on the
same class, so the cell was not clickable either. The node was in the
traversal and its children existed; only the affordance was missing.

Tell them apart structurally instead: the label is the last defined entry
in `row_header`, which is how `is_collapse` two lines below already reads
the following row. Flat rollup has no padding to distinguish and keeps the
text test.

Rebuilt the vendored packages from fork branch `expand-blank-tree-nodes`,
which stacks this on the depth patch already carried. Note that
`npm install` on its own will not pick a rebuilt tarball up — the version
in the filename never changes and the lock pins an integrity hash, so npm
serves the old one from its cache and nothing reports a problem. The
README and the rebuild script now say to re-resolve by path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 00:08:56 -04:00
aa8ce87560 Offer the tag that already exists
Wording variants are a user problem, but the completion was making it one:
a native datalist substring-matches, so typing "moved to different sku"
never offered "moved to a different sku" -- one inserted word and the
existing tag is invisible. Four spellings of that initiative are on the
current version, with a ledger line each.

TagInput matches on words instead. Both sides lowercased, stripped of
punctuation, relieved of stopwords and lightly stemmed, so Pulled Forward
finds Pull Forward. Typing something that reduces to an existing tag
without being it says so: already used as X, use that.

Nothing is merged automatically. The suggestion is offered and the typing
stays yours -- moved to another sku is genuinely a different sentence from
moved to a different sku, and only the person typing knows whether it is a
different thing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 23:43:25 -04:00
318a09b6f2 Stop the ledger printing a row per log entry
A slice as broad as a whole bucket reached every entry on the version, and
the walk stopped being a walk -- roughly 44 lines on the version this was
built against, where there are about a dozen things to say.

Three causes, three fixes.

Untagged adjustments had a line each, labelled "Scale #226" -- a row spent
to say nothing, 28 of them. They collapse to one line that counts them.

Tags grouped by exact text, so "Keystone reduction" and "Keystone
Reduction" reported as two initiatives, as did four spellings of moved to a
different sku. Grouping is case-insensitive now and the line shows whichever
spelling was used most. The tags themselves are untouched; wording variants
are still separate lines, and that is for whoever typed them.

What is left is capped: the loads stay whole, since they are the shape of
the forecast, and the adjustments keep the six largest with the rest rolled
into one line carrying its own subtotal. Still arithmetically the walk --
addition does not care about the order -- and the rollup expands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 23:41:25 -04:00
4020a8b78e Recount the ledger after an undo, and after every operation
The effect that recounts the selection is keyed on `slices`, and neither an
undo nor an operation changes the selection -- so the panel went on showing
totals that included rows it had just removed, or excluded rows it had just
written. Only scale refreshed, and only for itself.

Undo now re-reads the log as well, since the walk lines come from there: an
undone entry has to leave the ledger and not just the table. It runs after
the remove resolves, because fetchCurrentTotals counts through the table's
own views and would otherwise count what was being deleted. It reads
slicesRef rather than slices, being inside a handler registered once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 23:37:30 -04:00
b1d6e5e627 Merge branch 'clone-a-version-definition'
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 23:15:31 -04:00
fea8d0ac14 Let a measure be handed back to the others
Clicking a held box pins it, and the only way out was to know that
emptying the field unpins it -- filled() is false on a blank either way.
Clicking into a greyed column to see what is in it and then wanting out
again is the ordinary case, not the awkward one.

An x on the column header clears that measure's edit, shown only while it
has one, and Escape in the box does the same and steps out. The panel's own
Escape handler already ignores events from inputs, so the two do not
collide.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 23:08:01 -04:00
46a812dd74 Grey the measure the plug holds
Absorbed by asks which of price and volume moves, and then said so only in
a sentence underneath itself. The column it holds still now says it: the
header reads "holds" in place of its hint and the three boxes go flat grey,
so what you are editing is legible from the numbers rather than from prose
about them.

The sentence goes, since the greyed column is the same statement made where
it applies. The toggle stays -- it is an input, and with dollars alone
nothing in the data answers it.

A held box is still typeable, and doing so is how you override the toggle:
naming qty or price answers the question directly, which is why the control
only appears while dollars are the sole input.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 23:05:54 -04:00
042c85d6c3 Back-fill the measures an edit forces
Scaling dollars with price holding moves qty, but the qty column stayed
blank until the Result row -- so the number you had just implied was the
one thing not shown beside the number you typed. A measure with no edit of
its own now reads all three ways from where the edit actually lands it.

derived stays the edit-driven map, since it is what outcome is computed
from and back-filling into it would feed the result into its own inputs; a
separate shown map is what the rows render.

Clicking into a back-filled figure pins it, which is the difference between
"units may move" and "units are this". The write is the same either way
when the pinned number is the implied one, so the cost of pinning by
accident is nothing.

Reverts the muted styling for the derived rows -- all three boxes look like
boxes again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 22:53:03 -04:00
1035dae76b Draw the derived ledger rows as output, not input
One box per measure is live; the other two are the same number said
differently. Both states were full input boxes differing by a border
colour, so three typed figures appeared where there was one typed figure
and two calculations -- and with six boxes on screen there was no reading
which one the adjustment would be built from.

The live box keeps its blue border and ground and gains weight; the derived
two lose their border and background and go muted. They are still editable,
which is what the border on hover says, and the title spells out that
clicking enters the number that way instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 22:50:34 -04:00
75f662d12a Stop reading numbers back out of formatted text
The Result row re-parsed the ledger's own display strings, which fmtNum
grouped with toLocaleString. parseFloat stops at the first separator, so
"3,456,789" came back as 3 -- a plausible small number rather than an
error, which is why it read as a formatting quirk instead of a wrong
answer. A 3% change on sales_usd previewed as 3.

It showed only where the new value was derived rather than typed: the field
you type into keeps your raw text, so entering a target directly looked
right while the % and Change rows did not.

derive now returns the three figures as numbers alongside the strings, and
outcome reads those. Nothing has to know how the display is grouped.

The write was never affected -- buildPayload reads scaleInputs[*].raw,
which LedgerInput has always kept comma-free -- so this was a wrong
preview of a correct write.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 22:27:31 -04:00
a642c65d1d Line the percent box up with the other two
The % was a sibling of the input in a flex row, so it took width of its
own. In a right-aligned cell that pushed the percent row's box a suffix and
a gap to the left of New value and Change -- three ways of saying the same
thing, not lining up.

It now sits inside the box, over the input's right padding, so every row is
the same width against the same edge, and that edge is where the numbers
above it end too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 22:20:49 -04:00
6c24aa8390 Make the ledger's settings read as settings
Absorbed by and Target applies to each hand-rolled their own label row and
their own hint, at two different type sizes, with no shared label width and
no rule separating them from the numbers above. Stacked, they read as a
paragraph of loose words -- label, two buttons, a sentence, label, two
buttons, a longer sentence -- rather than as two settings on the edit.

Both now use Field, like every other setting in this panel: one aligned
label column, the control, the hint below it in the same muted 11px. Per
slice joins them instead of trailing off as a bare paragraph. Target
applies to becomes Target on, which fits the label column without wrapping.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 22:15:33 -04:00
b015b0c09b Give the tag field back its space
The six quick-pick pills under it wrapped onto two rows as soon as the tags
were longer than a word, which most are, and the operations panel docked at
the bottom has a fixed height to spend -- so the form ran past its own
border. The same tags complete from the input's datalist, which costs no
space and is not capped at six.

The datalist now carries the tag alone. An option's text content renders as
a second column beside the value, so the use count was widening every row
of the list to show a number nobody picks on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 22:05:14 -04:00
2fdf113636 Step through pivot depth instead of listing it
One button per level grows with the pivot: the saved layouts here run 3-5
group_by and 2-3 split_by, so "Customer by Program" rendered ten depth
buttons, and with the layout menu, Save and Fit that is thirteen controls
in the view zone. The active level was a blue button among the greys, so
reading the current state meant scanning for it.

A stepper is three controls whatever the depth and names where you are.
The full list is still there behind the middle button, for jumping rather
than stepping.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 21:54:02 -04:00
05631817ae Scope tag completions to the version
The list came from every version of the source, joined through pf.version,
so adjusting this year's plan offered last year's initiatives and counted
uses earned somewhere else. A tag names an initiative inside a scenario.

A new version now completes from nothing until it has a tag of its own,
which is what it actually has: cloning a definition copies no adjustments.
The bucket datalist on the Baseline page was already per version, so the
two agree now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 15:03:41 -04:00