Commit Graph

28 Commits

Author SHA1 Message Date
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
c5b12aac76 Record DuckDB virtual-server spike findings; choose display-grain pre-aggregation
Document the outcome of the Option C spike (server-side DuckDB as a
Perspective virtual server) and the resulting architecture decision.

pf_perspective_options.md:
- Spike findings: latency is excellent (~12ms round trip, 5-29ms
  aggregation, ~1.5-1.9s to materialize 534,902 rows) but Perspective's
  GenericSQLVirtualServerModel ignores group_by_depth and has no
  ViewConfig field for per-node expansion state, so interactive
  drill-down is not achievable. This affects options B, C and D alike
  since they share that SQL model.
- Decision: the real lever is grain, not transport. Pre-aggregating to
  display grain collapses 534,902 -> 4,642 rows (~115x) on osm_stack
  while keeping the native Perspective engine, so expand/collapse/
  depth/sort/filter continue to work.
- Two candidate designs (Path A live virtual server vs Path B
  pre-aggregated extract) with the deciding question: do real cuts ever
  exceed the browser's leaf-row ceiling?

pf_spec.md:
- Concrete Path B design: pf.col_meta.in_grain, GET /api/versions/:id/agg,
  synthetic pf_gkey index, and the append-deltas write/undo model that
  mirrors the prior Excel pivot-cache workflow.

Drop pf_ux_mockup.md — an ASCII mockup of UI that is now built; the
views in ui/src/views are the current reference.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 21:47:55 -04:00
64f3cc58e8 Add PERSPECTIVE.md config/deploy reference; fix CLAUDE.md distribution link
Document the @perspective-dev distribution (not FINOS @finos/perspective):
loader (npm /inline vs CDN), the version trilemma (inline needs 4.5.x,
viewer-d3fc caps at 4.4.1, charts need 4.4.1 — can't have all three),
Arrow vs JSON delivery constraints, deploy pattern, and an upgrade smoke
test. Correct CLAUDE.md's stale perspective.finos.org link to the actual
@perspective-dev repo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 23:49:53 -04:00
101cb27604 Update CLAUDE.md and spec: units optional, dim_group/dim_period, delete todo.md
- units role is now optional; spec and CLAUDE.md reflect conditionality in SQL patterns
- pf.col_meta gains dim_group and dim_period_col fields (documented in both files)
- pf.dim_period calendar table added to schema docs
- pf.source default_layout column added to spec DDL
- Forecast table metadata columns corrected to pf_iter/pf_logid/pf_created_at throughout spec
- SQL patterns updated with correct CTE structure and RETURNING * to match generated code
- Project status updated to 2026-06-12; stale Arrow IPC open question removed
- todo.md deleted; open items retained in CLAUDE.md known issues

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 23:27:05 -04:00
c4ba90ae4c note 2026-05-22 23:29:35 -04:00
cf391286a2 Improve theme toggle icons; document light/dark in CLAUDE.md
Replace Bootstrap fill icons with Feather-style stroke SVGs (sun with
rays + crescent moon) in StatusBar toggle.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 22:59:21 -04:00
73e8f5d202 Add CLAUDE.md project instructions and license field to package.json
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-02 20:47:15 -04:00