Commit Graph

206 Commits

Author SHA1 Message Date
b14ab5eeb4 Merge branch 'readable-toolbar-labels'
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 09:49:04 -04:00
6364ce0c8c Make the toolbar section labels readable
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>
2026-09-19 15:22:20 -04:00
cc5de46b9e Say what each toolbar control actually does
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>
2026-09-19 15:17:13 -04:00
9e9782419a Filter aggregates with the rest of the layout
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>
2026-09-19 14:43:00 -04:00
d2ba944d41 Make a pivot layout a thing people own and publish
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>
2026-09-19 14:20:04 -04:00
7217518cfd Write down what auto-pause actually does
It reads like a paint optimisation and is not: pausing deletes the view, so
becoming visible again is a full rebuild. dataflow embeds the same viewer and
would hit the same stall, so it belongs in the shared reference next to the
other things that cost us a day to find.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 12:25:22 -04:00
d14036d1b5 Keep the view across a tab switch
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>
2026-09-19 09:08:01 -04:00
a028cdb79a Write down the deploy ritual and today's loose ends
The restart / Generate SQL pair caught us out four times today, twice
appearing as an unrelated client-side error: a stored template carrying a
token the running code does not substitute fails at the database, and what
surfaces is Perspective aborting on an empty dataset.

Also records the four things left open -- recode and clone reporting success
on zero rows, the change log not showing an entry id, territory being read
only at login, and the depth buttons rebuilding the view.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 16:58:07 -04:00
23032a0658 Read source columns from pg_catalog, not information_schema
information_schema.columns omits materialized views -- they are not in the
SQL standard -- and gs.osm_skinny is one. So the source this app runs on
looked like it had no columns at all: creating a version failed with "No
usable columns in col_meta" while col_meta plainly held thirty-six, and
registering such a source would have seeded nothing.

RELATION_COLUMNS_SQL returns the same shape information_schema did, so
mapType and every caller are unchanged: data_type is format_type with the
modifier stripped, which spells things the same way ('character varying',
'numeric'), and precision and scale are unpacked from atttypmod as
information_schema does internally. Verified against the live matview -- 36
usable columns, and the types map to exactly what fc_osm_skinny_29 already
has.

The table browser had the same blind spot from information_schema.tables and
now lists from pg_class by relkind, so a materialized view can be registered
rather than merely used by a source registered when it was still a table.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 16:12:05 -04:00
469e4cc957 Name each loaded table uniquely
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>
2026-09-18 13:49:44 -04:00
0d05dc3baa Show that an operation is running
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>
2026-09-18 13:32:59 -04:00
f2734ab5b2 Scope the table-info counts to the territory
table-info feeds two things and was scoped for neither: the status bar's
"total rows", and the figure the load progress promises while it waits. So
an account that can see a fraction of the table was told the whole size of
it -- jbukowski's 542k rows reported as 2.8M.

It only showed on the larger territories. The seeded figure is replaced by
X-Row-Count when the headers arrive, and on a small territory the aggregate
returns fast enough that the wrong number barely appears.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 13:29:52 -04:00
5cc59b5792 Wrap the bridge's bar labels instead of cutting them
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>
2026-09-18 13:16:35 -04:00
96342f3527 Say "Starting point vs Plan" rather than "Loads vs Plan"
"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>
2026-09-18 13:14:31 -04:00
344168abdd Run the ledger's cumulative across the whole selection
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>
2026-09-18 12:59:45 -04:00
62c815da63 Show the walk's running total in the ledger
The lines sum to Adjustable, and checking that they do meant adding
eight-digit numbers in your head. The column closes on the Adjustable row, so
the walk visibly lands where it says it does.

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 12:57:50 -04:00
2814b4073c Tell the bridge which bucket is the forecast
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>
2026-09-18 12:42:27 -04:00
50f4c50b3a Group the digits as you type in the ledger
-2000000 and -20000000 are the same shape at a glance, and the ledger deals
in both.

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 12:28:31 -04:00
b4f579c4b1 Merge territory scoping and the write audit trail
An account sees and changes only its own territory: the values live on
pf.app_user, the column they name is flagged per source in col_meta, and the
predicate is built from the session and ANDed on last where no request can
remove it. Fail closed -- an account nobody configured sees nothing.

Enforced on both reads, on every operation, and on the value completion
endpoint, which reads the source table and would otherwise enumerate the
whole business to someone shown none of their rows. Undo and annotation are
gated by author, since label and bucket name the pivot's columns for
everyone; recode refuses to move a row between territories unless you are an
admin.

Each entry now records what it ran against and the statement it ran, beside
the intent it already recorded -- the three things that cannot be
reconstructed afterwards, and the SQL that the intent actually became.
2026-09-18 12:26:08 -04:00
c6005bd17c Record what a write ran against, and the statement it ran
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>
2026-09-18 12:19:40 -04:00
39b2a7e4a2 Only the author or an admin can edit a log entry
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>
2026-09-18 12:06:13 -04:00
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