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>
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>
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>
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>
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>
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>
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>
"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>
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>
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>
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>
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>
`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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
Nobody asked for it. It was my own addition to the plans panel, guessing
that cloning a definition forward a year would be the common case; the
actual edit wanted was two dates moved by five days, typed by hand. A bulk
rewrite of filter values is a lot of machinery to leave sitting next to Run
all on the chance it is ever right.
Never used, so nothing to unwind: every plan on the cloned version still
carries the template's filters verbatim apart from the two dates edited in
the form.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PUT /versions/:id has taken name and description since it existed; only the
fallback-name fields ever called it, so the one thing a version's name could
not be was changed. A This forecast panel on the Baseline page now saves
both on blur.
Safe at any time, closed versions included: nothing derives from the name.
The forecast table is keyed on the id, as are pf.log, pf.layout and the
UI's localStorage.
Two edges the route left open. (source_id, name) is unique, so a rename can
collide -- it now answers 409 with the same sentence the POST has always
used, rather than a raw Postgres message that reads as a server fault. And
the UPDATE COALESCEs a falsy name back to the existing one, so a blank box
would have looked saved and changed nothing; that is refused where it is
typed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A segment only existed once it had been loaded -- a pf.log entry with
forecast rows behind it -- so there was nowhere to hold "this is the cut I
want, but the dates need changing first", and rebuilding a scenario meant
retyping every segment by hand.
pf.load_plan is the definition on its own, with run_logid naming the log
entry it produced once it has been run. Not in pf.log deliberately: every
row there means a write happened, and unexecuted intent would cost that
table its meaning. The FK is ON DELETE SET NULL, so undoing a load returns
its plan to unrun rather than taking the definition with it.
POST /sources/:id/versions now takes clone_from: settings, version-scoped
layouts, and the segments as plans, running none of them. Plans are read
from the template's own pf.load_plan rows, or derived from its
baseline/reference log entries when it has none -- every version built
before this -- since params already stores the filters and the offset.
Adjustments are not copied in any form. A scale's target was sized against
the rows that were there.
Shift years exists because cloning a definition forward a year is the
reason to clone one, and doing it by hand across eight segments is where
the mistakes come from. Filter values only, and only a bare 20xx: a month
reads "04 - Sep" and a part number that looks like a year must not move.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
LAYOUT / ROWS / COLUMNS were 10px text-gray-400: about 2.5:1 on white,
where WCAG wants 4.5:1 at that size. Their own class rather than a darker
grey utility, because in dark mode text-gray-400 and text-gray-500 both
resolve to --text-muted (#61656e, nearer 2:1 on the panel) -- swapping the
utility would have fixed light mode and left dark exactly as it was.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The toolbar was one row of buttons separated by identical hairlines, so a
column width and the version's change history read as peers. Split it: a
view zone on its own ground -- layout, the two depth controls, fit widths,
none of which reach the server -- and the forecast zone that re-loads,
opens a window, or writes.
The row depth buttons were a fixed 0 1 2 3. A number means nothing without
already knowing what group_by holds, and a fixed range offers levels a
two-field pivot does not have while hiding the fifth of one that does.
They are now built from the live group_by and named after the level they
reveal, which is what the column axis already did -- so both are drawn by
one DepthButtons component, since both are the same thing: a depth in
ViewConfig.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
cleanLayout's job is to let a layout outlive the columns it names, and it
walked every config field that carries a column name except aggregates --
which carries one as its key and, in the multi-arg form, a second as the
weight. Empty in practice today, so nothing was breaking; the first
explicit aggregate would have made a later column change abort the whole
restore rather than lose one entry.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Named layouts lived in localStorage: invisible to anyone else, gone on the
next machine, and nothing to publish from. The one server-side layout was
pf.source.default_layout -- a single anonymous blob any account could
overwrite for every account, which is a published layout with no owner.
pf.layout replaces both. A layout is named, owned, and either private or
published; published ones are listed by everyone on the forecast and
writable only by their owner or an admin, the same rule pf.log already uses
for its entries. Scope is the version, since that is the entry point, with
version_id NULL for the source-wide default a new version inherits.
Applying is never restricted -- Save is withheld on a layout that is not
yours, Save as forks it -- because the guarantee wanted is that a published
layout cannot be changed out from under people, not that it cannot be
adapted.
The toolbar's flat chip row becomes one Layout menu: Published and Mine,
rename/publish/default/delete shown only where they would be allowed, and
a dirty dot computed by comparing the live config against the one the pivot
was applied from, since restore() fires the change event itself.
Existing localStorage lists are lifted into pf.layout on first load.
PUT /sources/:id/default-layout is removed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Perspective's auto-pause is on by default, and pausing deletes the view:
AutoPauseState::apply() fires on the viewer's own IntersectionObserver and
on the document's visibilitychange, and set_pause(true) does
view_sub.take().delete(). Coming back is therefore not a redraw but
restore_and_render() -- a fresh view and a fresh traversal of the whole
grain -- which is the multi-second chug on every tab switch, and takes any
per-node expansion with it.
Nothing updates the table while the tab is hidden; every operation is
driven from this page. So the pause bought nothing and cost a rebuild.
Also drops a comment that still described the old set_depth re-apply on
refocus, whose machinery is long gone.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Refresh Data aborted with Table "fc_29" already exists. The name was fixed
per version and the cleanup before it is best-effort: the viewer is still
holding the previous table when the new one is built, so deleting it does
not free the registry entry, and creating a second under the same name
fails.
Nothing reads the name -- the viewer is loaded by reference, and cleanLayout
strips it out of saved configs -- so it only has to be unique. The previous
name is remembered and freed on the next load, when the viewer has let go of
it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Apply Scale sat there live and silent for the whole round trip, so on a large
slice the only sign anything was happening was the absence of a result -- and
a second click applied the change twice.
The button now spins, reads "Applying…", and refuses further clicks until the
write returns, with a line beside it saying the pivot updates when it
finishes. Cleared in a finally, so a failure releases it too.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SVG text does not wrap, so labels were cut at twelve characters -- which with
the sort prefixes now on every bucket meant "04 - Forecas…" and told you
nothing the position of the bar had not already.
Word wrapping to the bar's width, three lines at most, with the ×n and
untagged markers moved below however many lines the label took and the plot's
bottom padding raised to make room.
Character width is estimated rather than measured: measuring means a DOM
round trip per label on every render, and at this size a digit is about
0.55em, which is close enough for a centred label with a bar's width to play
with. A word longer than the line overflows rather than breaking, since half
a word helps nobody.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"Loads" is our word for a segment import. It means nothing to a salesperson
reading a waterfall, and the step is simply where the forecast began relative
to the comparison.
It also carried a stray "· untagged". That suffix marks an adjustment nobody
grouped into an initiative, and this is not an adjustment at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It started at the baseline, so it accumulated only the part that can still
move and stopped short of the number on the screen. Now it accumulates in
display order from the top -- billed, then booked, then the baseline, then
each adjustment -- and closes on Selected total.
Adjustable keeps its own figure but no running cell: it is a subtotal of the
walk, not a point on the line, and printing the running there would put two
different totals side by side in one row.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
It was the literal 'Forecast'. The moment the buckets were renamed to carry
their sort prefix -- '04 - Forecast' -- nothing matched: every row counted as
a comparison rather than a step, so the walk had no middle, the loads came to
nothing, and the bridge showed the basis cancelling itself exactly to zero
with a Forecast anchor of 0.00 over 0 rows.
The version's adjustment_bucket is the right source, being the same value an
unbucketed adjustment is labelled with, so the bridge and the pivot agree by
construction rather than by both hardcoding the same string.
If that value names no bucket in the data -- renamed since, or never
configured -- it falls back to whichever bucket actually holds the
adjustments. A bridge that is merely mislabelled beats one that is silently
empty.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
-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>
params says what was asked for. That is not enough to explain a surprising
result, because the same intent produces different rows depending on state
the entry does not carry, and because the translation from intent to SQL is
itself a place bugs live.
So both, not one. env records the state that cannot be reconstructed later:
the territory in force, the version's exclude_iters, and when the template
was generated -- all mutable rows elsewhere with nothing remembering what
they were. sql_text records the statement as executed, territory and scope
already resolved into it.
The template generation is a fingerprint rather than a version. It cannot
bring the old template back; it can tell you the entry did not run under the
current one, which is what would otherwise make a comparison quietly wrong.
Generate SQL has overwritten those templates four times today.
The statement is fetched on demand through GET /log/:logid/debug and left out
of the list, which is opened to scan rather than to read SQL. Under an
entry's payload in the change log there is now an "executed SQL" toggle.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PATCH /log/:logid had no check, so any account could edit the tag, note,
label and bucket of any entry -- including loads whose rows it cannot see.
That reads as harmless annotation and is not: label and bucket name the
pivot's columns for everyone in the version, so a rep could rename the
company's segments.
Same rule as undo now, author or admin, with the fields shown read-only
rather than editable-then-403 -- in the change log's tag and note cells and
on the Baseline page's label and bucket.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The change log showed what happened and never who did it, which stops being
a detail the moment more than one person is in the version.
The Undo button greys out on entries belonging to someone else, with the
reason on hover. The server already refused them; the button offered the
click anyway and answered with a 403, which reads as a fault rather than a
rule.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The login query names its columns, and territory and is_admin were not among
them -- so every session carried an empty list, every account scoped to
FALSE, and the forecast page came back with nothing however the grant was
set. The CLI had written it correctly; nothing read it.
The empty case then aborted twice over. First on the index, fixed already.
Then on the layout: an empty table has no schema, so restoring a saved
config asks for the dtype of a column that is not there and the worker dies
-- "Could not get dtype for column `sseas_e`". With no rows there is nothing
to lay out, so nothing is restored, and the saved layout waits in
localStorage for rows to come back.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
worker.table([], { index: 'pf_gkey' }) aborts: an empty array carries no
columns, so the index names one that does not exist and the page dies with
"Specified index `pf_gkey` does not exist in dataset" instead of saying it
found nothing.
Nothing is a legitimate answer -- an empty version, and now a territory with
no rows in it, which is what surfaced this. The empty table is built without
an index and the page says which of the two it is.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The flag was enforced everywhere and settable nowhere but psql, so the one
piece of configuration a second account depends on was invisible.
A radio rather than a checkbox, because exactly one column per source can be
the territory -- the control should say so rather than leaving it to an
error on save. Clicking the chosen one again clears it, which a radio has no
other way to express. The save still refuses two, since the UI is not the
only caller, and two would mean whichever a .find() reached first -- the trap
is_key already fell into.
Restricted to dimension columns: a territory is something rows are divided
by, and scoping on a date or a measure is not a thing to offer.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
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>