Commit Graph

56 Commits

Author SHA1 Message Date
5feb28eb76 Keep the pivot's pf_ columns in the slice, and log the panel's expected change
The ledger filtered on every column of the loaded table, while the request
kept only pf_segment and pf_bucket and stripped pf_iter, pf_note, pf_op,
pf_user and pf_logid. Under the default layout, grouped by pf_iter, a click
on a customer's `scale` row totalled the scale rows and wrote to every row
the customer has -- on GRIFFIN, $4.5M shown against $9.7M written. They now
travel: pf_iter / pf_user / pf_logid compared directly, pf_note / pf_op
resolved to log ids as pf_segment / pf_bucket are, a null note as IS NULL.
They narrow a slice but do not make one selective on their own.

Each scale also carries `expected`, the Result line as a change, and
`plug`, both kept in the log's params beside `resolved` so a panel/server
disagreement can be found by reading it. Nothing compares them yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 22:40:00 -04:00
0c9746d3be Let a total cell be selected inside a filtered pivot
A grand-total cell is its displayed amount like any other: under a column
it is that column's values, and inside a filtered pivot it is what the
filter leaves. Its slice can therefore be {}, which is accepted only
alongside a non-empty scope -- without one it would be the whole version,
and is still refused on both sides. buildWhereAny reads a union containing
{} as TRUE, the scope ANDed on afterwards. The panel names it "Total".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 21:05:51 -04:00
45e45bfa60 Make the pivot toolbar read as three controls, not twelve
Each axis's depth stepper is now one joined control, styled like the
operation panel's Scale | Recode | Clone, so the arrows are visible and
tell enabled from disabled in dark mode. The Total checkboxes become
toggle chips beside their stepper. Operations shows its open state as
pressed rather than relabelling to a blue "Hide panel", keeping blue
for actions. The zone boxes are gone; the gap between the zones already
separates them. The layout name gets more room before it truncates.
color-scheme: dark on the dark palette fixes native controls app-wide.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 12:59:45 -04:00
816ff7ab4c Save the grand totals with the layout; add a total-column toggle
Both totals travel in the layout as pf_totals { row, col }: added by
captureConfig, stripped by cleanLayout before restore(). The grand-total
column, previously always hidden by GRID_CSS, now hides only without
data-pf-col-total. Layouts saved before the key get row shown, column
hidden.

The group rule is skipped when every column group is one column wide --
one split level and one measure -- where it separated nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 01:08:15 -04:00
6deb8e547c Drop point-in-time counts and a temporary table name from CLAUDE.md
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 00:16:50 -04:00
82fa560cc8 Refuse offsets finer than a day; recode skips zero coordinates
A bare number is an interval in seconds to Postgres, so "30" was
accepted as a date offset, cast back to the same date, and a shifted
recode took rows out of a cell and put them back into it. Offsets are
now refused unless they are a whole number of days.

Recode no longer writes a take-out/put-back pair for coordinates that
already net to zero on value and units, and refuses, as scale does,
when nothing in the selection is left to move.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 00:14:00 -04:00
709c9d0395 Read a null on the column axis as null, not as the text "null"
The engine renders a null split_by value as the literal "null" in the
column name (t_tscalar::to_string on an invalid scalar) and an empty
string as ''. cellFilters() passed "null" through as a value, so a
slice over a blank segment_new became segment_new = 'null', matched no
row, and the adjustment wrote nothing. '' was refused outright, on the
mistaken belief that the two blanks joined to the same text.

Both are now mapped back: "null" to null, '' to ''.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 23:24:20 -04:00
94af6a5f13 Let a recode shift its dates, as clone does
date_offset moves the put-back leg of a recode while the negative leg
stays put, so a shifted recode is a timing move (pull forward, push
out); with a shift, set may be empty. Period dimensions on the moved
rows are re-derived from pf.dim_period against the shifted date, except
where the date is null, where the existing label is kept. No shift, no
derivation: a plain recode is unchanged.

The Recode form gets the same "Shift dates" field as Clone, blank by
default and cleared after each post.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 23:03:03 -04:00
2337951b65 Store every log entry's segment and bucket instead of deriving them
pf_segment and pf_bucket were resolved at read time through a chain of
fallbacks (label, tag, note, the version's unlabeled_load; the version's
adjustment defaults), repeated in /agg, the /data cursor and the decorate
proc, so nothing but the pivot could say what bucket a row was in.

Now they are written onto pf.log when the entry is created: a load's
label is required and its bucket defaults to the label; an adjustment is
stamped with the version's adjustment_segment/adjustment_bucket, else
the built-ins. Reads become l.label / l.bucket. A log_named CHECK
guarantees both are present, and 01_schema.sql backfills existing
entries with the old read-time rule so no displayed name changes.

The Baseline page's "Fallback names" become "Defaults for new
adjustments"; unlabeled_load is no longer read.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 22:35:49 -04:00
7d09ab2245 Document the nav, the admin gating, and what left the toolbar
Three sections were describing an app that no longer exists: the
project layout still called Sidebar a 3-step nav and StatusBar a source
and version picker, and the toolbar section still listed Fit widths.

Adds "Choosing a forecast" (the sidebar list, why it is not nested under
the Forecast step, why the source is absent and inferred, and the
forecast/step naming collision that is still undecided) and "Setup and
Baseline are admin-only to write" (which routes carry requireAdmin,
which deliberately do not and why, the fieldset approach, and the note
that a 403 used to blank the app because an error body reached .map
during render).

The toolbar section gains why Fit widths was removed rather than fixed,
and how the Calculating indicator is driven -- including that
perspective-config-update is the obvious hook and reports the end of
the work rather than the start.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 02:02:16 -04:00
5b11222e09 Set the pivot theme through restore(), not setAttribute
The previous commit stopped stored layouts overriding the theme, which
was necessary and not sufficient: nothing was setting it either.
setAttribute('theme', ...) does nothing in 5.4.0 -- the theme is a
ViewerConfig field (ViewerConfig.theme, a theme name) and the element
observes no such attribute -- so the pivot stayed light whatever the
toggle said. Presumably it worked on an older Perspective; CLAUDE.md
documented it and is corrected here too.

A partial restore, so it does not rebuild the view the way applyDepth's
whole-config restore does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 01:18:26 -04:00
87ad5b1997 Fix the depth steppers' off-by-one, and fit row labels on restore
The stepper's depth and ViewConfig's group_by_depth/split_by_depth are
the same unit -- a count of levels to show -- but applyDepth and
applySplitDepth sent d + 1 and every readback subtracted the same 1.
Self-consistent, so the toolbar always agreed with itself, and off by
one against the grid at every position: picking partgroup showed one
level past it. server.cpp turns group_by_depth into set_depth(n - 1)
and t_traversal::set_depth adds the 1 back, so n asks for n levels.

The +1 existed to dodge depth 0, which reaches the engine as
set_depth(-1) on an unsigned t_depth, underflows, and clamps to fully
expanded. The value is floored at 1 instead, so the stepper's "Total"
slot goes on showing the first level as it always has -- the wart is
kept deliberately rather than traded for the underflow. A bare total
needs group_rollup_mode.

Separately: fitRowLabels() has been written and unused since it was
judged to read worse than the default spacing. That judgement has
changed -- row labels come back clipped after a restore -- so it is now
called after both restore paths, initViewer and applyLayout. It is not
routed through the Fit widths button on purpose: that path goes via
regular-table's auto-size, which measures the leaf header's rendered
box over viewport rows only, while fitRowLabels canvas-measures the
label text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 23:44:07 -04:00
cf8b72d737 Default the change log to Mine instead of Everyone
Opening the log is almost always to check your own recent work, not
the whole version's, so it now starts on 'mine' -- still resets on
every open rather than persisting, same reasoning as before, just the
other default.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 23:09:08 -04:00
8bc7d65fce Say which change-log entries are yours
The version this was built against carries 127 adjustments from five accounts,
one of them the reader's, so finding your own meant reading the By column down
the whole list.

Own entries are now marked in place -- a blue rule down the left of the row, the
By cell in blue -- and the modal header carries an Everyone / Mine control with
a count on each. Marked as well as filterable on purpose: the mark tells you
without hiding anyone, the filter is for when you want only yours.

The filter resets to Everyone on every open and is not persisted. A filter that
hides other people's work is fine as a thing you asked for and a trap as a thing
you inherited from last week.

`isMine()` is strict authorship, deliberately not `canUndo()` -- an admin can
undo everything, so marking every row as theirs would say nothing. The fetch
stays unfiltered, since the counts on the control need the whole set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 14:30:03 -04:00
7fd07e52d2 Hold price unless somebody names it
Two defaults both moved price when nobody asked:

A qty edit held the dollars, so price absorbed the whole difference -- 10% more
cases at the same revenue is a 9% price cut, and nothing in the figure being
edited shows it. Dollars now follow qty at the current price:
`value = (curValue / curUnits) * unitsIncr`, on the server in resolveIncrs() and
mirrored in the ledger's Result line. A slice with no units has no price to
hold, so there the dollars stay put and the edit is a pure qty change.

`plug` defaulted to 'price' -- volume holds, price moves -- because that is what
the API did before the flag existed. It now defaults to 'volume'. The client's
stored preference is keyed pf_scale_plug_v2, since a browser carrying the old
'price' choice would otherwise go on moving price silently.

The deliberate routes are unchanged and both outrank the above: name the dollars
and the qty together and price falls out of the two, or name a target_price,
which is the Excel form's Edit Price mode. The ledger labels the column that
cannot move `· holds`, which now includes price on a qty edit.

One behaviour to know about: holding price is impossible on a slice whose value
is zero, so a dollar edit there answers 400 with an explanation where before it
quietly moved price. Fail-loud is the point.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 13:43:06 -04:00
7006014cc3 Isolate a slice on a blank dimension value instead of widening it
Clicking the blank `partgroup` node under ALTMAN PLANTS selected the customer's
whole $27,588,393.62 (6,851 rows) rather than the $6,198,860.42 (2,421) under
the node clicked -- and the ledger agreed, because the panel totals through the
same filter list the operation writes from. The key was not narrowed, it was
absent, and an absent key means every value of it.

The drop is the engine's: `getCellConfig` builds the click's filter list with
`pivot_value ? [pivot, '==', pivot_value] : undefined`, so a blank group key is
gone before `perspective-click` is dispatched -- and being falsy, so is a
dimension whose value is 0. `cellFilters()` re-derives both axes from
`detail.row.__ROW_PATH__` and the clicked column name instead; the click and a
dragged region now share that one derivation rather than keeping two copies of
the same falsy test.

Both blanks are real here and they are different rows: `partgroup` is null on
51,964 and '' on 111,696. So they stay distinct end to end. `buildWhere` renders
a null as IS NULL, where it used to fall through `esc()` to `= ''` and select
*the other blank* -- the slice naming one node while the operation wrote the
other. In a list the null arm is ORed on, since `IN (NULL)` is never true. The
ledger filters with `is null` rather than `== null`, the engine matching on type
and a null threshold against a string column being the same dropped predicate.

Only `undefined` now means absent, which keeps a subtotal row (a shorter row
path) and a collapsed column level reading as "not part of the slice".

A blank on the *column* axis still cannot be isolated: a column name is its
values joined with '|' and a null joins as the empty string, so the two blanks
are the same text by the time it reaches the client. Those cells are refused
with a message rather than widened.

The panel prints (null) and (blank), and keeps em dash for a column genuinely
absent from the slice -- isolating the blank node and ignoring the column are
opposite statements and read identically before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 12:21:06 -04:00
766ccc52bb Scope an operation by a substring filter, case-insensitively
A view narrowed with `partd contains 'Keysto'` could be totalled but not
written: `SCOPE_OPS` carried the comparison and list operators and refused
everything else, so the panel showed a figure the operation answered 400 on.
Refusing is the right default -- a scope silently dropped is a write wider than
the panel that authorised it -- but `contains` is how a text dimension is
actually filtered, the alternative being an IN list of the 180 descriptions
that happen to match today.

ILIKE, not LIKE, because that is what the engine does: `t_tscalar::contains`
lowercases both sides before `std::string::find`, as do `begins_with` and
`ends_with`. On `fc_osm_skinny_34` the difference is 16,864 rows against 35, so
LIKE would have written a fortieth of what the grid displayed. Narrower is no
safer than wider here -- either way it is not the set the ledger counted.

The term is literal in the engine, so `%`, `_` and `\` are escaped out of it
before it becomes a pattern; `50%` means those three characters.

`matches` stays refused. It is RE2 in the engine and Postgres regexes are not
RE2 -- `\b` is a word boundary in one and a backspace in the other -- so a
pattern both accept could match a different set of rows, which is worse than a
400.

Every operator that takes a term now refuses an empty one. An unfinished filter
row in the pivot arrives as `[col, '==', null]`, which used to compile to
`= ''`; as a substring op it would have been `ILIKE '%%'`, which is every
non-null row and therefore no scope at all.

Runtime code, not template: nothing to regenerate, only a restart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 11:53:18 -04:00
99d7fe72d8 Manage a version's tags as a list, not one entry at a time
A tag was only ever a value typed on a pf.log entry, so the only way to
change one was to find every entry carrying it in the change log and edit
each. Nothing showed what the list was, and the bridge groups on this
column -- so "Pull Forward" and "Pulled Forward" read as two initiatives
with no way to make them one.

PATCH /versions/:id/tags rewrites every entry carrying `from`. A `to` that
already exists merges the two, an empty one clears the tag; the panel
confirms both, since neither is visible as a diff afterwards.

Permission is the pf.log rule applied to every entry the tag is on rather
than most of them: a half-applied rename leaves the initiative split, which
is what the rename was for. GET /versions/:id/tags now returns owners and
can_edit so the panel can grey the row out instead of offering a click that
answers 403.

On the Baseline page, next to the other version-level settings -- the
Forecast page edits one entry's tag, this edits the column.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 09:00:27 -04:00
91c53ac6d8 Land on the forecast somebody chose, not the newest one
App.jsx fell back to versions[0] when localStorage held no selection, and
the version list is ordered created_at DESC -- so a fresh browser landed on
whatever version was created last. That is a scratch scenario as readily as
the plan, and sales accounts, which never pick a version themselves, had no
way back to the one they were meant to read.

pf.version.is_default names the landing version, at most one per source by
partial unique index, the way pf.layout.is_default already works. Setting it
is admin-only and enforced in the route: it decides where everyone else
lands, so it follows the pf.log rule rather than being the version's own
setting like its name.

A stored selection still wins -- the default is the fallback, not an
override, so nobody building scenarios is thrown back to the plan on every
load.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 08:34:46 -04:00
aa8ce87560 Offer the tag that already exists
Wording variants are a user problem, but the completion was making it one:
a native datalist substring-matches, so typing "moved to different sku"
never offered "moved to a different sku" -- one inserted word and the
existing tag is invisible. Four spellings of that initiative are on the
current version, with a ledger line each.

TagInput matches on words instead. Both sides lowercased, stripped of
punctuation, relieved of stopwords and lightly stemmed, so Pulled Forward
finds Pull Forward. Typing something that reduces to an existing tag
without being it says so: already used as X, use that.

Nothing is merged automatically. The suggestion is offered and the typing
stays yours -- moved to another sku is genuinely a different sentence from
moved to a different sku, and only the person typing knows whether it is a
different thing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 23:43:25 -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
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
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
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
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
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
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
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
4d2af589b9 Document both axes collapsing through ViewConfig
§Row depth and the observer shim and §Column hierarchy described two
different mechanisms and an asymmetry between the axes that no longer
exists: rows were imperative view state chased by an observer shim, and
columns collapsed by truncating split_by. Both are a depth in ViewConfig
now, so the two sections are one.

Records what matters for reading the code: that the depths are 1-based
against server.cpp's `set_depth(depth - 1)`, that this needed the engine
patch in ui/vendor because apply_update dropped the fields, and that
Session::update_view_config returning early on "no change" is why nothing
happened at all rather than happening wrongly.

Keeps a short account of what was deleted -- the shim, the focus listeners,
the retry loop, splitFull, collapsingRef, the prefix test -- because someone
will otherwise wonder why the surrounding code looks so plain, and because
split_full is still read for layouts saved under the old scheme.

Per-node expansion moves to its own subsection with the reason it is hard
(set-only API, no getter) and what fixing it would take. The stale claim that
the selection is cleared on every column change is gone: with a depth the
dimensions a slice was cut from are all still present.

Also: observerShim.js dropped from the project layout, and the tech stack
now says 5.4.0 from the patched vendored build rather than 5.2.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 12:38:18 -04:00
708e5662ab Correct what is_key does, and record the trap in it
CLAUDE.md said is_key "marks dimensions used in slice WHERE clauses", which
is not true and never was: assertSelective and buildWhere validate against
filterCols, which is every dimension plus every date column regardless.

What it actually drives is the key of a dim_group, value completion, the
sibling autofill trigger, and the dim_period anchor -- and the first of
those wants exactly one column per group while the others want several. When
a group has more than one, find() silently takes the lowest opos, which is
how segment_new outranked part and a master-data refresh keyed on a column
that is null throughout: zero members, reported as success.

Written down because the failure gives no signal at all -- the refresh
succeeds, the list is simply empty, and every symptom appears somewhere
else entirely.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 00:47:20 -04:00
088b6a30c5 Document the plug, the segment columns, and the observer shim
Three things a reader would otherwise have to rediscover, and one of them is
load-bearing: observerShim.js has to be main.jsx's first import or it
silently intercepts nothing, which no amount of reading the shim itself
tells you. Also records the two false trails found while getting there --
getTable() resolving while getView() throws, and getView() returning a fresh
wrapper every call so identity cannot detect a rebuild.

Known issues rewritten against what is actually true now: the operation
panel wiring and the progress-bar throttle are done, and the load-time entry
now says what was measured -- that the cost is row count, not payload, and
dynamic grain is the remaining lever.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 23:48:25 -04:00
51d956caea Merge feature/static-grain: display-grain pre-aggregation
Brings in the /agg endpoint, col_meta.in_grain, the pf_gkey index and the
append/remove write model that replaces undo's full reload. On the live
2,574,287-row forecast this collapses to 32,411 rows at a
rep/customer/channel/season/month grain, with totals tying exactly
(857,792,111.91 either way) and pf_gkey unique across every group.

Three conflicts, all from work done on this branch after the grain branch
was cut:

- sql_generator exports: union of both sides, adding grainOf.
- Forecast.jsx fetchArrow: the grain branch factored the inline progress
  reader into a helper; kept the helper, and the tag/note ledger functions
  beside it, since the two were only textually adjacent.
- Forecast.jsx initViewer: took the grain branch's endpoint selection, but
  dropped its loadPerspective() -- 99375bb replaced that lazy CDN loader
  with a static inline import, so awaiting fetchArrow directly is correct
  here.

Carried the segment labels into grain mode as well: /agg now joins pf.log
the way /data does. pf_logid is part of the grain, so the join adds no
rows. Without it the labels would have disappeared exactly when a source
declared a grain -- which is the mode that will actually be used.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 18:08:20 -04:00
e31a60b18b Re-check the selected source and version against the live list
The selection is restored from localStorage, but it was only validated once
at mount. Deregistering the selected source left App holding an id that no
longer exists, so every subsequent call 404'd "Source not found" with no way
out but a reload — Setup.deleteSource clears its own selectedSource and never
tells App. Deleting the selected version had the same shape.

Move both checks into effects keyed on the lists themselves. A sourcesLoaded
flag keeps the source effect from firing against the initial empty array and
wiping the restored id before the fetch resolves.

Also coerce both refresh callbacks to an array. A 401 returns an error object,
and data.some() then threw inside an unhandled promise, stranding the
selection instead of clearing it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit e6e2faeb37)
2026-09-15 21:31:07 -04:00
c2e6fc8e77 Put the app behind a login
The server had no authentication: every /api route was open, CORS
allowed any origin, and the identity written to the audit log came from
the request body — the UI sent a hardcoded pf_user: 'admin', which any
client could have set to anything it liked.

Accounts live in pf.app_user with scrypt hashes from node's own crypto,
so there is no native build step and the parameters travel with each
hash. Sessions are express-session over connect-pg-simple in pf.session:
a restart no longer signs everyone out, and a session can be revoked by
deleting its row, which is how disable-user cuts off access immediately
rather than at cookie expiry.

Everything under /api except login/logout/me now requires a session, and
the React app is mounted only once there is one — its load effects call
the API on mount, so a logged-out mount would just fire a burst of 401s.
A session that expires while the app is open lands back on the login
screen: auth.jsx wraps fetch once rather than teaching every call site
to check.

Identity is now read from the session for pf_user, created_by and
closed_by, and the body values are ignored.

Hardened for an internet-facing deployment: trust proxy so req.ip and
secure-cookie detection are right behind TLS termination, httpOnly +
SameSite=Lax + Secure cookies, ten login failures per IP per fifteen
minutes, one error message for unknown, wrong and disabled alike, and a
fresh session id on success. CORS is off entirely unless CORS_ORIGIN
names an origin — a wildcard alongside a session cookie would be CSRF by
construction. The server refuses to boot without SESSION_SECRET rather
than falling back to a guessable default.

pf.sh grows add-user, passwd, list-users, disable-user and enable-user;
passwords are read on stdin and hashed before they reach psql, so no
plaintext in argv or shell history. install.sh generates the secret,
applies 02_auth.sql, and creates the first account.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:20:25 -04:00
4b9296abc1 Collapse the column hierarchy from the toolbar
The row axis has had Expand 0/1/2/3 since the pivot landed; the column
axis had nothing. With a year over month split there was no way to step
back to whole years short of dragging split_by apart in the settings
panel and putting it back afterwards.

Perspective gives the two axes nothing in common here. Rows collapse
because the GROUP BY ROLLUP view holds every level at once and
view.set_depth() hides the deeper ones. For columns there is no
equivalent: expand()/collapse() take a row index, ViewConfig has
group_by_depth but no split_by_depth, and split_rollup_mode only chooses
whether subtotal column groups are emitted — a view shape, not an
interaction. So applySplitDepth() collapses by restoring a truncated
split_by, which rebuilds the view.

Three consequences of that rebuild, each handled:

- Once collapsed, viewer.save() only reports the short split_by, so the
  full hierarchy is held separately (splitFullRef) and persisted into the
  layout as split_full. Without it, collapsing would be a one-way door:
  reload while collapsed and the deeper levels are gone. adoptSplit() is
  the single place it is set.
- perspective-config-update fires for our own restore as well as for the
  user rearranging the pivot, and the two mean opposite things — one must
  adopt the new hierarchy, the other must not. collapsingRef separates
  them.
- Row depth lives on the discarded view, so it is re-applied afterwards.

The selection is cleared on each change: slices name the split_by
dimensions they were cut from, and the highlight is keyed on grid
coordinates. Neither survives a column axis that just changed shape.

Buttons are named for the level they show — Total, then one per split_by
column — rather than numbered like Expand, since the levels are named and
a number would say nothing about what you are collapsing to.

Whole-axis, not per-branch: Excel can collapse 2025 while 2026 stays
expanded, and this cannot. `columns` selects which measures appear, not
individual split combinations, so there is no way to hide one branch's
leaves while keeping another's.

Verified in the browser against cash/test with split_by Year x Reason:
Reason -> Total -> Year -> Reason all render the expected column sets and
the right button highlights; and a reload while collapsed to Year comes
back collapsed with Reason still offered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoxNi8cFsQLPUSw3obb5NH
2026-09-12 09:15:15 -04:00
a0d39d44c0 Merge perspective-inline-5.2.0: 5.2.0 pin, multi-slice ops, bridge, drag-select
Brings the Perspective work forward onto the display-grain decision in
c5b12aa. Four commits:

- Load Perspective from the bundled /inline entrypoints and pin every
  package at 5.2.0, after the 4.x CDN bundle resolved its server WASM to
  an unversioned path and took the app down on 2026-08-10.
- Multi-slice operations (slices array, apply_mode prorate/each),
  per-measure resolution, target_basis, initiative tags, the tagged
  bridge, and the reworked adjustment panel.
- Drag to select a region, and show the selection on the grid.

Merges clean against c5b12aa; the two touch disjoint files.
2026-09-12 09:11:11 -04:00
099925b121 Drag to select a region, and show the selection on the grid
Two gaps in how the pivot reports a selection. Dragging across cells did
nothing, and a selection built from several ctrl-clicks was invisible on
the grid — the panel listed the slices but nothing on screen said which
cells they came from.

Drag-select
- The perspective-select handler was reading detail.selected and
  detail.insertConfigs. In 5.2.0 that event carries a ViewWindow —
  { start_row, end_row, start_col, end_col }; insertConfigs only appears
  on perspective-global-filter, and only in SELECT_ROW_TREE mode. So the
  handler always returned early and the whole path was dead.
- It fires on every mouseover as the region grows, so the handler now
  records the latest window and a window-level mouseup commits it. A
  single-cell region is skipped there: perspective-click already owns
  plain and modifier clicks, and handling it in both places would undo a
  ctrl-click toggle. Modifier+drag adds to the selection.
- Turning a region back into slices re-derives, per cell, the same
  filters Perspective attaches to a click — row dimensions from the
  view's __ROW_PATH__, column dimensions from the split_by segments of
  the column name. Reading the path rather than the rendered cell is
  what keeps dates as epoch millis instead of whatever the grid
  formatted them as. The grand-total row resolves to no dimension at
  all — that would mean the whole version — and is skipped.

Highlight
- The datagrid already highlights whatever sits in its own
  model._selection_state.selected_areas, and wipes that list on every
  mousedown, so a multi-click selection only ever showed the last cell.
  Keep a sliceKey -> rectangles map parallel to `slices` and push the
  full set back after each change, which gets the native highlight for
  every selected cell without any styling of our own.
- Deselecting anywhere — ctrl-click, the panel's x, Clear selection —
  prunes the map by live slice key, so the grid and the panel cannot
  disagree.

Also: edit_mode is forced to SELECT_REGION on restore rather than
defaulted, since a saved layout's plugin_config could previously
override it and turn selection off entirely.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoxNi8cFsQLPUSw3obb5NH
2026-09-12 08:51:14 -04:00
119065ef59 Update spec and CLAUDE.md for multi-slice, tags, and the bridge
Documentation had drifted far enough to mislead: the spec described the
single-slice panel, the target/delta modes that no longer exist, and the
old `slice` request shape, while both files still claimed Perspective
4.4.0 from a CDN when it has been bundled inline at 5.2.0 since August.

pf_spec.md
- pf.log gains `tag`, with why it is written by a follow-up UPDATE
  rather than through the per-source templates in pf.sql.
- Operations envelope documents `slices`, `apply_mode`, and the OR-of-
  AND-groups WHERE clause (and why it cannot be flattened to IN lists).
- Scale documents per-measure resolution and `target_basis`, plus the
  two guards: non-selective slices, and proration across a ~zero pool.
- New routes: PATCH /log/:logid, table-info, bridge, source tags.
- Forecast View rewritten for the dockable panel and the ledger; adds
  the selection caveat around pf_iter and the expand-depth explanation.
- New Bridge View section; Log View gains inline tag editing.
- Status block refreshed, with a Fixed subsection recording the three
  correctness bugs and their causes.
- Open Questions: adds bridge drill-down and targeting one iter band;
  notes what the bridge partially answers.

CLAUDE.md
- Corrects the Perspective version and the CDN claim.
- Project layout now lists components/, including the two new files.
- Selection section covers multi-select and why duplicate effective
  slices are collapsed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1TQiBYZbbWWkMNoCtUd8M
2026-09-11 23:34:08 -04:00
654a368672 Add static display-grain pre-aggregation (col_meta.in_grain)
Ship rows pre-aggregated to the grain the pivot displays instead of raw
forecast rows. This is Path B from pf_perspective_options.md: it keeps
Perspective's native WASM engine — so expand/collapse/depth/sort/filter
all still work — and fixes load time by cutting rows, not transport.

Measured on pf.fc_osm_stack_20 at pending_rep x customer x smon:
534,902 -> 6,154 rows (~87x), pf_gkey unique across all 6,154, and both
measures reconcile exactly to the raw totals.

The grain is static: flagged once per source in Setup and baked into the
stored pf.sql templates, so load and operations agree by construction.
Sources with no flagged column keep the previous raw-row behaviour, so
this is backward compatible.

- pf.col_meta gains in_grain; grainOf() in lib/sql_generator.js is the
  single definition of the grain and is reused by routes/log.js.
- New get_agg template + GET /api/versions/:id/agg, generated only when a
  grain is defined. Regenerating drops templates no longer produced, so
  clearing the grain falls back to /data.
- scale/recode/clone now aggregate their own new rows to grain before
  returning. Because pf_logid is part of pf_gkey those keys are always
  new, so table.update() appends and the view re-sums — the Excel
  pivot-cache pattern, no bucket recomputation.
- Undo reports pf_gkeys (RETURNING cannot take DISTINCT, so the delete
  feeds a CTE that reduces to distinct keys); the client removes those
  index values and the view re-sums.
- pf_gkey is concat_ws(chr(31), COALESCE(col::text, chr(30)), ...).
  The separator and NULL sentinel are load-bearing: plain concat_ws skips
  NULLs, so ('a',NULL) and (NULL,'a') would collide and silently merge two
  groups into one indexed row.
- Forecast.jsx reads col_meta first to pick /agg vs /data; the Arrow
  streaming logic is extracted to fetchArrow() since both share it.
- Setup.jsx gains a grain checkbox and shows the resulting grain.
- 01_schema.sql: move the col_meta ALTERs after its CREATE TABLE — they
  referenced the table before it existed on a fresh install.

All six generated statements verified to plan against the real forecast
table; the in_grain column has been added to the dev database.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 16:25:38 -04:00