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>
Perspective pads a subtotal's column path to full length rather than
shortening it -- ['04 - Forecast', '', 'sales_usd'] -- so testing for
an empty string found no subtotals and nothing was tinted. The blank test now
strips zero-width spaces and the other invisibles alongside whitespace.
The grand total falls out of the same rule, its path being blank at every
level above the measure.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Prior, plan and forecast each carry twelve months and a total, so scanning
across is thirty-odd columns of identical-looking numbers with nothing to say
where one domain ends and the next begins -- annual figures read as just
another month.
Each group's first column now takes a left rule and each group's subtotal a
tint and a heavier weight. Both are derived from the cell's column path: the
deepest path is a leaf, so anything shorter is an aggregate of the levels
below it, which is what makes a subtotal a subtotal.
Through regular_table's style listener rather than CSS, because the DOM cells
are recycled across columns as you scroll -- a stylesheet would paint the
wrong ones the moment the grid virtualised. The styles go into the grid's own
shadow root, since a page stylesheet cannot reach it, and use currentColor so
the rule follows the theme instead of disappearing against Pro Dark.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The panel's preview calls buildPayload itself, and the scope arrived as a
parameter that the preview had no way to supply -- so it defaulted to empty
and printed a payload with no scope for a write that had one. A preview that
disagrees with what is sent is worse than no preview: it is the one place
someone looks to check before committing a change.
buildPayload reads the scope from the ref instead, so there is one payload
and both callers get it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
X-Row-Count is exact but travels with the response headers, and in grain mode
the server aggregates the whole table before sending any -- so the number
appeared just as the fifteen-second wait ended, which is no use to anyone
watching it.
The forecast table's own count goes up first instead, from the same
table-info the status bar already reads, and the exact figure replaces it
when the headers arrive. The count query is deliberately not awaited: it
scans the whole table, and the load must not wait on a progress message.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
perspective-click reports only the clicked cell's own dimensions -- the
view-level filter is not in it -- so a slice never carried the season the
grid was scoped to. The ledger therefore counted rows the grid was hiding
(921,225.71 on screen against 956,485.13 in the panel) and an operation
would have written them.
Both now read the filter off the viewer. The ledger applies it to its own
view, where the values are already in the table's types and any operator
works. The operation merges the equalities into each slice, cell values
winning on a shared column since a cell cannot contradict the filter it was
drawn inside, and refuses outright on any other operator: a range or an
in-list cannot travel in a slice, and dropping it silently is the widening
this is meant to stop.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
X-Row-Count arrives with the headers, long before the body has been read, so
the overlay can name the wait instead of saying "Loading…" over a grey
screen for fifteen seconds. On this data the row count *is* the wait -- the
bytes are quick and the rows are not -- so it is the number worth showing
beside the transfer bar, which only ever measured the fast part.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A slice carries every value as a string -- built from filters the grid
reports, and shaped to survive JSON on the way to the API. Perspective
matches on type, and a string '2027' against an integer column is not a
filter that matches nothing, it is a filter that is dropped.
So the pivot's own season filter never reached the ledger: with the grid
scoped to sseas_e = 2027 the ledger totalled 956,485.13 against a cell
reading 921,225.71, the difference being eleven rows of a baseline segment
whose shipments fall in the next season. Only dates were being coerced, and
only because someone had hit this before with them.
Values are now typed against the loaded table's schema rather than against
col_meta's role, which is the thing that actually decides the match. The
server side was already right -- Postgres casts the literal -- and returns
921,225.71 for the same slice.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Clicking a bucket subtotal with the month level collapsed produced
{"customer": "...", "pf_bucket": "04 - Forecast", "smon_e": "sales_usd"} --
a month equal to a measure, matching nothing, so the ledger came back empty
and an operation would have had no rows to act on.
Perspective maps split_by positionally over the column name, and a collapsed
axis has fewer segments than there are split_by levels, so the measure lands
on the first hidden dimension. Both slice paths now drop any == filter whose
value is one of the view's measures; the region path additionally takes the
measure off the end of the column name before mapping, which is the same
error made in our own code.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
adjustment_segment, adjustment_bucket and unlabeled_load are columns on
pf.version now, edited under "Fallback names" on the Baseline page, with the
constants in sql_generator left as the built-in for a version that sets none.
Read through a join, not substituted at generation: pf.sql is keyed on
(source_id, operation) and shared by every version of a source, so a baked-in
value could not vary by version and regenerating for one would change the
others. The join costs three more GROUP BY columns on /agg, all functionally
dependent on a version id that is already fixed for the whole query.
The built-ins stay a convention guess -- ADJUSTMENT_BUCKET's "04 - " suits one
numbering -- which is now a default to override rather than the only answer.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Description was the original field and Note replaced it, but nothing removed
it -- so the edit form carried both, writing the same pf.log.note, with
`note: description || segNote` letting Description win silently over whatever
was typed in Note right below it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Measuring the columns was not enough while the table itself was w-full inside
an uncapped page: it stretched to the window and handed the slack back out,
so the measurements only decided who got squeezed. The table now sizes to its
content and the page uses items-start, so each block is as wide as it needs.
Ceilings pulled in a little too -- with nothing competing for slack they are
the actual width, not a limit on a fight.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The page was capped at max-w-4xl. Eleven columns in 896px meant something was
always smashed, and the previous fix just moved which one -- w-full on the note
cell let it claim the slack, and w-48 on label is only a hint in an auto-layout
table, so the browser shrank the label input to min-content. The cap is gone;
the blocks that read better narrow keep their own.
label, note and counts-toward are now measured off the longest value in the
log, in ch, with floors so an empty table keeps its headers and ceilings so one
long note cannot push the numbers off the side.
The note's one-line clip moved onto its inner div: a max-width on a table cell
is only a hint too, so pinning it to the cell could collapse the column to
min-content or let it grow past the measurement.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It was the only one of eleven columns with no width, so it lived on whatever
slack was left once kind, label and counts-toward took theirs -- and it holds
the longest text of any of them. Before label and bucket existed it also held
the operation badge and the segment name, and had the room for them.
Now one line with an ellipsis, the full text on hover, and unclipped in the
expand panel underneath, which already renders it. w-full with max-w-0 is
what lets a cell in an auto-layout table absorb the slack and still clip;
with only w-full the column grows to fit and nothing truncates.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
bucket_order and log.seq were still settable -- PUT /versions/:id took the
first, PATCH /log/:logid the second -- with nothing left to read either. A
field that only ever gets written is worse than a missing one: the call
succeeds, so the caller has no way to find out it did nothing.
The columns themselves stay, marked vestigial where they are declared.
Dropping a column is not worth a migration to reclaim two that cost nothing.
pf.log.bucket is untouched and stays exactly as it was -- what a row counts
toward, read first by BUCKET_EXPR. It is the bucket *order* that no longer
needs storing, the text having become the order.
Also removes a comment head in Forecast.jsx that survived its function and
had glued itself onto fitColumns.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
bucket_order fed the ordering expressions and nothing else, so its up/down
list is gone. The datalist it shared state with stays, now offering the
buckets actually in use plus the four conventional names carried with their
prefixes -- a near-miss spelling silently splits a column in two, so the
options are worth more than they were.
saveBucket was a duplicate of saveLogField left behind by af9e6de's refactor;
the bucket cell goes through saveLogField like the label does. Both
confirmations now say to reload the Forecast view, which is true of a label
for the same reason it was true of a bucket: it is part of the aggregated row.
pf.log.seq and pf.version.bucket_order are no longer read anywhere. The
columns stay -- dropping them is not worth the migration, and nothing costs
anything by their being there.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
With the prefix stored in pf.log.label there is nothing left to compute, so
all of it goes: ORDER_EXPR_NAMES, buildOrderExpression and its ExprTK
printable-ASCII-per-byte guard, SYNTHETIC_SEGMENTS and its 98/99 ordinals,
syncOrderExpressions with its two self-issued fetches, the dbgOrder tracing,
and the three places it had to be re-applied because restore() replaces
`expressions` wholesale.
What remains is a list of the names it used to manage, stripped by
cleanLayout so a layout saved under the old scheme does not keep ordering by
a rule nothing updates. The strip goes before the axis filter: dropping them
from `expressions` is what makes the existing ok() reject them on every axis,
which restore() requires -- an expression the pivot is using cannot vanish
from underneath it.
Net 250 lines out. The Baseline page's reorder buttons no longer feed
anything and go next.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Perspective orders column groups by the value string, so "01 - Actual" is
the only way an arbitrary order can be expressed. That prefix now lives in
pf.log.label, typed by whoever names the segment, rather than being built
from a seq column by client-side expressions.
pf_segment and pf_bucket read label first, and the expressions are shared
between /agg and /data instead of being spelled out in each -- they have to
agree, and they had drifted apart in whitespace already.
The synthetic values lose their parentheses and their ordinals, except the
adjustment fallback: '(adjustment)' sorted *before* '01 - ...', since '(' is
0x28 and digits begin at 0x30, so it becomes '99 - Adjustments' to sit last.
Labelling an adjustment's own log row overrides that, which is how one kind
of adjustment splits out from the rest. '(unlabeled load)' becomes plain
'Unlabeled', which needs no ordinal -- letters already follow digits.
The load routes carry label and bucket onto the log row, so the fields the
segment form has been offering since af9e6de are no longer a silent no-op.
startEdit now reads them back, which it never did: editing a segment for any
other reason blanked both.
Existing sources need Generate SQL re-run -- the load templates are stored.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"referenceYTD Sales" ran together because the badge shared the note column,
and that column had lost width to label and counts-toward. The badge is a
fixed-width token, so it gets a column of its own and stops competing with
free text.
seq goes with it. The label carries the sort order now -- it is typed with
its own "01 - " prefix -- so a separate ordinal column is one more thing to
keep in agreement with it for no gain. saveSeq and its state go too.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two gaps you hit. There was nowhere to set "counts toward" while defining a
segment -- only in the list afterwards -- and the Edit buttons disappear
entirely once any adjustment exists.
That guard is right in principle and too broad in practice. Editing a
segment's filters or date offset after a scale would silently recalibrate a
distribution that was sized against the old rows, so it stays gated. But the
label and the bucket are presentation: they change what the pivot shows and
what the segment counts toward, never which rows were loaded. Those are now
editable in the list at any time, and settable on the create form.
pf.log.label is new: the segment's display name, falling back to tag then
note. Separate from both because those have jobs already -- tag groups
adjustments into initiatives for the bridge, note is commentary -- and
because the label is where sort order lives. Perspective orders column
groups by the value string, so a leading "01 - " is how ordering gets
expressed, and putting that in the note would put it in every note.
The load routes do not yet carry bucket and label through: they return only
rows_affected, with no log id to attach them to. That comes with the switch
away from computed prefixes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The bridge builds its own view from the pivot's filters, and those filters
can name pf_bucket_ord or pf_segment_ord — columns that exist only as
expressions. A view created without them cannot resolve the column, so the
bridge failed outright as soon as anyone filtered on an ordering column.
The per-slice path is left alone: its filters are built from col_meta names,
so they only ever reference real columns. Said so in place, since the
asymmetry otherwise reads as an oversight.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
130px was far too wide — it was a guess, and it turned the row labels into a
quarter of the sheet. The floor only needs to stop a column coming back
unusable after a layout restore, not to fit anything.
Three characters, measured in the grid's own font plus its cell padding
rather than fixed in pixels, so it survives a theme or zoom change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Loading a saved layout brought them back a few pixels wide, needing to be
dragged open by hand. Restoring resets the widths, and the row-header
columns are then sized from their header — which for row headers is a blank
corner cell — so they measure as empty.
A minimum rather than a fit. Fitting to content is the other extreme: each
group_by level is its own column, so the first widens to its longest label
and shoves the second rightwards, which is the spacing that read worse than
the default. 130px leaves a reasonable default alone and only intervenes
where a column came back unusable; anything already wider, whether dragged
or recorded in a layout, is untouched.
Applied after the initial load and after a layout restore — the two places
that reset widths.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each group_by level is its own row-header column, so fitting the first to
its longest label pushes the second to start after it — and the default
spacing reads better than the fitted result, cap or no cap. Fit now sizes
the data columns only, which is what it was doing when it was useful.
fitRowLabels is kept, unused, one call away: the measurement was the hard
part and the judgement about whether to apply it may change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Switching to a saved layout dropped pf_bucket_ord and pf_segment_ord.
restore() replaces `expressions` wholesale rather than merging, and a layout
saved before the feature existed carries none to restore — the same fault
initViewer had, fixed there and not generalised.
The layout is persisted from the merged config afterwards, so the saved copy
picks the expressions up rather than re-dropping them on the next load.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each group_by level is its own row-header column, so fitting the first to
its longest label pushes the second to start after it — which reads as the
deeper level being indented past the end of the shallower one, and sends the
rest of the pivot off to the right when a label is long.
Capped at 260px, which trades a rare truncation for a sheet that stays
legible. And taking the max with the current width means Fit only ever
widens, so a width set by dragging is not undone by pressing it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Row labels still did not fit, because the override was landing on the wrong
column. restore_column_size_overrides maps the key "__ROW_PATH__" to index
tree_header_offset - 1, which with two group_by levels is index 2 — the
first data column. That column is the grand total, which the stylesheet
hides, so pinning it changed nothing visible.
The row labels are separate columns (rt-col-0 and rt-col-1 here, one per
group_by level), so each is measured on its own and set by index through
regular-table's saveColumnSizes / restoreColumnSizes, which are index-based
and public.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It works now — inputs carrying a bucket_order and three seq values, both
expressions applied — so the console does not need narrating, least of all
during a demo.
Kept rather than deleted, gated like the depth tracing: the failure it
diagnosed (running before its inputs existed, then reporting nothing) is the
kind that recurs, and rebuilding this each time is wasted work. The
initViewer scaffolding goes, having served its purpose.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"Bucket" and "Segment" sit too close to the source data -- segment_new is an
actual column here -- and a name collision would be worse than a confusing
label: an expression named after an existing column shadows or rejects it
rather than just reading ambiguously.
pf_ prefixed like every other synthesised column, so they sort beside
pf_bucket and pf_segment in the column list and read as belonging to the app.
The old names are kept in the managed list so they are cleared from configs
that still carry them. Without that they would sit in saved layouts forever,
ordering by a rule nothing updates -- which is exactly what a stale Segment
expression was already doing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The instrumentation finally said it plainly:
inputs {versionId: '29', versionFound: false, bucket_order: null,
logMetaCount: 0, seqs: []}
It read the `versions` prop and the `logMeta` state, both populated
asynchronously, while running from initViewer — which finishes well before
them on a large load. So it was called with nothing every time, could never
build an expression, and the effect meant to re-run it once the data landed
never fired. Two small queries beat depending on that timing, the same
correction the master-data effect needed for the same reason.
Also explains why the ordering half-worked: `existing: Array(1)` with
Segment already applied. An earlier session had built it and it has been
riding in the saved layout since, so segment ordering appeared to work while
Bucket never existed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The earlier attempt at this never landed: its patch failed an assertion, and
I read a `grep -c "pf-order"` of 1 as confirmation when that count is just
the logger's own template literal — every call site was missing. So the
function was reached, returned silently, and reported nothing, twice over.
Logs on entry, the inputs it resolved, the expressions already in the saved
config, and each exit. Verified against the source and against the
individual strings in the built bundle rather than a count.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
No [pf-order] line appeared at all, and the sync call is unconditional
inside initViewer, so initViewer is not reaching it. Logs on entry, on the
superseded-by-a-newer-load guard, and immediately before the call, so the
next reload says which.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Neither the success nor the failure line appeared, and save() reports no
expressions at all -- so the function is either not being called or leaving
by a path that says nothing. Two of its exits were silent: the no-viewer
guard, and the case where the computed expressions already match what is
applied.
Logs on entry, logs the inputs it actually saw (the version it resolved, its
bucket_order, how many log entries carry a seq, and what it decided to
build), and logs every exit. One reload should end the guessing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
syncOrderExpressions caught its errors and logged them, and returned quietly
when there was nothing to build. Those two outcomes are indistinguishable
from the outside -- the Bucket and Segment columns are simply absent -- which
is most of why this has taken several rounds to pin down.
A restore() rejection now raises a message in the status bar, and the
no-ordering case logs the inputs it saw: the version's bucket_order and
every log entry carrying a seq. One reload should say which of the two is
happening.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Values fitted and row labels did not. The measurement pairs each column's
header with its body cell, and for the row-header columns the header is a
blank corner cell — so the column was being sized from an empty string,
never from the labels beneath it.
Measured with a canvas instead of the DOM, for the same reason the headers
cannot size themselves: the cell is clipped, so reading its box back returns
the width it was allotted rather than the width of its text. Tree
indentation is added, since it occupies real width. The result is pinned as
a column_size_override on __ROW_PATH__ — the key
restore_column_size_overrides special-cases for this column, and an override
is what survives subsequent draws, which is precisely why overrides were
defeating resetAutoSize earlier.
Group headers are deliberately left alone. pro.css is explicit:
/* Header groups should overflow and not contribute to auto-sizing. */
thead tr:not(.rt-autosize) th { overflow: hidden; max-width: 0px; }
Only the leaf header row participates, because a group label spans many
columns and letting it set width would widen all of them. With the ordinal
prefix in front, "01 - Prior…" still reads when truncated. Fixing it
properly means distributing a group's label width across its span, which is
a different job.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Bucket and Segment columns never appeared in the column list, and
viewer.save() reported expressions: {}. syncOrderExpressions ran immediately
after viewer.load() — and the saved layout was restored on the next line.
restore() replaces `expressions` wholesale rather than merging, so the
expressions were created and then wiped every single time, before anything
could see them.
Moved to after both restore branches. The effect on [logMeta, versions,
versionId] then keeps them in step, as intended.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fit worked on the values and not on the headings. Auto-fit measures cells
with getBoundingClientRect() and sets each column's min-width from the
result, but the datagrid's CSS wraps and clips header text — so a clipped th
measures at the width it was *allotted*, not the width of its content, and a
column can never grow to fit its own heading. Row labels are th elements in
tbody and clip for the same reason.
white-space: nowrap on those cells, and nothing else: no width, no overflow.
The measurement then sees the full text and the existing sizing logic does
the rest.
Folded into the stylesheet already being injected for the grand-total
column, and renamed from pf-hide-split-total to pf-grid-css now that it does
more than the one thing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Fit button did nothing. Setting _reset_column_size and redrawing cannot
work, because draw() undoes the reset on the very next line:
const old_sizes = save_column_size_overrides.call(this);
... if (this._reset_column_size) { resetAutoSize() }
restore_column_size_overrides.call(this, old_sizes);
and old_sizes comes from regular_table.saveColumnSizes() -- the *live*
widths -- not from plugin_config. So each draw preserves whatever the
columns currently are, whatever the config says, and clearing
column_size_override released nothing.
Calls regular_table.resetAutoSize() and draw() directly instead: the same
draw the plugin performs, without the save/restore wrapped around it.
_cached_column_sizes is cleared too, since it short-circuits the next save
and would be restored over the measurement.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The datagrid already measures content -- draw() calls
regular_table.resetAutoSize() -- but only when _reset_column_size is set,
and it puts any pinned widths back immediately afterwards:
const old_sizes = save_column_size_overrides.call(this);
... if (this._reset_column_size) { resetAutoSize() }
restore_column_size_overrides.call(this, old_sizes);
So a width pinned by dragging a column edge, or carried in a saved layout,
outlives every measurement -- including the automatic ones the datagrid does
when split_by or columns change. Clearing the overrides is what releases
them; the flag then makes the next draw measure instead of reuse.
The flag is set after restore(), not before: building the model recomputes
it from what changed in the config, and would clear it again.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Unordered, '(adjustment)' sorted *first* rather than last: '(' is 0x28 and
digits begin at 0x30, so it precedes '01 - Prior Year Sales'. The
parenthesised labels exist to mark a value as not a real segment, which is
exactly what an ordinal now does, so they get high ordinals and plain names
-- 98 - Unlabeled, 99 - Adjustments.
99 rather than max(seq) + 1 so the position does not shift every time a
segment is added or renumbered.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both were direct children of <table>, which is invalid: a browser hoists
stray non-table content out of the element, and in doing so it disturbed the
column widths of the segment listing below.
The datalist was already there before this change and had been getting away
with it; adding a visible div beside it is what made the consequence
obvious.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"01 · Prior Year" would not parse -- Invalid `expressions` "Bucket": Invalid
string token: 01. ExprTK's string scanner validates each *byte*:
is_valid_string_char(c) = isprint((unsigned char) c) || is_whitespace(c)
"·" is U+00B7, two bytes 0xC2 0xB7 in UTF-8, and isprint(0xC2) is false in
the C locale. The literal fails at that byte and the parser reports from the
start of it, which is why the message names "01" rather than the character
it actually objected to.
" - " instead, which matches the "07 - Dec" already in the source data rather
than introducing a second convention.
The same rule applies to the label being compared, so a bucket or segment
named with anything outside printable ASCII is left unordered rather than
emitted into an expression that will not parse -- an unparseable expression
loses the whole column, not just that one case.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Perspective orders column groups by the value string, and SortDir's col asc /
col desc only reverses that -- so Prior Year -> Plan -> Actual -> Forecast is
expressible as neither, being alphabetical in neither direction. The order
has to be part of the value, as a "01 · " prefix.
Done as Perspective expression columns, named Bucket and Segment, generated
from pf.version.bucket_order and pf.log.seq. The first attempt computed the
prefix in the served SQL (archived on feature/column-sequencing-sql), which
was the wrong layer: the prefix is a pivot-ordering concern, and putting it
in the query put it in every other reader too -- the change log and the
bridge's basis list would both have read "04 · Forecast". It also meant a
Generate SQL to introduce the placeholder, and a reload to see any change.
As expressions it stays in the pivot, travels with saved layouts because it
lives in ViewConfig, and reordering on the Baseline page takes effect
immediately -- the expressions are rebuilt and the pivot re-renders, no
reload.
Kept from the SQL attempt: pf.version.bucket_order and pf.log.seq, which are
needed either way, and the Baseline controls -- a seq column per segment and
a reorderable row of bucket chips. bucket_order is on the version because
the Baseline page is version-scoped; Setup is the only source-level context
and not where anyone would look for this.
Anything unordered falls through to the raw column, so it sorts after the
ordered entries (digits before letters) rather than silently landing first.
The expressions are merged into the live config rather than replacing it, so
a user's own expressions survive, and an expression that stops existing is
dropped from the axes first -- restore() rejects a config that pivots on an
expression it no longer defines.
Needs 01_schema.sql for the two columns. No Generate SQL.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The rule was injected into perspective-viewer-datagrid's shadow root, which
is not where the cells live, so it applied to nothing and the grand-total
column stayed visible.
Locates regular-table by walking through shadow roots and injects into
whatever root contains it, via getRootNode(). That makes no assumption about
how the plugin nests -- which is the assumption that was wrong, and the kind
that breaks on a Perspective upgrade anyway.
The grid is built asynchronously after load, so hideSplitTotal now reports
whether it found anything and is retried once on a short delay, as well as
from the config-update handler.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rollup mode emits a grand-total column group alongside the subtotals. The
subtotals are the point -- they are what per-branch collapse needs -- but
the grand total sums across the split, and when the split is Prior Year /
Plan / Forecast that sum is meaningless while looking exactly like a real
figure to anyone scanning the sheet.
The engine cannot separate the two: t_totals is { BEFORE, HIDDEN, AFTER },
and in a rollup the grand total *is* the root of the hierarchy that produces
the subtotals. Turning it off turns the subtotals off with it. So it is
hidden rather than suppressed -- the view still computes the column, it is
simply not painted, and collapsed to zero width so there is no gap where it
was.
Targeted by psp-split-total, excluding psp-split-subtotal, which is how the
datagrid already distinguishes them. That survives adding a measure or
rearranging the pivot; hiding by column position would not -- the group
spans one column per measure in `columns`.
Injected into the plugin's shadow root, since a document stylesheet cannot
reach inside it, and re-asserted on config updates because the plugin
element is replaced when the plugin changes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Column collapse did not survive flipping away from the browser and back,
because it was the one piece of state still held outside the config:
applySplitDepth restored a *truncated* split_by, and a rebuilt view took its
split_by from the config while the levels it had dropped lived only in
splitFull.
split_by_depth is the config-level equivalent, and it works now that
apply_update applies it -- server.cpp does
ctx2->set_depth(HEADER_COLUMN, column_pivot_depth - 1), so it is 1-based
exactly like group_by_depth.
Everything that existed to support truncation goes with it:
- splitFull no longer has to outlive split_by, because split_by keeps every
level. It is just the live axis, for rendering the buttons.
- split_full is no longer persisted beside the config. Still read on load,
for layouts saved by the old scheme.
- collapsingRef and the prefix test are gone. They existed to tell our own
collapse from the user rearranging the pivot, which only mattered because
a collapse looked like a shorter split_by.
- The selection survives a collapse. It was cleared because slices name the
split_by dimensions they were cut from and the axis was changing shape;
with a depth those dimensions are all still there.
CLAUDE.md's §Column hierarchy describes the old mechanism throughout, and is
now wrong; updating it separately.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rebuilt from fleetside72/perspective at apply-depth-on-config-update, which
carries the patch ui/vendor has been describing: ViewConfig::apply_update
applied ten fields and neither group_by_depth nor split_by_depth, so a depth
set through restore() was dropped before the engine saw it. The new
perspective-js wasm is 81 bytes larger than the old, which is about right
for two calls and a generic.
Four things went wrong getting here, all of them host tooling rather than
the patch, and the script's preflight now catches three:
- pnpm 12 rejects a repeated `--if-present`, which sh_perspective.mjs emits
once per package in scope. The tree needs pnpm 10; it pins no
packageManager, so `npm i -g pnpm` gets something too new.
- `pip install cmake` gives 4.x, which clears the existing >= 3.29.5 floor
and then fails in the Arrow build, because CMake 4 dropped support for
cmake_minimum_required < 3.5.
- The pack step used `npm pack`, which leaves these packages' mutual
`workspace:^` dependencies as-is; npm install then refuses the tarball
with `Unsupported URL Type "workspace:"`. Now `pnpm pack`, which rewrites
them -- and which is evidently how the previous tarballs were made, since
theirs read `^5.4.0`.
- postinstall:playwright runs `playwright install --with-deps`, which
apt-installs system libraries and needs root. Removed on the build branch.
npm normalised the vendor paths in package.json from ./vendor to vendor;
same meaning, left alone.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ViewConfig::apply_update in perspective-client applies ten fields and
neither group_by_depth nor split_by_depth is among them. A depth therefore
works when a view is created -- table.view({ group_by_depth: 1 }), which is
what the fork's own depth_test.mjs exercises -- and is silently discarded by
restore(), which is how a viewer changes its own configuration. That is why
the EXPAND buttons did nothing however the value was sent.
group_by_depth and the omission are both upstream; the fork mirrored
split_by_depth alongside it faithfully, including the omission. So the column
axis expand/collapse the fork added has the same hole, and pf_app only avoids
it by collapsing columns through a truncated split_by instead.
The patch adds an Option-aware sibling to _apply and applies both fields. It
cannot be built here -- protoc is absent, so the generated protobuf modules
are missing and the crate does not compile for unrelated reasons -- but
cargo check reports nothing against view_config.rs. It sits in ui/vendor with
the rebuild instructions, beside the tarballs it is not yet in.
Meanwhile applyDepth reads the config back after restoring and falls back to
view.set_depth() when the value did not stick, so the buttons work on the
current build. The fallback loses depth on a view rebuild, as it always did;
what is not back is the shim and listeners that used to chase that. The check
clears itself once the engine honours the field.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The EXPAND buttons did nothing because their 0..3 were written against
view.set_depth(), which is 0-based, while the config field is 1-based.
server.cpp does
ctx1->set_depth(row_pivot_depth - 1) // one-sided
ctx2->set_depth(HEADER_ROW, row_pivot_depth - 1)
for both contexts, so group_by_depth counts the levels to show. Passing 0
asked the engine for set_depth(-1), and every other button was off by one
level. The fork's own depth_test.mjs uses group_by_depth: 1 as its working
control, which is the same arithmetic seen from the outside.
So the toolbar sends d + 1, and subtracts one again when reading a saved
layout back for the button state.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A partial restore({ group_by_depth: d }) had no effect -- the buttons did
nothing, and the depth appearing to survive every rebuild was simply a tree
that had never been collapsed. So the full config goes back with the one
field changed.
table is dropped from it: the table is loaded by reference, and a stale name
in a restored config fails the lookup -- the same reason every other restore
in this file strips it.
If this still does nothing then group_by_depth is not applied when the view
is constructed, and the fix belongs in the fork beside split_by_depth.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ViewConfig carries group_by_depth -- alongside the split_by_depth this fork
added -- so row depth can be set declaratively:
await viewer.restore({ group_by_depth: d })
The config is what the viewer rebuilds its view from, so the depth survives
every rebuild by construction, and viewer.save() carries it into the
persisted and named layouts for free.
It had been imperative: getView() then view.set_depth(), which puts the
depth on an object the viewer discards whenever it re-renders. Everything
that grew around that existed only to guess when a rebuild had happened and
put the depth back -- a shim patching window.IntersectionObserver and
window.ResizeObserver, focus/visibilitychange/pageshow listeners, a retry
loop for getView() throwing "No table set" while getTable() resolved, a flag
tracking whether the viewer had "gone away", and tracing to debug all of it.
That guesswork caused three separate visible faults in a single session: the
tree fully expanding on refocus, snapping on any reflow, and snapping when
Perspective's own settings sidebar was opened. Each fix was a finer
heuristic about which browser event meant what, which is the shape of
fighting a framework rather than using it.
312 lines out, 35 in. applySplitDepth no longer re-applies the row depth
after truncating split_by, because the rebuilt view brings it along.
expand_depth stays readable on load: it is the legacy key, from when the
depth had to be stored beside the config rather than in it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two causes, both mine, both triggered by adjusting the layout.
A zero size was being treated as the viewer going away, and opening
Perspective's settings sidebar collapses the datagrid for a frame. The
return then re-applied the stored row depth over whatever had been expanded
by hand. Only lost intersection counts as a departure now; a zero size is a
transient of layout, not an absence.
And perspective-config-update fires for anything in the config, the settings
flag included -- not only for split_by. While collapsed the live split_by is
a truncation of the full hierarchy, so adopting it recorded the truncation
as the hierarchy and put the deeper levels out of reach. A genuine
rearrangement is not a prefix of what we already hold, which is the
difference the handler now tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Driving the re-apply from the observer callbacks caught the refocus case but
fired on far more than that: ResizeObserver reports every reflow, so
dragging the panel, a scrollbar appearing, or anything that changed the
layout re-applied the stored depth and discarded whatever had just been
expanded by hand. From the outside that is the pivot snapping to a different
layout while clicking around.
Going away is what discards the view; a reflow is not. So the callbacks now
record the departure -- lost intersection, collapsed to zero size, or a
hidden document -- and only a return after one of those restores anything.
The flag clears once the depth is back, so one departure causes one restore.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
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>
The Date offset on a baseline or reference segment was a pair of
type="number" inputs, years and months, both clamped at min 0. So no
characters could be typed, days could not be expressed at all, and a segment
could only ever be shifted forward in whole months -- while the stored value
is a Postgres interval that happily accepts "4 months", "1 year" or
"-90 days". parseOffset only read year and month, so anything else would not
have survived a round trip through the edit form either.
One text field now, with suggestions, matching what clone already offers.
parseInterval parses it far enough to draw the timeline preview; Postgres
stays the authority, and assertInterval -- now shared by the baseline,
reference and clone routes -- rejects what it will not accept, with a
message naming the field rather than a parse error from inside a CTE.
Timeline takes months and days separately rather than years and months,
because the two are not interchangeable: a month shift lands on the same day
of another month, a day shift can cross a month boundary. It adds months
then days, as Postgres does, and its label handles negatives.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A selection of five AOP rows cloned nothing, with no explanation. The cause
was exclude_iters, which clone applied along with scale and recode.
It should not. That exclusion exists to stop operations *modifying*
reference rows: scale distributes an increment across its pool, so including
reference would attribute forecast movement to prior-year rows, and recode
writes negative rows that zero the original out. Clone does neither -- it
reads rows and inserts new pf_iter = 'clone' rows, leaving the source
untouched. Copying a plan or a prior year out of reference and into
adjustments is the operation doing exactly what it is for.
So from_logid stops being the only way to reach those rows and becomes what
it should be: a narrowing, for a selection spanning AOP and Prior Year where
only one is wanted.
The "would just duplicate the rows" guard no longer fires when the selection
is entirely non-adjustable, since moving rows from reference into adjustments
changes what they are even at factor 1 with no shift.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
The panel required at least one override for both recode and clone. For
recode that is right -- rewriting rows as themselves does nothing. For clone
it is not: copying prior year forward changes the dates and, through
dim_period, the season and month dimensions, which is the entire point of
cloning from a reference segment. The server-side requirement was dropped
when from_logid went in; this one was missed, so the operation was blocked
in the UI with "Enter at least one override value".
Clone now only has to be doing something -- an override, a shift, or a
factor other than 1 -- and says so when it is doing none of the three.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The bridge filtered on exclude_iters, so it dropped every reference row --
including Open Orders, which is loaded as reference precisely so nothing
adjusts it and is still part of the forecast. Membership is pf_bucket now,
which is the axis that answers this question; pf_iter answers a different
one.
What you compare against depends on what you are building: an AOP is built
off a prior period, a forecast off an update to the AOP. So the basis is
chosen rather than assumed, and the walk stays exact either way because
Forecast - Basis = (Forecast loads - Basis) + adjustments
The opening step is that first term, every tagged adjustment explains the
rest, and the bars sum to the endpoint by construction. No residual to
explain away -- which is what a bridge from prior year to forecast would
otherwise be, since the adjustments describe movement from the forecast's
own loads and not from last year.
With no basis it is the composition instead: the forecast's loads as the
opening anchor, then the adjustments. The picker only appears once something
carries a bucket, so a version that has not been labelled behaves as before.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
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>
There was no way to express "these segments together are the forecast".
pf_iter cannot say it: it answers whether operations may write to a row, and
Open Orders is loaded as reference precisely so nothing adjusts it while
still being part of the forecast number. The two questions are independent,
so one cannot be derived from the other.
pf.log.bucket is the second axis. Free text with suggestions -- Forecast,
Prior Year, Prior Prior Year, Plan -- rather than an enum, so another banner
needs no migration. Blank by default, falling back in the pivot to the
segment's own name, so nothing changes until something is labelled.
/data and /agg emit it as pf_bucket beside pf_segment, through the pf.log
join that is already there. Set it per segment in the Baseline list, which is
where loads live now that the change log only shows adjustments.
Needs 01_schema.sql for the column and Generate SQL for /agg to select it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The effect decided which dim_groups to load from colMetaRef.current, but
that ref is filled inside initViewer, which is async. The effect ran first,
saw an empty array, found no groups, and fetched nothing -- and a ref
changing does not re-run an effect, so it never recovered. dimMembers stayed
{} for the whole session.
Everything therefore fell back to the source lookup, which is precisely the
query pf.dim_member exists to replace: XCP06500G18B112 has two attribute
combinations across history, so DISTINCT ... LIMIT 2 returned two rows and
the route answered null. The member row had the answer all along, picked as
the most recent by order date.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Autofill filled only empty boxes, so it appeared to work exactly once. Type a
part, get its nine attributes; type a different part, and every box is
already full, so nothing updates and the form still describes the previous
part. That is worse than blank -- the recode would have been submitted with
one part's code and another's attributes.
These columns describe the key that was just entered, so they are replaced
outright now.
A lookup that finds nothing also said nothing, which is indistinguishable
from a lookup that did not run. It warns, and names the value it could not
find, so an unknown part reads differently from a broken autofill.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Autofill stopped filling anything as soon as the member endpoint answered,
because a hit was treated as the only possible answer: if the key was not in
the list, it returned rather than trying the source. An empty list counted as
an answer too, which is the state while a refresh is still running -- so
wiring up master data broke the behaviour it was meant to improve, for
everyone who had not finished building the list yet.
A miss is not an answer. Empty list, or a key the list does not know, both
fall through to the source lookup as before. It costs one request, on a path
that only runs when someone types a value.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
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>
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>