Commit Graph

220 Commits

Author SHA1 Message Date
46a812dd74 Grey the measure the plug holds
Absorbed by asks which of price and volume moves, and then said so only in
a sentence underneath itself. The column it holds still now says it: the
header reads "holds" in place of its hint and the three boxes go flat grey,
so what you are editing is legible from the numbers rather than from prose
about them.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 15:03:41 -04:00
fe456d4e90 Drop Shift years
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>
2026-09-21 12:39:07 -04:00
738e177793 Let a forecast be renamed
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>
2026-09-21 10:26:37 -04:00
b014f7815a Clone a version's definition without running it
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>
2026-09-21 10:23:35 -04:00
cb9f98f551 Merge branch 'carry-every-date-through-an-adjustment'
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 09:49:04 -04:00
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
19e52fa3fe Carry every date column through an adjustment
Scale and recode wrote dataCols, which held dateCols[0] and nothing else.
On a source with order, requested and ship dates that means every scale and
recode row was written with rdate and sdate_e null -- not stale, absent --
while the period dimensions derived from them (rseas, smon_e) were copied
with the other dimensions and so read perfectly, which is what kept it out
of sight.

Both columns are role 'date' and therefore sliceable, so an adjustment
scoped to a ship date matched no adjustment row that had ever been
written, and anything dating the forecast by ship date dropped every
adjustment without saying so. Clone already carried them all, for the
date_offset it has to shift; the loads always did.

The GROUP BY widens with the select list. On osm_skinny that is 971,185
groups against 971,352 -- ordnum and ordline already make a row nearly
unique -- so the row count is unaffected in practice.

Existing rows are not repaired; see Known issues.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 09:19:09 -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