Commit Graph

185 Commits

Author SHA1 Message Date
61268f2a7a Say who made each change, and which ones you can undo
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>
2026-09-18 12:03:06 -04:00
9d08638a92 Read territory at login, and survive having none
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>
2026-09-18 11:52:00 -04:00
68e2d81c8c Let an empty result be an answer, not an abort
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>
2026-09-18 11:48:57 -04:00
e94c364406 Fix the territory commands' success message and menu order
They called ok(), which this script does not have -- the helper is
success() -- so set-territory ended on "ok: command not found" after having
worked. The three new entries also sat between 13 and 14 in the menu, having
been appended where the list-users case was rather than at the end.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 11:47:02 -04:00
e60dd3ad96 Put the territory column in the Setup editor
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>
2026-09-18 11:38:55 -04:00
712d114dc5 Scope what an account can see and change to its territory
Territory filtering was deferred from v1, so every account saw and could
write every row. With the sales team about to adjust their own territories
that is the thing standing in the way, and it is also why a rep would wait
fifteen seconds to load 2.7M rows to work on a few thousand.

The list lives on pf.app_user.territory with is_admin beside it, and
col_meta.is_territory marks which column of a source the values belong to --
flagged rather than named in code, so a second source can be divided by
something other than a sales rep.

Fail closed: buildTerritoryClause returns FALSE for an empty list or an
unflagged source. An account nobody configured sees nothing, rather than
everything because a column was left null.

Built from the session, never the request. That is what separates it from
`scope`, which the browser sends and should: a filter the user chose belongs
in the payload, a permission cannot come from the thing it restrains. It is
ANDed on last, where nothing in the request can undo it.

Enforced on /data (the cursor and the count behind X-Row-Count), on /agg
before the GROUP BY since the territory column need not be in the grain, on
every operation through sliceUnits, and on the value completion endpoint --
which reads the source table, so without it a dropdown enumerates every
customer and rep in the business to someone shown none of their rows.

Undo is gated by owner rather than territory: it removes an entry's rows
wholesale, so half-undoing one would leave a state nothing describes. Recode
refuses to set the territory column unless you are an admin, since moving a
row between territories is reassignment, not forecasting.

./pf.sh gains set-territory, set-admin and orphan-territory. The last lists
territory values no account owns -- work under one is invisible to everybody
but an admin, which a typo causes easily and nothing in the app reveals.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 11:14:03 -04:00
b173a3ddaf Merge adjustment row collapse and stamped log totals
Adjustments write one row per coordinate rather than one per row read, so an
operation no longer inherits the row count of every layer before it.
Verified live: a scale reading 2,948 rows wrote 908.

Each log entry records what it did -- row count, value and units, and the
columns those are denominated in -- stamped after the write rather than
recomputed by joining the whole forecast table every time the change log is
opened.
2026-09-18 10:57:06 -04:00
496642c545 Stamp each log entry with what it did
The change log joined the whole forecast table on every open to total rows
it had just written -- 2.5M rows to report a few thousand, and the Baseline
page's row and value columns paid the same cost again.

The totals go onto pf.log at write time instead. This is not a cache that
can drift: a log entry's forecast rows never change once written, because
only the operation owning the logid inserts them and the only thing that
removes them is undo, which deletes the log row too. measure_cols records
which columns the figures are denominated in, since the value and units
roles can be reassigned in col_meta and the numbers would otherwise quietly
come to mean something else.

Stamping is best-effort and runs after the commit: a failure to record what
happened must not roll back the thing that happened. The loads return their
new log id to make it possible, the adjustments take theirs from the rows
they return, and apply_mode 'each' writes one entry per slice, so it is a
set rather than a single id.

?recount=1 does it the old way and writes back what it finds. Stored totals
cannot drift on their own, but nothing stops someone deleting forecast rows
by hand, and a stored figure has no way to notice -- so there is a way back,
which doubles as the backfill for entries written before the columns
existed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 10:40:39 -04:00
f70de94e61 Write one adjustment row per coordinate, not per row read
Every operation inherited the row count of everything before it. Scale read
the baseline's rows plus every prior adjustment's rows sitting at the same
dimensional coordinate, and wrote a delta for each -- so eight Pull Forward
entries meant the next scale over that slice wrote nine rows where one would
do, and the table grew super-linearly with how much work had been done on it.

The base sets are grouped now: scale's `base`, recode's `src`, and clone's
source, each by every stored dimension and date, summing the measures. The
collapse is over pf_logid and pf_iter alone, so no column goes null and
nothing becomes unsliceable by a later operation -- which is the trap in
collapsing to the display grain instead, where the non-grain dimensions would
have to be null and the next slice naming one would silently miss these rows.

The maths is unchanged. Scale's proportional split needs the total over the
pool, and sum(sum(x)) OVER () gives the same figure over collapsed
coordinates that sum(x) OVER () gave over raw ones -- the window runs after
the GROUP BY. Verified on a real slice: 3,989 rows collapse to 3,187 at
1,303,545.75 either way. Across the version's existing adjustments it is
149,458 rows against 124,474, and that understates it, since the point is
that the next layer no longer multiplies the last.

All three templates planned against the live table before committing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 09:59:14 -04:00
74fd030638 Merge segment labels, stored ordering, and slice fidelity
The pivot's column order is stored text now, typed into pf.log.label, rather
than a prefix computed in the browser from a seq column -- which ordered the
pivot and nothing else, had to be re-applied after every layout load, and
could not express a label outside printable ASCII. 250 lines of expression
machinery go with it.

The fallback names an unnamed row shows live on pf.version, editable per
scenario. A segment edit no longer wipes the annotations it was not handed.

Slices mean what they say: pf_segment and pf_bucket resolve to log ids
instead of being dropped, the measure name no longer leaks in from a
collapsed column, and the pivot's filter scopes both the ledger and the
write. Each of those was a case of the panel showing one number and the
operation changing a wider set.

The ledger names its lines the way the rest of the app does, leads with what
cannot move, and breaks that out per segment.
2026-09-18 09:56:23 -04:00
afab81d770 Merge ledger and slice fidelity work
Slices now mean what they say -- pf_segment and pf_bucket resolve to log ids
rather than being dropped, the measure name no longer leaks in from a
collapsed column, and the pivot's own filter scopes both the ledger and the
write. The ledger names its lines from label like everything else does, shows
what cannot move first and per segment, and the column groups are ruled off
in the grid.
2026-09-18 09:53:37 -04:00
28aa7012f1 Put what cannot move at the top of the ledger
The immovable rows sat between Adjustable and Selected total, which made
them read as an afterthought to a figure they in fact constrain. They come
first now: this much is already booked and billed, this is what is left to
work with, and here is how that got to where it is.

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 09:38:37 -04:00
f9424d9c42 Recognise a subtotal by its zero-width padding
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>
2026-09-18 09:26:21 -04:00
54d49ebc75 Rule off the column groups and mark their subtotals
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>
2026-09-18 09:20:27 -04:00
cdb40e7368 Make the request preview show the request
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>
2026-09-18 09:10:09 -04:00
d983e2b1df Carry the pivot filter as a scope, operators and all
Refusing anything but == was safe and useless: a view bounded to
sseas_e <= 2027 is an ordinary way to scope a forecast, and it has no slice
form at all, a slice being {col: value}. The filter now travels beside the
slices as [col, op, value] triples and is ANDed onto every unit -- not folded
into the slices, since it applies to all of them equally and under
apply_mode 'each' would just repeat itself in every statement.

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 08:46:23 -04:00
1a0a9db8d0 Show the row count while the load is still waiting on it
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>
2026-09-18 08:41:24 -04:00
9753846d34 Make the pivot's filter scope the ledger and the write
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>
2026-09-18 08:39:44 -04:00
6b63e9a5f3 Say how many rows the load is waiting on
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>
2026-09-18 08:38:04 -04:00
c6ef350283 Type a slice's values before filtering the ledger with them
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>
2026-09-18 08:33:34 -04:00
f94aa4ec99 Drop the measure name from a collapsed column's slice
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>
2026-09-18 08:16:09 -04:00
50a0bb42aa Make a slice mean what it says, and say what cannot move
The phantom: pf_segment and pf_bucket are computed from pf.log when the rows
are served, so buildWhere had no column to compare and dropped them. Clicking
one bucket's cell and scaling therefore wrote every bucket at that dimension
intersection, while the panel showed only the bucket clicked. On the example
slice that is 350,524.74 displayed against 503,446.08 written.

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 00:17:25 -04:00
0cabe9bcd2 Put the fallback display names on the version
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>
2026-09-17 23:47:17 -04:00
aa03e74b5e Prefix the adjustment bucket, and list the hardcoded names
Adjustments fell back to a bare 'Forecast' while the loads they adjust read
'04 - Forecast', so the bucket column split in two and the adjustments sat
apart from the rows they came from. The fallback now matches.

The three fallback names are gathered into one DISPLAY DEFAULTS block at the
top of sql_generator, exported, and tabulated in CLAUDE.md, so the answer to
"where did that name come from" is one place rather than a grep. The
incremental row stamps in the operation routes use the constant now instead
of restating the literal, which is how they drifted apart in the first place.

None of this belongs in the source. ADJUSTMENT_BUCKET carries a number that
only suits one convention and changing it changes every version on every
source; the note says what per-version would take.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 23:42:51 -04:00
1286351be3 One name for one column: Note
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>
2026-09-17 23:18:41 -04:00
2306315a17 Stop a segment edit from wiping its annotations
PUT /versions/:id/baseline/:logid deletes the log row and inserts a fresh one
from the stored template, so every field the form does not send comes back
null. That took the label and the bucket with it, and the tag besides -- the
segment form has no tag input at all, so a tag could not survive an edit made
for any other reason.

The route now hands back what it was not given, reading the row it is about
to replace. `??` rather than `||`: an empty string is the form clearing a
field deliberately, undefined is the form not carrying it.

The load templates gained a tag token to receive it. Stored templates are
per-source and were generated before any of this existed, which is the other
half of why labels vanished -- source 14's had no label column to write to.
Regenerated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 23:06:17 -04:00
3162759f93 Size the table to its content, not to the window
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>
2026-09-17 23:02:38 -04:00
602effde73 Give the segment table room, and size its text columns by content
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>
2026-09-17 23:00:27 -04:00
98f7bbef34 Stop the note column wrapping the whole row
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>
2026-09-17 22:57:20 -04:00
98322a6080 Close the write-only surface left behind by the ordinals
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>
2026-09-17 22:51:34 -04:00
3299bfe10b Be exact about what the 99 fallback does
It puts adjustments after every *numbered* segment, not last outright: on the
existing version, whose segments are still named AOP and YTD Sales with no
prefix, "99 - Adjustments" sorts first, because digits precede letters. That
is the scheme working as designed rather than an edge case, so the note says
so.

Also corrects the regeneration claim: editing a label is a PATCH and needs
nothing regenerated. Generate SQL is a one-time thing per source, so its
stored load templates write label and bucket at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 22:45:56 -04:00
03278eb091 Drop the bucket reorder buttons, and document the new mechanism
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>
2026-09-17 22:45:11 -04:00
7738b904bf Delete the client-side ordering expressions
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>
2026-09-17 22:44:07 -04:00
885c9abe83 Put the ordering prefix in the stored label, not in an expression
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>
2026-09-17 22:42:57 -04:00
893a395529 Give the operation badge its own column, and drop seq
"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>
2026-09-17 22:22:46 -04:00
af9e6de88e Give a segment an editable label and bucket, at creation and after
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>
2026-09-17 22:15:25 -04:00
0fdb08291d Carry the pivot's expressions into the bridge's own view
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>
2026-09-17 15:38:03 -04:00
a83138b3ce Make the row-label floor three characters, not 130px
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>
2026-09-17 15:25:38 -04:00
4045d336c4 Put a floor under the row-label columns
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>
2026-09-17 15:21:44 -04:00
a9062c398c Leave row labels at their default width
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>
2026-09-17 15:13:48 -04:00
df8b6f56c9 Re-apply the ordering columns after loading a saved layout
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>
2026-09-17 15:10:40 -04:00
ec5d9e1dc3 Cap the row-label width, and never narrow a column
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>
2026-09-17 15:05:36 -04:00
9ccaabd4c1 Pin the row-label columns by index, not through __ROW_PATH__
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>
2026-09-17 14:59:34 -04:00
e7c266a871 Quiet the ordering trace behind pf_debug
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>
2026-09-17 14:47:38 -04:00
424199c1aa Name the ordering columns pf_bucket_ord and pf_segment_ord
"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>
2026-09-17 14:46:13 -04:00
f7f4fbb4c6 Have the ordering sync fetch its own inputs
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>
2026-09-17 14:43:30 -04:00
6c9d0eef11 Actually instrument the ordering sync
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>
2026-09-17 14:40:22 -04:00
a5bb814a68 Trace how far initViewer gets before the ordering sync
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>
2026-09-17 14:35:25 -04:00
e8e95bf4b9 Log every entry and exit of the ordering sync
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>
2026-09-17 14:29:43 -04:00