pf_app/ui/src/views/Forecast.jsx
Paul Trowbridge 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

2352 lines
108 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect, useRef } from 'react'
import useTheme from '../theme.jsx'
import useAuth from '../auth.jsx'
import OperationPanel from '../components/OperationPanel.jsx'
import BridgeView from '../components/BridgeView.jsx'
// Perspective is bundled, not fetched at runtime. The /inline entrypoints embed the
// WASM in the build, so the version is fixed by package-lock.json. Do NOT go back to
// CDN <script>/import URLs: the 4.x CDN bundle resolves its server WASM to an
// unversioned path, so it silently pulls whatever @perspective-dev/server is newest.
import perspective from '@perspective-dev/client/inline'
import '@perspective-dev/viewer/inline'
import '@perspective-dev/viewer-datagrid'
import '@perspective-dev/viewer/themes'
// Slice keys that are not col_meta columns: computed from pf.log when the rows are
// served, so they are real columns in the loaded table but have to be resolved back
// to log ids server-side. Mirrors COMPUTED_SLICE_COLS in lib/sql_generator.js.
const COMPUTED_SLICE_COLS = new Set(['pf_segment', 'pf_bucket'])
const LAYOUT_KEY = (vid) => `pf_layout_v${vid}` // last-used layout (auto restore)
const LAYOUTS_KEY = (vid) => `pf_layouts_v${vid}` // named layout list
function cleanLayout(cfg, validCols) {
if (!cfg) return cfg
const c = { ...cfg }
// Dead expressions go before the axis filter, not after: dropping them from
// `expressions` is what makes `ok()` reject them everywhere else.
if (DEAD_ORDER_EXPRS.some(n => c.expressions?.[n] !== undefined)) {
c.expressions = { ...c.expressions }
for (const name of DEAD_ORDER_EXPRS) delete c.expressions[name]
}
const exprNames = new Set(Object.keys(c.expressions || {}))
const ok = (col) => validCols.has(col) || exprNames.has(col)
if (c.columns) c.columns = c.columns.filter(col => col == null || ok(col))
if (c.group_by) c.group_by = c.group_by.filter(ok)
if (c.split_by) c.split_by = c.split_by.filter(ok)
// the uncollapsed column hierarchy travels with the layout (see applySplitDepth)
if (c.split_full) c.split_full = c.split_full.filter(ok)
if (c.sort) c.sort = c.sort.filter(([col]) => ok(col))
if (c.filter) c.filter = c.filter.filter(([col]) => ok(col))
return c
}
// Expression columns this view used to manage, back when the pf_bucket and
// pf_segment ordering prefix was computed in the pivot rather than stored in
// pf.log.label.
//
// Stripped by cleanLayout, never created: a layout saved under that scheme still
// names them, and without this they would sit there forever, ordering by a rule
// nothing updates. They have to leave the axes at the same time as the
// expressions themselves -- restore() rejects a config whose group_by or sort
// names a column that no longer exists.
const DEAD_ORDER_EXPRS = ['pf_bucket_ord', 'pf_segment_ord', 'Bucket', 'Segment']
export default function Forecast({ sources = [], sourceId, versions = [], versionId, refreshSources }) {
const { dark } = useTheme()
const { user } = useAuth()
// Undo removes an entry's rows wholesale, so the server allows it only to the
// account that made it, or an admin. Mirrored here to say so before the click
// rather than after the 403.
const canUndo = (entry) => !!user && (user.is_admin || entry.pf_user === user.username)
const [loading, setLoading] = useState(false)
const [largeDataset, setLargeDataset] = useState(false)
// The pivot's own filter, refreshed whenever the ledger recomputes. A ref
// rather than state: it is read at dispatch and at totals time, never
// rendered from directly, and making it state would re-run the totals effect
// that sets it.
const viewFilterRef = useRef([])
const [loadProgress, setLoadProgress] = useState(null) // { received, total }
// Rows the load is waiting on, so the wait can say what it is waiting for --
// on this data the row count is the wait (see CLAUDE.md, "Load time is
// dominated by row count").
//
// Filled twice. X-Row-Count is exact but arrives with the response headers,
// and in grain mode the server aggregates before sending any: the number
// turned up just as the wait ended. So the forecast table's own count goes in
// first, from the same table-info the status bar reads, and the exact figure
// replaces it when the headers land.
const [loadRows, setLoadRows] = useState(null)
// the same filter, for display: the panel has to show the scope it is acting
// inside or the slice it prints is not the slice that gets written
const [viewScope, setViewScope] = useState([])
const [msg, setMsg] = useState(null)
// layouts
const [layouts, setLayouts] = useState([])
const [activeLayoutId, setActiveLayoutId] = useState(null)
const [showSaveAs, setShowSaveAs] = useState(false)
const [saveAsName, setSaveAsName] = useState('')
// operation panel — a selection is a LIST of slices; one entry is the common case
// The column hierarchy and how many of its levels are showing. Mirrored into
// splitFullRef for handlers registered once; held as state so the toolbar
// re-renders when either changes.
const [splitFull, setSplitFull] = useState([])
const [splitDepth, setSplitDepth] = useState(null)
const [slices, setSlices] = useState([])
const [applyMode, setApplyMode] = useState('prorate') // 'prorate' | 'each'
const [activeOp, setActiveOp] = useState('scale')
const [currentTotals, setCurrentTotals] = useState(null) // { value, units }
// One entry per measure ('value' | 'units' | 'price'), each { field, raw } where
// field is 'new' | 'change' | 'pct'. There is no separate mode toggle: the row
// you type in decides how the number is interpreted, and the other two rows of
// that measure are derived from it.
const [scaleInputs, setScaleInputs] = useState({})
// which side of price x volume absorbs a dollar-only change: 'price' keeps
// volume flat, 'volume' keeps price flat. Mirrors the Excel form's
// Plug Price / Plug Volume choice.
const [scalePlug, setScalePlug] = useState(() => localStorage.getItem('pf_scale_plug') || 'price')
// which change-log row has its payload open, if any
const [expandedLog, setExpandedLog] = useState(null)
// master data per dim_group, fetched once per source: { part: { key_col, siblings, members } }
const [dimMembers, setDimMembers] = useState({})
// clone can borrow its mix from a named segment instead of the current selection
const [cloneFrom, setCloneFrom] = useState('')
const [cloneOffset, setCloneOffset] = useState('12 months')
// what a target/percentage is measured against: the rows this operation can
// write, or everything the pivot shows for the slice (excluded rows included)
const [targetBasis, setTargetBasis] = useState('selected')
// one tag spans all three operations — an initiative is not per-operation
const [opTag, setOpTag] = useState('')
const [knownTags, setKnownTags] = useState([])
// logid -> { tag, note, operation }, so ledger lines can name themselves
const [logMeta, setLogMeta] = useState({})
const [scaleNote, setScaleNote] = useState('')
const [recodeSet, setRecodeSet] = useState({})
const [recodeNote, setRecodeNote] = useState('')
const [cloneSet, setCloneSet] = useState({})
const [cloneScale, setCloneScale] = useState('1')
const [cloneNote, setCloneNote] = useState('')
// panel placement: 'bottom' | 'right' | 'float'. Persisted so it stays where you put it.
const [dock, setDock] = useState(() => localStorage.getItem('pf_dock') || 'bottom')
const [panelOpen, setPanelOpen] = useState(() => localStorage.getItem('pf_panel_open') !== 'closed')
const [panelWidth, setPanelWidth] = useState(() => Number(localStorage.getItem('pf_panel_w')) || 360)
const [panelHeight, setPanelHeight] = useState(() => Number(localStorage.getItem('pf_panel_h')) || 260)
useEffect(() => { localStorage.setItem('pf_dock', dock) }, [dock])
useEffect(() => { localStorage.setItem('pf_scale_plug', scalePlug) }, [scalePlug])
useEffect(() => { localStorage.setItem('pf_panel_open', panelOpen ? 'open' : 'closed') }, [panelOpen])
// Esc closes the panel — the usual way out of the floating window
useEffect(() => {
if (!panelOpen) return
const onKey = (e) => {
if (e.key !== 'Escape') return
const t = e.target
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return
setPanelOpen(false)
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [panelOpen])
useEffect(() => { localStorage.setItem('pf_panel_w', String(panelWidth)) }, [panelWidth])
useEffect(() => { localStorage.setItem('pf_panel_h', String(panelHeight)) }, [panelHeight])
// Floating panel geometry — free position and size, unlike the docked modes
// which only have one adjustable axis. null until first opened, so the default
// can be placed against the actual viewport.
const [floatRect, setFloatRect] = useState(() => {
try {
const saved = JSON.parse(localStorage.getItem('pf_float_rect') || 'null')
if (saved && saved.w > 0 && saved.h > 0) return saved
} catch {}
return null
})
useEffect(() => {
if (floatRect) localStorage.setItem('pf_float_rect', JSON.stringify(floatRect))
}, [floatRect])
// keep at least a corner of the window on screen, whatever the viewport does
function clampRect(r) {
const w = Math.max(300, Math.min(r.w, window.innerWidth - 16))
const h = Math.max(180, Math.min(r.h, window.innerHeight - 16))
const KEEP = 100 // px of the window that must stay reachable
return {
w, h,
x: Math.max(KEEP - w, Math.min(r.x, window.innerWidth - KEEP)),
y: Math.max(0, Math.min(r.y, window.innerHeight - 40)),
}
}
useEffect(() => {
if (dock !== 'float' || floatRect) return
const w = 440, h = Math.min(460, window.innerHeight - 120)
setFloatRect(clampRect({ x: window.innerWidth - w - 24, y: window.innerHeight - h - 24, w, h }))
}, [dock, floatRect])
useEffect(() => {
if (dock !== 'float') return
const onResize = () => setFloatRect(r => r ? clampRect(r) : r)
window.addEventListener('resize', onResize)
return () => window.removeEventListener('resize', onResize)
}, [dock])
// shared pointer-drag loop for both moving and resizing the floating window
function startFloatDrag(e, apply) {
if (!floatRect) return
e.preventDefault()
const sx = e.clientX, sy = e.clientY
const r0 = floatRect
const onMove = (ev) => setFloatRect(clampRect(apply(r0, ev.clientX - sx, ev.clientY - sy)))
const onUp = () => {
window.removeEventListener('mousemove', onMove)
window.removeEventListener('mouseup', onUp)
document.body.style.userSelect = ''
}
document.body.style.userSelect = 'none'
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onUp)
}
const onFloatMove = (e) => {
if (e.target.closest('button')) return // let the dock switcher work
startFloatDrag(e, (r, dx, dy) => ({ ...r, x: r.x + dx, y: r.y + dy }))
}
const onFloatResize = (e) => {
e.stopPropagation()
startFloatDrag(e, (r, dx, dy) => ({ ...r, w: r.w + dx, h: r.h + dy }))
}
// history modal
const [showLog, setShowLog] = useState(false)
const [showBridge, setShowBridge] = useState(false)
const [logEntries, setLogEntries] = useState([])
const [logLoading, setLogLoading] = useState(false)
const [editingCell, setEditingCell] = useState(null) // { id, field: 'note'|'tag', text }
const [undoingId, setUndoingId] = useState(null)
const viewerRef = useRef(null)
const workerRef = useRef(null)
const tableRef = useRef(null)
const colMetaRef = useRef([])
// Mirrors ViewConfig.group_by_depth for the toolbar's active state. The
// config is the source of truth; this only exists so the buttons can render.
const [expandDepth, setExpandDepth] = useState(null)
// The column axis has no set_depth() — collapsing it means restoring a shorter
// split_by, so the full hierarchy has to be remembered separately to expand again.
const splitFullRef = useRef([])
// a collapse from the user rearranging split_by themselves
const initIdRef = useRef(0)
const modifierRef = useRef(false)
// the datagrid plugin element, for reading cell coordinates and driving its
// own selection highlight (see the selection-highlight effect below)
const gridRef = useRef(null)
// { x, y } of the body cell the last mousedown landed on, or null
const downCellRef = useRef(null)
// the region a drag is building, as a Perspective ViewWindow; committed on mouseup
const regionRef = useRef(null)
// sliceKey -> the grid rectangles that produced it. Kept parallel to `slices` so
// deselecting a slice removes exactly the cells that contributed it.
const areasRef = useRef(new Map())
// the current selection, readable from event handlers that were registered once
const slicesRef = useRef([])
// Perspective's ROLLUP view contains every level of the hierarchy; view.set_depth()
// is the only thing hiding the deeper ones, and it lives on the view rather than in
// the saved config. The viewer rebuilds its view when it redraws — which its
// Intersection/ResizeObserver triggers when you switch back to the tab — and the
// fresh view has no depth set, so the whole tree appears expanded. Re-apply the
// depth we last set once the redraw has settled.
// perspective-click fires as a CustomEvent with no modifier state of its own,
// and carries no cell coordinates either, so record both from the mousedown that
// precedes it. The grid lives in a shadow root, so the real target is the head of
// the composed path, not event.target (which retargets to the host).
useEffect(() => {
const onDown = (e) => {
modifierRef.current = e.ctrlKey || e.metaKey || e.shiftKey
downCellRef.current = cellAt(e)
}
window.addEventListener('mousedown', onDown, true)
return () => window.removeEventListener('mousedown', onDown, true)
}, [])
// Reading the coordinates of the body cell under a mouse event, or null if the
// event did not land on one.
function cellAt(e) {
const table = gridRef.current?.regular_table
if (!table) return null
const target = (e.composedPath?.()[0]) || e.target
try {
const meta = table.getMeta(target)
if (meta?.type === 'body' && meta.x != null && meta.y != null) return { x: meta.x, y: meta.y }
} catch {
return null
}
return null
}
// Drag-select commit. perspective-select fires continuously while the mouse moves
// (once per mouseover, as the potential region grows), so the event handler only
// records the latest window and the commit happens here, on mouseup. A single-cell
// region is left alone: perspective-click already owns plain and modifier clicks,
// and handling it twice would undo a ctrl-click toggle.
useEffect(() => {
const onUp = async () => {
const win = regionRef.current
regionRef.current = null
if (!win || win.start_row == null || win.end_row == null) return
const nRows = win.end_row - win.start_row
const nCols = (win.end_col ?? 1) - (win.start_col ?? 0)
if (nRows <= 1 && nCols <= 1) return
const found = await slicesFromRegion(win)
if (!found.length) return
const additive = modifierRef.current
if (!additive) areasRef.current.clear()
let next = additive ? slicesRef.current : []
for (const { slice, area } of found) {
const k = sliceKey(slice)
areasRef.current.set(k, [...(areasRef.current.get(k) || []), area])
next = addSlice(next, slice)
}
setSlices(next)
}
window.addEventListener('mouseup', onUp)
return () => window.removeEventListener('mouseup', onUp)
}, [])
// A selected region is a rectangle of grid coordinates; turning it back into slices
// means re-deriving, per cell, the same [col, '==', value] filters Perspective
// attaches to a click. Row dimensions come from the view's __ROW_PATH__ (raw values,
// so dates stay epoch millis rather than whatever the grid formatted them as), and
// column dimensions from the split_by segments of the column name.
async function slicesFromRegion(win) {
const viewer = viewerRef.current
if (!viewer) return []
const cfg = await viewer.save()
const view = await viewer.getView()
const groupBy = cfg.group_by || []
const splitBy = cfg.split_by || []
const base = cfg.filter || []
const rows = await view.to_json({ start_row: win.start_row, end_row: win.end_row })
if (!rows.length) return []
const userKeys = Object.keys(rows[0]).filter(k => !META_COL_RE.test(k))
const c0 = win.start_col ?? 0
const c1 = win.end_col ?? userKeys.length
const out = []
for (let i = 0; i < rows.length; i++) {
const path = rows[i].__ROW_PATH__ || []
const rowFilters = groupBy
.map((col, ix) => (path[ix] ? [col, '==', path[ix]] : null))
.filter(Boolean)
// the grand-total row resolves to no dimension at all, which would mean
// "the whole version" — never what dragging over it is asking for
if (groupBy.length && !rowFilters.length) continue
for (let c = c0; c < c1; c++) {
const key = userKeys[c]
if (!key) continue
// A column name is dimension values joined by | with the measure last, and
// only as many values as the axis currently shows -- collapse the column
// hierarchy and the deeper levels are simply absent. Mapping split_by
// positionally over every segment therefore read the measure as a value
// for the first collapsed dimension: a bucket subtotal came back as
// smon_e = 'sales_usd', which matches no row, so the operation silently
// had nothing to act on. Drop the measure, then map over what is left.
const segs = key.split('|').slice(0, -1)
const colFilters = splitBy
.slice(0, segs.length)
.map((col, ix) => {
const v = segs[ix]
return (v && !META_COL_RE.test(v)) ? [col, '==', v] : null
})
.filter(Boolean)
const slice = sliceFromFilters([...base, ...rowFilters, ...colFilters],
(cfg.columns || []).filter(Boolean))
if (!Object.keys(slice).length) continue
const y = win.start_row + i
out.push({ slice, area: { x0: c, x1: c, y0: y, y1: y } })
}
}
return out
}
// The datagrid highlights whatever sits in its own `selected_areas`, and wipes that
// list on every mousedown — so a ctrl-click selection built up over several clicks
// would only ever show the last cell. Push the full set back after each change and
// redraw, which gets the native highlight for every selected cell for free.
useEffect(() => {
slicesRef.current = slices
const grid = gridRef.current
const state = grid?.model?._selection_state
if (!state) return
const live = new Set(slices.map(sliceKey))
for (const k of [...areasRef.current.keys()]) {
if (!live.has(k)) areasRef.current.delete(k)
}
state.selected_areas = [...areasRef.current.values()].flat()
state.dirty = true
grid.regular_table?.draw({ preserve_width: true })?.catch?.(() => {})
}, [slices])
function onDragStart(e) {
e.preventDefault()
const vertical = dock === 'bottom'
const start = vertical ? e.clientY : e.clientX
const startVal = vertical ? panelHeight : panelWidth
const onMove = (ev) => {
const delta = (vertical ? ev.clientY : ev.clientX) - start
if (vertical) setPanelHeight(Math.max(140, Math.min(window.innerHeight - 160, startVal - delta)))
else setPanelWidth(Math.max(260, Math.min(760, startVal - delta)))
}
const onUp = () => { window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp) }
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onUp)
}
useEffect(() => {
if (!versionId || !sourceId) return
loadLayouts(versionId)
initViewer(versionId, sourceId)
}, [versionId, sourceId])
useEffect(() => {
if (viewerRef.current) {
viewerRef.current.setAttribute('theme', dark ? 'Pro Dark' : 'Pro Light')
}
}, [dark, versionId])
useEffect(() => {
const keys = new Set(slices.flatMap(sl => Object.keys(sl)))
const blank = Object.fromEntries([...keys].map(k => [k, '']))
setRecodeSet(blank)
setCloneSet(blank)
setScaleInputs({})
if (slices.length > 0) fetchCurrentTotals(slices)
else setCurrentTotals(null)
}, [slices])
// Totals for the current selection. Perspective view filters are AND-only, so a
// union of slices can't be expressed as one view — each slice gets its own view
// and the results are summed. Per-slice totals are kept so the panel can show
// exactly what is selected rather than one opaque number.
async function fetchCurrentTotals(sliceList) {
if (!tableRef.current || !sliceList.length) { setCurrentTotals(null); return }
const valueCol = colMetaRef.current.find(c => c.role === 'value')?.cname
const unitsCol = colMetaRef.current.find(c => c.role === 'units')?.cname
if (!valueCol && !unitsCol) return
const version = versions.find(v => String(v.id) === String(versionId))
const excludeIters = new Set(version?.exclude_iters || ['reference'])
const dimNames = new Set(colMetaRef.current.filter(c => c.role === 'dimension').map(c => c.cname))
const dateNames = new Set(colMetaRef.current.filter(c => c.role === 'date').map(c => c.cname))
const ITER_ORDER = ['baseline', 'scale', 'recode', 'clone']
// A slice carries every value as a string -- it is built from filters the
// grid reports and from a payload that has to survive JSON. Perspective
// matches on type, so a string '2027' against an integer column does not
// filter to nothing, it is dropped: the ledger then totalled rows the pivot
// was hiding, which is how a season filter on sseas_e went unnoticed while
// the numbers disagreed by exactly the out-of-season rows.
// The pivot's own filter is not part of a clicked slice -- perspective-click
// reports only the cell's own dimensions -- so the ledger has to read it off
// the viewer and apply it alongside. Without this the ledger totals rows the
// grid is hiding, and the operation writes them: a grid scoped to
// sseas_e = 2027 gave a cell of 921,225.71 against a ledger of 956,485.13.
//
// Taken from viewer.save(), so the values are already in the table's own
// types and the operators are whatever the user set -- ranges and in-lists
// included, which a slice cannot express.
const viewFilter = await (async () => {
try {
const cfg = await viewerRef.current?.save()
return (cfg?.filter || []).filter(f => Array.isArray(f) && f.length >= 2)
} catch { return [] }
})()
viewFilterRef.current = viewFilter
setViewScope(viewFilter)
const schema = await tableRef.current.schema()
const typed = (col, val) => {
switch (schema[col]) {
case 'integer': case 'float': return Number(val)
case 'boolean': return val === true || val === 'true'
case 'date': case 'datetime': return Number(val)
default: return String(val)
}
}
async function totalsFor(sliceObj) {
// pf_segment and pf_bucket are computed server-side but are ordinary
// columns in the loaded table, so here they filter directly. They have to
// be applied, or the ledger totals a wider selection than the operation
// will write.
const filters = [
...viewFilter,
...Object.entries(sliceObj)
.filter(([col]) => COMPUTED_SLICE_COLS.has(col) || dimNames.has(col) || dateNames.has(col)
|| schema[col] !== undefined)
// a cell inside the filtered view cannot contradict it, so a repeated
// column is the same predicate twice and harmless
.filter(([col]) => !viewFilter.some(f => f[0] === col))
.map(([col, val]) => [col, '==', typed(col, val)]),
]
const view = await tableRef.current.view({ filter: filters })
const rows = await view.to_json()
await view.delete()
const buckets = new Map()
// one ledger line per change: the baseline, then each operation that has
// touched this slice since, keyed by its log id so they read in the order
// they were applied
const entries = new Map()
// rows the pivot shows but operations cannot write (usually 'reference').
// Kept separate rather than filtered away: the grid total includes them,
// so the panel has to account for them or the two disagree.
// Per segment, not one lump: YTD Sales and Open Orders are different
// things, and a single "final" line hides which part of the number is
// which.
const excluded = { value: 0, units: 0, rows: 0, names: new Set(), bySegment: new Map() }
for (const r of rows) {
const k = r.pf_iter || '?'
const val = valueCol ? (parseFloat(r[valueCol]) || 0) : 0
const uni = unitsCol ? (parseFloat(r[unitsCol]) || 0) : 0
if (excludeIters.has(k)) {
excluded.value += val
excluded.units += uni
excluded.rows += 1
// Name them by what they are, not by the iter band that happens to
// exclude them: "02 - Prior Year" means something to a forecaster,
// "reference" is the mechanism.
const name = String(r.pf_segment || 'excluded')
excluded.names.add(name)
const seg = excluded.bySegment.get(name) || { name, value: 0, units: 0, rows: 0 }
seg.value += val
seg.units += uni
seg.rows += 1
excluded.bySegment.set(name, seg)
continue
}
const t = buckets.get(k) || { value: 0, units: 0 }
t.value += val
t.units += uni
buckets.set(k, t)
const ek = k === 'baseline' ? 'baseline' : `log:${r.pf_logid}`
const e = entries.get(ek) || { key: ek, iter: k, logid: r.pf_logid ?? null, value: 0, units: 0 }
e.value += val
e.units += uni
entries.set(ek, e)
}
const byIter = Array.from(buckets, ([iter, t]) => ({ iter, ...t }))
.sort((a, b) => {
const ai = ITER_ORDER.indexOf(a.iter), bi = ITER_ORDER.indexOf(b.iter)
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi)
})
const total = byIter.reduce(
(acc, r) => ({ value: acc.value + (r.value || 0), units: acc.units + (r.units || 0) }),
{ value: 0, units: 0 })
return { byIter, byEntry: Array.from(entries.values()), total, excluded, rows: rows.length }
}
try {
const perSlice = []
for (const sl of sliceList) {
perSlice.push({ slice: sl, ...(await totalsFor(sl)) })
}
// roll the per-slice iter buckets up into one combined breakdown
const combined = new Map()
for (const ps of perSlice) {
for (const r of ps.byIter) {
const t = combined.get(r.iter) || { value: 0, units: 0 }
t.value += r.value || 0
t.units += r.units || 0
combined.set(r.iter, t)
}
}
const byIter = Array.from(combined, ([iter, t]) => ({ iter, ...t }))
.sort((a, b) => {
const ai = ITER_ORDER.indexOf(a.iter), bi = ITER_ORDER.indexOf(b.iter)
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi)
})
const total = byIter.reduce(
(acc, r) => ({ value: acc.value + (r.value || 0), units: acc.units + (r.units || 0) }),
{ value: 0, units: 0 })
// same rollup for the ledger: baseline first, then adjustments by log order
const entryMap = new Map()
for (const ps of perSlice) {
for (const e of ps.byEntry || []) {
const t = entryMap.get(e.key) || { ...e, value: 0, units: 0 }
t.value += e.value || 0
t.units += e.units || 0
entryMap.set(e.key, t)
}
}
const byEntry = Array.from(entryMap.values()).sort((a, b) => {
if (a.key === 'baseline') return -1
if (b.key === 'baseline') return 1
return (a.logid ?? 0) - (b.logid ?? 0)
})
const excluded = perSlice.reduce(
(acc, ps) => ({
value: acc.value + (ps.excluded?.value || 0),
units: acc.units + (ps.excluded?.units || 0),
rows: acc.rows + (ps.excluded?.rows || 0),
names: new Set([...acc.names, ...(ps.excluded?.names || [])]),
bySegment: (() => {
const m = acc.bySegment
for (const seg of (ps.excluded?.bySegment?.values?.() || [])) {
const t = m.get(seg.name) || { name: seg.name, value: 0, units: 0, rows: 0 }
t.value += seg.value; t.units += seg.units; t.rows += seg.rows
m.set(seg.name, t)
}
return m
})(),
}), { value: 0, units: 0, rows: 0, names: new Set(), bySegment: new Map() })
setCurrentTotals({
byIter, byEntry, total, valueCol, unitsCol, perSlice,
excluded: {
...excluded,
names: [...excluded.names].sort(),
bySegment: [...excluded.bySegment.values()].sort((a, b) => a.name.localeCompare(b.name)),
},
excludedIters: [...excludeIters],
})
} catch {
setCurrentTotals(null)
}
}
// Ledger lines show a tag or note rather than a bare log id, and the tag box
// completes from initiatives already used on this source.
async function refreshLogMeta(vid) {
if (!vid) { setLogMeta({}); return }
try {
const entries = await fetch(`/api/versions/${vid}/log`).then(r => r.json())
const map = {}
for (const e of entries) map[e.id] = {
label: e.label || null,
tag: e.tag || null, note: e.note || null, operation: e.operation,
bucket: e.bucket || null,
}
setLogMeta(map)
} catch { setLogMeta({}) }
}
async function refreshTags(sid) {
if (!sid) { setKnownTags([]); return }
try {
const rows = await fetch(`/api/sources/${sid}/tags`).then(r => r.json())
setKnownTags(Array.isArray(rows) ? rows : [])
} catch { setKnownTags([]) }
}
// One request per dim_group that has an is_key. The lists are small enough to
// hold whole -- 11,290 parts -- which is what lets completion and the sibling
// autofill run locally instead of querying the source per keystroke.
useEffect(() => {
if (!sourceId) { setDimMembers({}); return }
let cancelled = false
;(async () => {
// col_meta is fetched here rather than read from colMetaRef: that ref is
// filled inside initViewer, which is async, so this effect ran first, saw an
// empty array, found no groups and never fetched a list -- and a ref changing
// does not re-run an effect, so it never recovered. It is a small query and
// the browser will serve it from cache anyway.
let meta = []
try {
meta = await fetch(`/api/sources/${sourceId}/cols`).then(r => r.ok ? r.json() : [])
} catch { return }
if (cancelled) return
const groups = [...new Set(meta.filter(c => c.dim_group && c.is_key).map(c => c.dim_group))]
const loaded = {}
for (const g of groups) {
try {
const r = await fetch(`/api/sources/${sourceId}/dim/${encodeURIComponent(g)}`)
if (r.ok) loaded[g] = await r.json()
} catch { /* a group with no members yet just falls back */ }
}
if (!cancelled) setDimMembers(loaded)
})()
return () => { cancelled = true }
}, [sourceId, versionId])
useEffect(() => { refreshLogMeta(versionId) }, [versionId])
// Relabelling on the Baseline page needs a reload to show here: the label is
// part of the aggregated row, so the pivot cannot re-derive it in place.
useEffect(() => { refreshTags(sourceId) }, [sourceId])
// Stream an Arrow IPC endpoint into one buffer, reporting download progress.
// Both /data and /agg speak the same protocol — a single record batch plus an
// X-Row-Count header — so the caller only picks the URL.
async function fetchArrow(url) {
const r = await fetch(url)
if (!r.ok) { const { error } = await r.json(); throw new Error(error || 'Failed to load data') }
const rowCount = parseInt(r.headers.get('X-Row-Count') || '0')
const total = parseInt(r.headers.get('Content-Length') || '0') || null
if (rowCount) setLoadRows(rowCount)
const reader = r.body.getReader()
const chunks = []
let received = 0
let lastUpdate = 0
setLoadProgress({ received: 0, total })
while (true) {
const { done, value } = await reader.read()
if (done) break
chunks.push(value)
received += value.byteLength
const now = Date.now()
if (now - lastUpdate >= 100) {
setLoadProgress({ received, total })
lastUpdate = now
}
}
setLoadProgress({ received, total })
const merged = new Uint8Array(received)
let pos = 0
for (const c of chunks) { merged.set(c, pos); pos += c.byteLength }
return { buffer: merged.buffer, rowCount }
}
function loadLayouts(vid) {
const stored = localStorage.getItem(LAYOUTS_KEY(vid))
setLayouts(stored ? JSON.parse(stored) : [])
setActiveLayoutId(null)
}
async function initViewer(vid, sid) {
const viewer = viewerRef.current
if (!viewer) return
const myId = ++initIdRef.current
setLoading(true)
setLargeDataset(false)
setLoadProgress(null)
setLoadRows(null)
// deliberately not awaited: it is a count over the whole forecast table and
// the load must not wait on it
fetch(`/api/versions/${vid}/table-info`)
.then(r => r.ok ? r.json() : null)
.then(info => { if (info?.rows && initIdRef.current === myId) setLoadRows(n => n ?? info.rows) })
.catch(() => {})
setSlices([])
setExpandDepth(null)
adoptSplit([], 0)
try {
// col_meta first — it decides which endpoint to load from, and it is a tiny
// query next to the data fetch it gates.
const meta = await fetch(`/api/sources/${sid}/cols`).then(r => r.json())
colMetaRef.current = meta
// Grain mode: the source declares a display grain, so the server ships rows
// already aggregated to it and the table is indexed on pf_gkey. Without a
// grain we load raw forecast rows indexed on pf_id, as before.
const grainMeta = meta.filter(c => c.in_grain && ['dimension','date'].includes(c.role))
const grainMode = grainMeta.length > 0
const indexCol = grainMode ? 'pf_gkey' : 'pf_id'
const dataResult = await fetchArrow(`/api/versions/${vid}/${grainMode ? 'agg' : 'data'}`)
const { buffer, rowCount } = dataResult
const validCols = new Set(grainMode
? [
...grainMeta.map(c => c.cname),
...meta.filter(c => ['value','units'].includes(c.role)).map(c => c.cname),
'pf_gkey', 'pf_iter', 'pf_logid', 'pf_segment', 'pf_bucket', 'pf_note', 'pf_op',
]
: [
...meta.filter(c => ['dimension','value','units','date'].includes(c.role)).map(c => c.cname),
'pf_id', 'pf_iter', 'pf_logid', 'pf_user', 'created_at', 'pf_segment', 'pf_bucket', 'pf_note', 'pf_op',
])
const tableName = `fc_${vid}`
if (rowCount >= 500000) setLargeDataset(true)
if (myId !== initIdRef.current) return
if (!workerRef.current) workerRef.current = await perspective.worker()
const worker = workerRef.current
// Clean up the previous table — by JS reference first, then by name in the
// worker registry (covers the case where the ref was lost or delete failed).
if (tableRef.current) {
try { await tableRef.current.delete() } catch {}
tableRef.current = null
}
try {
const stale = await worker.open_table(tableName)
if (stale) await stale.delete()
} catch {}
// An empty result gets no index. `[]` carries no columns, so naming one
// aborts the worker outright -- "Specified index `pf_gkey` does not exist
// in dataset" -- and the page dies rather than saying it found nothing.
// Nothing is a legitimate answer: an empty version, or a territory with no
// rows in it.
const opts = rowCount > 0
? { name: tableName, index: indexCol }
: { name: tableName }
tableRef.current = await (rowCount > 0 ? worker.table(buffer, opts) : worker.table([], opts))
if (rowCount === 0) {
flash('No rows to show — the version is empty, or none of it is in your territory', 'error')
}
if (myId !== initIdRef.current) {
try { await tableRef.current.delete() } catch {}
tableRef.current = null
return
}
// Load by direct table reference — avoids "No Table attached" on large datasets
// that occurs when viewer.load(worker) + restore({ table: name }) can't resolve
// the named table in time.
await viewer.load(tableRef.current)
viewer.setAttribute('theme', dark ? 'Pro Dark' : 'Pro Light')
if (!hideSplitTotal()) setTimeout(hideSplitTotal, 400)
// restore last-used layout or build default
// Strip cfg.table — table is already loaded by reference above; a stale name
// in a saved config would cause Perspective to fail the name lookup.
//
// An empty table has no schema at all, so any config naming any column
// aborts the worker -- "Could not get dtype for column `sseas_e`". There
// is nothing to lay out, so nothing is restored; the saved layout stays in
// localStorage and comes back when there are rows again.
const saved = rowCount > 0 ? localStorage.getItem(LAYOUT_KEY(vid)) : null
if (rowCount === 0) {
await viewer.restore({ settings: false, plugin_config: { edit_mode: 'SELECT_REGION' } })
} else if (saved) {
const { table: _t, ...rest } = cleanLayout(JSON.parse(saved), validCols)
const cfg = { ...rest, plugin_config: { ...(rest.plugin_config || {}), edit_mode: 'SELECT_REGION' } }
await viewer.restore(cfg)
// split_full is the legacy key, from when collapsing truncated split_by
adoptSplit(cfg.split_full?.length ? cfg.split_full : cfg.split_by,
cfg.split_by_depth != null ? cfg.split_by_depth - 1 : null)
// restore() has already applied group_by_depth; this only syncs the
// toolbar. expand_depth is the legacy key, from when depth was imperative
// and had to be stored beside the config rather than in it.
if (cfg.group_by_depth != null) setExpandDepth(cfg.group_by_depth - 1)
else if (cfg.expand_depth != null) await applyDepth(cfg.expand_depth)
} else {
const sourceDefault = sources.find(s => String(s.id) === String(sid))?.default_layout
let cfg
if (sourceDefault && Object.keys(sourceDefault).length > 0) {
const { table: _t, ...rest } = cleanLayout(sourceDefault, validCols)
cfg = { ...rest, plugin_config: { ...(rest.plugin_config || {}), edit_mode: 'SELECT_REGION' } }
} else {
const valueCol = meta.find(c => c.role === 'value')?.cname
cfg = {
settings: false,
group_by: ['pf_iter'],
columns: valueCol ? [valueCol] : [],
plugin_config: { edit_mode: 'SELECT_REGION' }
}
}
await viewer.restore(cfg)
adoptSplit(cfg.split_full?.length ? cfg.split_full : cfg.split_by, (cfg.split_by || []).length)
}
// Its own try: the restore above has one, but this sits outside it, and a
// throw here would abort the rest of initViewer silently.
try {
await ensureRowLabelWidth()
} catch (err) {
console.error('[pf-layout] threw', err)
}
if (viewer._pspUpdate) viewer.removeEventListener('perspective-config-update', viewer._pspUpdate)
viewer._pspUpdate = async () => {
try {
// split_by is never truncated now, so the live value is always the real
// hierarchy and can be adopted unconditionally -- no need to tell our own
// collapse apart from the user rearranging the pivot.
const live = await viewer.save()
adoptSplit(live.split_by || [],
live.split_by_depth != null ? live.split_by_depth - 1 : null)
// the plugin element is replaced when the plugin changes, so re-assert
hideSplitTotal()
const cfg = await captureConfig()
if (cfg) await persistLayout(vid, cfg)
} catch {}
}
viewer.addEventListener('perspective-config-update', viewer._pspUpdate)
// click → slice via event filters (Perspective encodes row position as [col,'==',val] triples).
// Plain click replaces the selection; ctrl/cmd/shift-click toggles a slice in or
// out of it, which is what makes multi-slice operations possible.
if (viewer._pspClick) viewer.removeEventListener('perspective-click', viewer._pspClick)
viewer._pspClick = async (e) => {
const detail = e.detail || {}
if (!detail.row) return
const config = await viewer.save()
if (!(config.group_by || []).length) return
const s = sliceFromFilters((detail.config || {}).filter || [],
(config.columns || []).filter(Boolean))
if (!Object.keys(s).length) return
// the CustomEvent carries no modifier flags, so read them off the
// mousedown that produced it (captured on window below)
const additive = modifierRef.current
// the datagrid wipes its highlight on mousedown, so keep this cell's
// rectangle alongside the slice and re-apply the lot (see the effect above)
const cell = downCellRef.current
const k = sliceKey(s)
const next = additive ? toggleSlice(slicesRef.current, s) : [s]
if (!additive) areasRef.current.clear()
if (!next.some(x => sliceKey(x) === k)) areasRef.current.delete(k)
else if (cell) areasRef.current.set(k, [{ x0: cell.x, x1: cell.x, y0: cell.y, y1: cell.y }])
setSlices(next)
}
viewer.addEventListener('perspective-click', viewer._pspClick)
// Region selection (click and drag) reaches us as perspective-select carrying a
// ViewWindow — { start_row, end_row, start_col, end_col } — and fires on every
// mouseover as the region grows. Record the latest and let the window-level
// mouseup commit it; a null detail is the datagrid clearing its selection.
if (viewer._pspSelect) viewer.removeEventListener('perspective-select', viewer._pspSelect)
viewer._pspSelect = (e) => { regionRef.current = e.detail || null }
viewer.addEventListener('perspective-select', viewer._pspSelect)
gridRef.current = await viewer.getPlugin()
applyGroupRules()
setLargeDataset(false)
} catch (err) {
console.error('[initViewer]', err)
flash(err.message || String(err), 'error')
} finally {
setLoading(false)
}
}
// Record the column hierarchy as the uncollapsed truth. Called whenever a layout
// arrives or the user rearranges split_by themselves — but never for our own
// collapse, which would otherwise overwrite the full list with the short one.
// The column axis as it stands, for rendering the collapse buttons. split_by is
// no longer truncated to collapse, so it *is* the hierarchy -- nothing has to be
// remembered alongside it.
function adoptSplit(list, depth) {
const cols = Array.isArray(list) ? list : []
splitFullRef.current = cols
setSplitFull(cols)
setSplitDepth(depth == null ? cols.length : Math.min(depth, cols.length))
}
// Collapse or expand the column axis to `n` split_by levels.
//
// Both axes are now the same shape: a depth in ViewConfig. This used to restore
// a *truncated* split_by instead, because split_by_depth was accepted and then
// dropped by ViewConfig::apply_update -- see ui/vendor's patch. Truncating had
// to be undone to expand again, which is why the full hierarchy needed
// remembering separately (splitFull, persisted as split_full), why our own
// collapse had to be told apart from the user rearranging the pivot
// (collapsingRef and the prefix test), and why the selection was cleared each
// time the axis changed shape.
//
// None of that is needed for a depth. split_by keeps every level, so the
// hierarchy is simply `cfg.split_by`; the depth rides along in saved layouts and
// survives a view rebuild; and the dimensions a slice was cut from are still
// there, so the selection can stay.
async function applySplitDepth(n) {
const viewer = viewerRef.current
if (!viewer) return
const { table: _t, ...cfg } = await viewer.save()
const levels = (cfg.split_by || []).length
if (!levels) return
const depth = Math.max(0, Math.min(n, levels))
try {
// 1-based, as with group_by_depth: server.cpp does
// ctx2->set_depth(HEADER_COLUMN, column_pivot_depth - 1)
await viewer.restore({ ...cfg, split_by_depth: depth + 1 })
setSplitDepth(depth)
} catch (err) {
console.error('[applySplitDepth]', err)
flash(err.message || String(err), 'error')
return
}
try {
const cfg2 = await captureConfig()
if (cfg2) await persistLayout(versionId, cfg2)
} catch (err) {
console.error('[applySplitDepth persist]', err)
}
}
// Rule off the column groups, and mark each group's subtotal.
//
// Scanning across "prior · plan · forecast, each with twelve months and a
// total" is twelve columns of identical-looking numbers with nothing to say
// where one domain ends and the next begins. The annual figures and the
// monthly ones read as one run.
//
// Done through regular_table's style listener rather than CSS: which column
// starts a group, and which one is a group's subtotal, are facts about the
// data that only the cell metadata knows. The listener runs on every draw, so
// it survives scrolling and virtualisation -- a stylesheet cannot, since the
// DOM cells are recycled across columns as you scroll.
// Blank for this purpose means "no value at this level", which Perspective
// writes as a zero-width space rather than an empty string.
const notBlank = (v) =>
v != null && String(v).replace(/[\s\u200b-\u200d\ufeff]/g, '') !== ''
function applyGroupRules() {
const grid = gridRef.current
const table = grid?.regular_table
if (!table || table._pfGroupRules) return
table._pfGroupRules = true
// The grid lives in a shadow root, so a stylesheet on the page cannot reach
// these cells. Inject into whichever root actually contains the table.
// currentColor rather than a fixed grey, so the rule follows the theme
// instead of vanishing against Pro Dark.
const root = table.getRootNode() || document
if (!root.querySelector('#pf-group-rules')) {
const style = document.createElement('style')
style.id = 'pf-group-rules'
style.textContent = `
td.pf-group-start { border-left: 2px solid currentColor; opacity: 1; }
td.pf-subtotal { font-weight: 600; background: color-mix(in srgb, currentColor 7%, transparent); }
`
;(root.head || root).appendChild(style)
}
table.addStyleListener(() => {
const body = table.querySelectorAll('tbody td')
// The deepest column path is a leaf; anything shorter is an aggregate of
// the levels below it, which is what makes a subtotal a subtotal.
//
// The empty levels are not empty strings. Perspective pads a subtotal's
// path with zero-width spaces -- ['04 - Forecast', '\u200b', 'sales_usd']
// -- so every path is the same length and a naive `!== ''` test finds no
// subtotals at all.
let depth = 0
const metas = []
for (const td of body) {
let meta
try { meta = table.getMeta(td) } catch { meta = null }
metas.push([td, meta])
const path = meta?.column_header
if (Array.isArray(path)) depth = Math.max(depth, path.filter(notBlank).length)
}
let prevGroup = null
for (const [td, meta] of metas) {
td.classList.remove('pf-group-start', 'pf-subtotal')
const path = meta?.column_header
if (!Array.isArray(path) || !path.length) { prevGroup = null; continue }
const named = path.filter(notBlank)
const group = named[0]
if (group !== prevGroup) { td.classList.add('pf-group-start'); prevGroup = group }
if (named.length < depth) td.classList.add('pf-subtotal')
}
})
table.draw()
}
// Size every column to its contents.
//
// Values fit on their own: draw() calls regular_table.resetAutoSize(), which
// clears the width caches so the next draw measures cells and sets each
// column's min-width from the result. The complication is that the plugin's own
// draw saves the *live* widths first and restores them immediately after the
// reset, so going through it preserves whatever the columns already are. Hence
// driving regular_table directly.
//
// Two things that measurement will never fix:
//
// - Group headers are excluded on purpose. pro.css says so:
// /* Header groups should overflow and not contribute to auto-sizing. */
// thead tr:not(.rt-autosize) th { overflow: hidden; max-width: 0px; }
// Only the leaf header row participates. Letting a group label set width
// would widen every column beneath it, so this is left alone -- with the
// ordinal prefix in front, "01 - Prior…" still reads when truncated.
//
// - The row-label column measures its *header*, which for row headers is a
// blank corner cell, so it never reflects the labels underneath. That one
// is worth fixing, and is what fitRowLabels does below.
async function fitColumns() {
const viewer = viewerRef.current
if (!viewer) return
try {
const plugin = await viewer.getPlugin()
const grid = plugin?.regular_table
if (!grid?.resetAutoSize) {
flash('This plugin does not support fitting columns', 'error')
return
}
// _cached_column_sizes is consumed by the next save_column_size_overrides
// and would otherwise be restored over the measurement
plugin._cached_column_sizes = undefined
plugin._reset_column_size = false
grid.resetAutoSize()
await grid.draw({ invalid_columns: true })
// Row labels are deliberately left at their default width. Each group_by
// level is its own column, so fitting the first to its longest label pushes
// the second to start after it, and the default spacing reads better than
// the fitted one. fitRowLabels below still does it, one call away, if that
// judgement changes.
// Widths are not part of ViewConfig, so persist the layout to keep the
// saved copy in step with what is on screen.
const cfg = await captureConfig()
if (cfg) await persistLayout(versionId, cfg)
} catch (err) {
console.error('[fitColumns]', err)
flash(err.message || String(err), 'error')
}
}
// A floor under the row-label columns, not a fit.
//
// Restoring a layout resets the widths, and the row-header columns are then
// sized from their *header* — which for row headers is a blank corner cell —
// so they come back a few pixels wide and have to be dragged open by hand.
// Fitting them to content is the other extreme: each group_by level is its own
// column, so the first widens to its longest label and shoves the second
// rightwards.
//
// A minimum leaves the default spacing alone where it is already reasonable and
// only intervenes where a column came back unusably narrow. Anything already
// wider — set by dragging, or recorded in a layout — is untouched.
// Three characters, measured in the grid's own font rather than guessed in
// pixels, so it holds up if the theme or zoom changes.
const MIN_ROW_LABEL_CHARS = 3
async function ensureRowLabelWidth() {
const grid = (await viewerRef.current?.getPlugin())?.regular_table
if (!grid?.saveColumnSizes) return
const cells = [...grid.querySelectorAll('tbody th')]
const idxs = new Set()
for (const cell of cells) {
const m = [...cell.classList].map(c => /^rt-col-(\d+)$/.exec(c)).find(Boolean)
if (m) idxs.add(Number(m[1]))
}
if (idxs.size === 0) return
const style = getComputedStyle(cells[0])
const ctx = (ensureRowLabelWidth._ctx ||= document.createElement('canvas').getContext('2d'))
ctx.font = `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`
const min = Math.ceil(
ctx.measureText('0'.repeat(MIN_ROW_LABEL_CHARS)).width
+ parseFloat(style.paddingLeft) + parseFloat(style.paddingRight)
)
const sizes = { ...grid.saveColumnSizes() }
let changed = false
for (const idx of idxs) {
if ((sizes[idx] ?? 0) < min) { sizes[idx] = min; changed = true }
}
if (!changed) return
grid.restoreColumnSizes(sizes)
await grid.draw({ invalid_columns: true })
}
// Pin the row-label columns wide enough for the labels they show.
//
// Measured with a canvas rather than the DOM: the cell is clipped, so reading
// its box back gives the width it was allotted, not the width of its text —
// the same circularity that stops headers sizing themselves.
//
// Set by column *index* through regular-table's own saveColumnSizes /
// restoreColumnSizes, not as a plugin_config column_size_override. The override
// path maps the key "__ROW_PATH__" to index tree_header_offset - 1, which with
// two group_by levels is index 2 — the first data column, not a row label.
// Pinning that was doing nothing visible because that column is the
// grand-total one, which the stylesheet hides.
async function fitRowLabels(grid) {
const cells = [...grid.querySelectorAll('tbody th')]
if (cells.length === 0) return
const style = getComputedStyle(cells[0])
const ctx = (fitRowLabels._ctx ||= document.createElement('canvas').getContext('2d'))
ctx.font = `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`
const padding = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight)
// One width per row-header column, keyed by the rt-col-N the cell carries.
const widest = new Map()
for (const cell of cells) {
const col = [...cell.classList]
.map(c => /^rt-col-(\d+)$/.exec(c))
.find(Boolean)
if (!col) continue
const idx = Number(col[1])
const text = (cell.textContent || '').trim()
if (!text) continue
// Tree indentation occupies real width, so it counts toward the fit.
const inner = cell.querySelector('span.rt-tree-container')
const indent = inner ? (parseFloat(getComputedStyle(inner).paddingLeft) || 0) : 0
const w = ctx.measureText(text).width + indent
widest.set(idx, Math.max(widest.get(idx) ?? 0, w))
}
if (widest.size === 0) return
// Capped, and never narrowed.
//
// Each group_by level is its own row-header column, so widening the first to
// its longest label pushes the second to start after it — which reads as the
// deeper level being indented past the end of the shallower one. Fitting the
// widest label exactly is therefore right for the label and wrong for the
// sheet, and a long customer name would push the rest of the pivot off to the
// right.
//
// The cap trades a rare truncation for a layout that stays legible. Taking
// the max with the current width means Fit never makes a column narrower
// than it already is, so a width set by dragging survives.
const MAX_ROW_LABEL_W = 260
const sizes = { ...grid.saveColumnSizes() }
for (const [idx, w] of widest) {
// A few px over: canvas metrics and rendered text differ slightly with font
// fallback and letter-spacing.
const fitted = Math.min(Math.ceil(w + padding + 8), MAX_ROW_LABEL_W)
sizes[idx] = Math.max(fitted, sizes[idx] ?? 0)
}
grid.restoreColumnSizes(sizes)
await grid.draw({ invalid_columns: true })
}
const GRID_CSS = `
/* Let headers and row labels size their own column.
*
* Auto-fit measures cells with getBoundingClientRect() and sets each column's
* min-width from the result — but the datagrid's own CSS wraps and clips
* header text, so a clipped th measures at the width it was *allotted*, not
* the width of its content. The column can therefore never grow to fit its
* own heading, which is why the values fit and the headings did not. Row
* labels are th elements in tbody and clip for the same reason.
*
* nowrap alone: no width or overflow is touched, so the measurement sees the
* full text and the existing sizing logic does the rest. */
thead th,
tbody th {
white-space: nowrap !important;
}
th.psp-split-total:not(.psp-split-subtotal),
td.psp-split-total:not(.psp-split-subtotal) {
visibility: hidden !important;
width: 0 !important;
min-width: 0 !important;
max-width: 0 !important;
padding: 0 !important;
border-left-width: 0 !important;
border-right-width: 0 !important;
overflow: hidden !important;
}
`
// Find the grid wherever it is. Guessing the nesting was wrong once already --
// the plugin element's own shadow root is not where the cells live -- so locate
// regular-table by walking through shadow roots, then inject into whatever root
// actually contains it via getRootNode(). No structural assumption survives a
// Perspective upgrade; this one asks the DOM instead.
function deepFindRegularTable(node, depth = 0) {
if (!node || depth > 12) return null
if (node.tagName === 'REGULAR-TABLE') return node
for (const child of node.children || []) {
const hit = deepFindRegularTable(child, depth + 1)
if (hit) return hit
}
if (node.shadowRoot) return deepFindRegularTable(node.shadowRoot, depth + 1)
return null
}
function hideSplitTotal() {
const viewer = viewerRef.current
if (!viewer) return
const grid = deepFindRegularTable(viewer)
// The grid is built asynchronously after load, so it may not exist yet.
// Retried from the config-update handler, and once on a short delay.
if (!grid) return false
const root = grid.getRootNode()
if (!root || root.querySelector?.('#pf-grid-css')) return true
const style = document.createElement('style')
style.id = 'pf-grid-css'
style.textContent = GRID_CSS
;(root.appendChild ? root : document.head).appendChild(style)
return true
}
// Row depth is a ViewConfig field, so it is set through restore() rather than
// by calling set_depth() on the view. That is the whole difference: the config
// is what the viewer rebuilds its view *from*, so the depth survives every
// rebuild by construction, and viewer.save() carries it into the persisted and
// named layouts for free.
//
// It used to be imperative -- getView() then view.set_depth() -- which put the
// depth on an object the viewer discards whenever it re-renders. Everything
// that grew around that (an observer shim over two global browser APIs, focus
// and visibility listeners, a retry loop for "No table set", and a flag
// tracking whether the viewer had "gone away") existed only to guess when a
// rebuild had happened and put the depth back. None of it is needed now, and
// all of it is gone.
async function applyDepth(d) {
const viewer = viewerRef.current
if (!viewer) return
// group_by_depth counts levels, where view.set_depth() counts the boundary
// below them -- server.cpp does ctx->set_depth(row_pivot_depth - 1) for both
// the one- and two-sided contexts. So the toolbar's 0..3, which were written
// against the imperative call, are one less than the config wants: depth 0
// (grand total only) is group_by_depth 1, and passing 0 asked the engine for
// set_depth(-1).
//
// The whole config goes back rather than a partial update, which had no
// effect on its own. table is dropped: it is loaded by reference, and a stale
// name in a restored config fails the lookup.
const { table: _t, ...cfg } = await viewer.save()
await viewer.restore({ ...cfg, group_by_depth: d + 1 })
// Until the engine carries the patch in ui/vendor, restore() drops the depth:
// ViewConfig::apply_update applies ten fields and neither depth is among
// them, so the value arrives, deserializes, and is discarded before the
// engine sees it. Read it back to find out which build we are on, and fall
// back to the imperative call when it did not stick.
//
// The fallback is the old behaviour, warts and all: set_depth lives on the
// view, so it is lost whenever the viewer rebuilds one. What is deliberately
// not back is the machinery that used to chase that -- no observer shim, no
// focus listeners, no retries. Depth is simply re-applied when you next press
// a button, which is predictable, and it becomes durable for free once the
// engine is rebuilt.
const after = await viewer.save()
if (after.group_by_depth !== d + 1) {
const view = await viewer.getView()
await view.set_depth(d)
const plugin = await viewer.getPlugin()
await plugin.draw(view)
}
setExpandDepth(d)
}
async function captureConfig() {
const viewer = viewerRef.current
if (!viewer) return null
const cfg = await viewer.save()
// Both depths are already in cfg, straight from save(). Nothing of ours needs
// to travel beside it any more -- split_full existed only because collapsing
// truncated split_by and the discarded levels had to be remembered.
return cfg
}
async function persistLayout(vid, cfg) {
localStorage.setItem(LAYOUT_KEY(vid), JSON.stringify(cfg))
}
async function handleSaveAs() {
const name = saveAsName.trim()
if (!name) return
const cfg = await captureConfig()
if (!cfg) return
const id = Date.now()
const updated = [...layouts, { id, name, config: cfg }]
localStorage.setItem(LAYOUTS_KEY(versionId), JSON.stringify(updated))
await persistLayout(versionId, cfg)
setLayouts(updated)
setActiveLayoutId(id)
setShowSaveAs(false)
setSaveAsName('')
flash('Saved')
}
async function saveAsSourceDefault() {
const cfg = await captureConfig()
if (!cfg) return
const { table, ...rest } = cfg
try {
const res = await fetch(`/api/sources/${sourceId}/default-layout`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(rest)
})
if (!res.ok) { const data = await res.json(); flash(data.error || 'Failed', 'error'); return }
if (refreshSources) await refreshSources()
flash('Saved as source default')
} catch (err) { flash(err.message, 'error') }
}
async function handleSaveOver() {
const layout = layouts.find(l => l.id === activeLayoutId)
if (!layout) return
const cfg = await captureConfig()
if (!cfg) return
const updated = layouts.map(l => l.id === activeLayoutId ? { ...l, config: cfg } : l)
localStorage.setItem(LAYOUTS_KEY(versionId), JSON.stringify(updated))
await persistLayout(versionId, cfg)
setLayouts(updated)
flash('Saved')
}
async function applyLayout(layout) {
const viewer = viewerRef.current
if (!viewer) return
const validCols = new Set(tableRef.current ? Object.keys(await tableRef.current.schema()) : [])
const cfg = cleanLayout(layout.config, validCols)
cfg.plugin_config = { ...(cfg.plugin_config || {}), edit_mode: 'SELECT_REGION' }
await viewer.restore(cfg)
adoptSplit(cfg.split_full?.length ? cfg.split_full : cfg.split_by,
cfg.split_by_depth != null ? cfg.split_by_depth - 1 : null)
if (cfg.group_by_depth != null) setExpandDepth(cfg.group_by_depth - 1)
else if (cfg.expand_depth != null) await applyDepth(cfg.expand_depth)
await ensureRowLabelWidth()
setActiveLayoutId(layout.id)
// The persisted copy is taken after the restore, so it carries whatever
// cleanLayout dropped on the way in rather than the stale original.
const merged = await captureConfig()
await persistLayout(versionId, merged || cfg)
}
function deleteLayout(id, e) {
e.stopPropagation()
const updated = layouts.filter(l => l.id !== id)
localStorage.setItem(LAYOUTS_KEY(versionId), JSON.stringify(updated))
setLayouts(updated)
if (activeLayoutId === id) setActiveLayoutId(null)
}
function resetLayout() {
localStorage.removeItem(LAYOUT_KEY(versionId))
setActiveLayoutId(null)
const viewer = viewerRef.current
if (viewer) viewer.restore({ settings: true })
}
async function submitOp(op) {
if (!slices.length) { flash('Select a slice first', 'error'); return }
// The pivot's filter scopes what the ledger counted, so it has to scope what
// gets written too -- otherwise the panel shows one number and the operation
// changes a larger set. It travels as [col, op, value] rather than folded
// into the slices, because a slice is {col: value} and can only mean
// equality: a view filtered to sseas_e <= 2027 has no slice form at all.
const body = buildPayload(op)
if (!body) return
if (body.slices.some(sl => !Object.keys(sl).length)) {
flash('No dimension or date columns in slice — check col_meta', 'error'); return
}
if (op === 'scale') {
const has = ['target_value','target_units','target_price',
'value_incr','units_incr','value_pct','units_pct']
.some(k => body[k] !== undefined)
if (!has) { flash('Enter a target or increment', 'error'); return }
}
// Recode with nothing set would rewrite rows as themselves. Clone would not:
// copying a segment forward in time changes the dates and the period
// dimensions, which is the whole point of cloning from prior year, so it needs
// no dimension override at all. It only needs to be doing *something* --
// an override, a shift, or a factor.
if (op === 'recode' && !Object.keys(body.set || {}).length) {
flash('Enter at least one new dimension value', 'error')
return
}
// A clone that changes nothing is only pointless when the source and the
// destination are the same band. Copying a plan or a prior year out of
// reference and into adjustments changes what the rows *are*, even at factor 1
// with no shift -- that is the whole operation. Only warn when the selection
// is already adjustable.
if (op === 'clone') {
const shifts = body.date_offset && body.date_offset !== '0 days'
const scales = body.scale != null && body.scale !== 1
const changes = Object.keys(body.set || {}).length > 0
const fromRef = !!body.from_logid ||
(currentTotals?.excluded?.rows > 0 && !(currentTotals?.total?.value || currentTotals?.total?.units))
if (!shifts && !scales && !changes && !fromRef) {
flash('A clone with no override, no date shift and a factor of 1 would just duplicate the rows', 'error')
return
}
}
try {
const res = await fetch(`/api/versions/${versionId}/${op}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
})
const data = await res.json()
if (!res.ok) { flash(data.error, 'error'); return }
if (data.rows?.length && tableRef.current) await tableRef.current.update(data.rows)
const n = data.slices_applied > 1 ? ` across ${data.slices_applied} slices` : ''
// a slice that matched no rows, or was already at its target, writes nothing —
// say so rather than reporting a silent partial success
const skipped = data.slices_skipped?.length
? `${data.slices_skipped.length} slice${data.slices_skipped.length === 1 ? '' : 's'} unchanged (no rows, or already on target)`
: ''
flash(`${op}: ${data.rows_affected ?? data.rows?.length ?? ''} rows${n}${skipped}`, skipped ? 'warn' : 'ok')
// let the status bar re-read the forecast table's row count
window.dispatchEvent(new CustomEvent('pf-data-changed'))
refreshLogMeta(versionId)
if (opTag.trim()) refreshTags(sourceId)
if (op === 'scale') { setScaleInputs({}); setScaleNote(''); fetchCurrentTotals(slices) }
if (op === 'recode') { setRecodeNote('') }
if (op === 'clone') { setCloneNote(''); setCloneScale('1') }
} catch (err) { flash(err.message, 'error') }
}
async function lookupDerivedCols(col, value, setter) {
if (!sourceId || !value.trim()) return
const meta = colMetaRef.current.find(c => c.cname === col)
const group = meta?.dim_group && dimMembers[meta.dim_group]
// With the member list in hand this is a local lookup, and it answers where the
// source query could not: the source holds every attribute set a part ever had,
// so anything with more than one came back ambiguous and filled nothing. A
// member row is a single definition by construction.
//
// A miss is not an answer though. The list can be empty (never refreshed, or a
// refresh still running) or simply not contain this key, and in either case the
// source still knows. Falling back costs one request on a path that only runs
// when someone types a value.
let derived = group?.members?.length
? (group.members.find(m => m.key_value === value.trim())?.attrs || null)
: null
if (!derived) {
const res = await fetch(`/api/sources/${sourceId}/lookup?col=${encodeURIComponent(col)}&value=${encodeURIComponent(value)}`)
if (!res.ok) return
derived = await res.json()
}
if (!derived) {
flash(`No attributes found for ${col} "${value.trim()}" — the other fields are unchanged`, 'warn')
return
}
// Overwrite rather than only filling blanks. These columns describe the key
// that was just entered, so whatever a previous key left behind is wrong, not
// worth preserving -- and leaving it made a second lookup look like it had
// done nothing at all, since every box was already full.
setter(prev => {
const next = { ...prev }
for (const [k, v] of Object.entries(derived)) next[k] = String(v ?? '')
return next
})
}
// Segments worth copying a mix from: the loads. Adjustments are already in the
// forecast, so cloning one would double it rather than seed anything.
const cloneSources = Object.entries(logMeta)
.filter(([, m]) => ['baseline', 'reference'].includes(m.operation))
.map(([id, m]) => ({
id: Number(id),
operation: m.operation,
label: (m.label || m.tag || m.note || '').trim(),
}))
.sort((a, b) => a.id - b.id)
function buildEffectiveSlice(raw) {
const dimCols = new Set(colMetaRef.current.filter(c => c.role === 'dimension').map(c => c.cname))
const dateCols = new Set(colMetaRef.current.filter(c => c.role === 'date').map(c => c.cname))
const out = {}
for (const [k, v] of Object.entries(raw)) {
// Not col_meta columns, but real ones here and resolvable server-side to the
// set of pf.log ids that carry the name. Dropping them is what let a click on
// one bucket's cell scale every bucket at that intersection.
if (COMPUTED_SLICE_COLS.has(k)) { out[k] = v; continue }
if (dimCols.has(k)) { out[k] = v; continue }
if (dateCols.has(k)) {
const ms = Number(v)
out[k] = isFinite(ms) ? new Date(ms).toISOString().slice(0, 10) : v
}
}
return out
}
// The scope is read from the ref rather than passed in: the request preview in
// the panel calls this too, and when it was a parameter the preview defaulted
// it away -- showing a payload with no scope for a write that had one.
function buildPayload(op) {
const viewFilter = viewFilterRef.current || []
if (!slices.length) return null
// Two clicked cells can differ only by a column the operation cannot filter on
// (pf_iter, say, which is not in col_meta and so is dropped here). Those become
// the same effective slice, and sending it twice would apply the change twice
// under apply_mode 'each'. Collapse duplicates before they reach the API.
const seen = new Set()
const effectiveSlices = []
for (const sl of slices) {
const eff = buildEffectiveSlice(sl)
const key = JSON.stringify(Object.keys(eff).sort().map(k => [k, eff[k]]))
if (seen.has(key)) continue
seen.add(key)
effectiveSlices.push(eff)
}
// apply_mode only changes the maths when more than one slice is selected
let body = {
tag: opTag.trim() || undefined,
slices: effectiveSlices,
...(viewFilter.length ? { scope: viewFilter } : {}),
...(effectiveSlices.length > 1 ? { apply_mode: applyMode } : {}),
}
if (op === 'scale') {
body = { ...body, note: scaleNote || undefined }
const adj = currentTotals?.total || { value: 0, units: 0 }
const excl = currentTotals?.excluded || { value: 0, units: 0 }
// only meaningful when the slice actually contains rows operations cannot write
const hasExcl = excl.value !== 0 || excl.units !== 0
const useTotal = targetBasis !== 'adjustable' && hasExcl
if (useTotal) body.target_basis = 'selected'
const cur = useTotal
? { value: adj.value + excl.value, units: adj.units + excl.units }
: adj
const curPrice = cur.units ? cur.value / cur.units : null
const num = (raw) => {
const n = parseFloat(raw)
return (raw !== '' && raw != null && isFinite(n)) ? n : null
}
const v = scaleInputs.value, u = scaleInputs.units, p = scaleInputs.price
const vn = v && num(v.raw), un = u && num(u.raw), pn = p && num(p.raw)
if (vn != null) {
if (v.field === 'new') body.target_value = vn
else if (v.field === 'change') body.value_incr = vn
else body.value_pct = vn
}
if (un != null) {
if (u.field === 'new') body.target_units = un
else if (u.field === 'change') body.units_incr = un
else body.units_pct = un
}
// price has no increment form server-side, so every price edit is resolved
// to an absolute target price here
if (pn != null && curPrice != null) {
body.target_price = p.field === 'new' ? pn
: p.field === 'change' ? curPrice + pn
: curPrice * (1 + pn / 100)
}
// A dollar change on its own is ambiguous -- price x volume, and the number
// says nothing about which moved. plug names the one that absorbs it. Only
// sent when dollars are the sole input; naming units or price has already
// answered the question.
if (vn != null && un == null && pn == null) body.plug = scalePlug
} else if (op === 'recode') {
const set = Object.fromEntries(Object.entries(recodeSet).filter(([, v]) => v.trim()))
body = { ...body, note: recodeNote || undefined, set }
} else if (op === 'clone') {
const set = Object.fromEntries(Object.entries(cloneSet).filter(([, v]) => v.trim()))
body = { ...body, note: cloneNote || undefined, set, scale: parseFloat(cloneScale) || 1 }
// The offset stands on its own: shifting a selection through time is the
// common case, and it used to be sent only when a segment was named.
if (cloneFrom) body.from_logid = Number(cloneFrom)
const off = cloneOffset.trim()
if (off && off !== '0 days') body.date_offset = off
}
return body
}
function flash(text, type = 'ok') {
setMsg({ text, type })
// errors and warnings stay until dismissed or superseded; plain success fades
if (type === 'ok') setTimeout(() => setMsg(null), 3000)
}
async function openLog() {
setShowLog(true)
setExpandedLog(null)
setLogLoading(true)
try {
// Baseline and reference loads are segment construction, not forecasting.
// They are managed in the Baseline view, where they can be edited in place
// and their date ranges seen; listing them here only buries the adjustments
// this log is for -- and offers an undo that would silently gut the version.
// Asked for by kind so the server does not total rows we are not showing.
const data = await fetch(`/api/versions/${versionId}/log?kind=adjustments`).then(r => r.json())
setLogEntries(data)
} catch (err) {
flash(err.message, 'error')
} finally {
setLogLoading(false)
}
}
async function undoEntry(logId) {
setUndoingId(logId)
try {
const res = await fetch(`/api/log/${logId}`, { method: 'DELETE' })
const data = await res.json()
if (!res.ok) { flash(data.error, 'error'); return }
setLogEntries(prev => prev.filter(e => e.id !== logId))
// grain versions report pf_gkeys, raw versions pf_ids — either way these are
// the index values of the rows to drop, and the view re-sums what remains
const removed = data.pf_gkeys ?? data.pf_ids
if (removed?.length && tableRef.current) {
await tableRef.current.remove(removed)
}
flash(`Undone — ${data.rows_deleted} rows removed`)
} catch (err) {
flash(err.message, 'error')
} finally {
setUndoingId(null)
}
}
// note and tag are annotations on history — saving one never touches forecast rows
async function saveLogField(logId, field, text) {
const value = text.trim()
try {
const res = await fetch(`/api/log/${logId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [field]: value }),
})
if (!res.ok) {
const { error } = await res.json().catch(() => ({}))
flash(error || `Failed to save ${field}`, 'error')
return
}
const saved = await res.json()
setLogEntries(prev => prev.map(e => e.id === logId ? { ...e, [field]: saved[field] ?? null } : e))
setEditingCell(null)
if (field === 'tag') {
// the ledger names its lines from this, and the tag list gains a new entry
refreshLogMeta(versionId)
refreshTags(sourceId)
}
} catch (err) {
flash(err.message, 'error')
}
}
const dimCols = colMetaRef.current.filter(c => c.role === 'dimension')
const hasSlice = slices.length > 0
// how many of the selected cells actually resolve to distinct, operable slices
const distinctSlices = (() => {
const seen = new Set()
for (const sl of slices) {
const eff = buildEffectiveSlice(sl)
seen.add(JSON.stringify(Object.keys(eff).sort().map(k => [k, eff[k]])))
}
return seen.size
})()
const panelProps = {
distinctSlices,
dock,
slices, setSlices,
viewScope,
applyMode, setApplyMode,
currentTotals,
activeOp, setActiveOp,
scaleInputs, setScaleInputs,
scalePlug, setScalePlug,
targetBasis, setTargetBasis,
opTag, setOpTag, knownTags, logMeta,
scaleNote, setScaleNote,
recodeSet, setRecodeSet,
recodeNote, setRecodeNote,
cloneSet, setCloneSet,
cloneScale, setCloneScale,
cloneFrom, setCloneFrom, cloneOffset, setCloneOffset, cloneSources,
cloneNote, setCloneNote,
dimCols, lookupDerivedCols, dimMembers, versionId,
buildPayload, submitOp,
}
return (
<div className="h-full flex flex-col">
{/* Tag completions, shared by the operation panel and the change log's
inline editor. Lives here so it is in the DOM even when the panel is shut. */}
<datalist id="pf-tag-options">
{knownTags.map(t => (
<option key={t.tag} value={t.tag}>{t.uses} use{t.uses === 1 ? '' : 's'}</option>
))}
</datalist>
{/* Toolbar */}
<div className="px-3 py-1.5 border-b border-gray-200 bg-white flex items-center gap-3 shrink-0 flex-wrap text-xs">
{/* Layout group */}
<div className="flex items-center gap-1.5">
<span className="text-gray-400 uppercase tracking-wide" style={{fontSize:'10px'}}>Layout</span>
{layouts.map(l => (
<div key={l.id} onClick={() => applyLayout(l)}
className={`flex items-center gap-1 rounded px-2 py-0.5 cursor-pointer border transition-colors
${activeLayoutId === l.id ? 'bg-blue-50 border-blue-300 text-blue-700' : 'bg-white border-gray-200 text-gray-600 hover:border-gray-400'}`}>
{l.name}
<button onClick={e => deleteLayout(l.id, e)} className="text-gray-300 hover:text-red-400 text-sm leading-none ml-0.5">×</button>
</div>
))}
{showSaveAs ? (
<div className="flex items-center gap-1">
<input autoFocus value={saveAsName} onChange={e => setSaveAsName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleSaveAs(); if (e.key === 'Escape') { setShowSaveAs(false); setSaveAsName('') } }}
placeholder="Layout name…" className="border border-gray-300 rounded px-2 py-0.5 w-32 focus:outline-none focus:border-blue-400" />
<button onClick={handleSaveAs} className="text-blue-600 hover:text-blue-800 px-1">Save</button>
<button onClick={() => { setShowSaveAs(false); setSaveAsName('') }} className="text-gray-400 px-1">Cancel</button>
</div>
) : (
<>
{activeLayoutId !== null && (
<button onClick={handleSaveOver} className="border border-blue-200 text-blue-500 hover:text-blue-700 rounded px-2 py-0.5">Save</button>
)}
<button onClick={() => setShowSaveAs(true)} className="border border-dashed border-gray-200 text-gray-400 hover:text-gray-600 rounded px-2 py-0.5">
Save as
</button>
<button onClick={saveAsSourceDefault} disabled={!sourceId}
className="border border-dashed border-gray-200 text-gray-400 hover:text-gray-600 rounded px-2 py-0.5 disabled:opacity-40"
title="Use this layout as the default for new versions of this source">
Set source default
</button>
{activeLayoutId !== null && (
<button onClick={resetLayout} className="text-gray-300 hover:text-red-400">Reset</button>
)}
</>
)}
</div>
<div className="w-px h-4 bg-gray-200 shrink-0" />
<button onClick={fitColumns}
title="Size every column to its contents, releasing any widths pinned by dragging or carried in a saved layout"
className="border border-gray-200 rounded px-1.5 py-0.5 text-gray-500 hover:border-gray-400
transition-colors whitespace-nowrap">
Fit
</button>
<div className="w-px h-4 bg-gray-200 shrink-0" />
{/* Expand group */}
<div className="flex items-center gap-1.5">
<span className="text-gray-400 uppercase tracking-wide" style={{fontSize:'10px'}}>Expand</span>
{[0, 1, 2, 3].map(d => (
<button key={d} onClick={() => applyDepth(d)}
className={`border rounded px-1.5 py-0.5 transition-colors
${expandDepth === d ? 'border-blue-300 text-blue-600 bg-blue-50' : 'border-gray-200 text-gray-500 hover:border-gray-400'}`}>
{d}
</button>
))}
</div>
{splitFull.length > 0 && (
<>
<div className="w-px h-4 bg-gray-200 shrink-0" />
{/* Column hierarchy group — the split_by equivalent of Expand */}
<div className="flex items-center gap-1.5">
<span className="text-gray-400 uppercase tracking-wide" style={{fontSize:'10px'}}>Columns</span>
{Array.from({ length: splitFull.length + 1 }, (_, n) => {
const label = n === 0 ? 'Total' : splitFull[n - 1]
return (
<button key={n} onClick={() => applySplitDepth(n)}
title={n === 0
? 'Collapse the columns to a single total'
: `Show columns down to ${splitFull.slice(0, n).join(' ')}`}
className={`border rounded px-1.5 py-0.5 transition-colors max-w-[9rem] truncate
${splitDepth === n ? 'border-blue-300 text-blue-600 bg-blue-50' : 'border-gray-200 text-gray-500 hover:border-gray-400'}`}>
{label}
</button>
)
})}
</div>
</>
)}
<div className="w-px h-4 bg-gray-200 shrink-0" />
{/* Data group */}
<div className="flex items-center gap-1.5">
<button onClick={() => initViewer(versionId, sourceId)} disabled={loading || !versionId}
className="border border-gray-200 rounded px-2 py-0.5 text-gray-500 hover:bg-gray-50 disabled:opacity-40">
{loading ? 'Loading…' : 'Refresh data'}
</button>
<button onClick={openLog} disabled={!versionId}
className="border border-gray-200 rounded px-2 py-0.5 text-gray-500 hover:bg-gray-50 disabled:opacity-40">
Change log
</button>
<button onClick={() => setShowBridge(true)} disabled={!versionId}
title="How this version got from baseline to current, by initiative"
className="border border-gray-200 rounded px-2 py-0.5 text-gray-500 hover:bg-gray-50 disabled:opacity-40">
Bridge
</button>
<button onClick={() => setPanelOpen(o => !o)} disabled={!versionId}
title={panelOpen ? 'Hide the operations panel (Esc)' : 'Show the operations panel'}
className={`border rounded px-2 py-0.5 disabled:opacity-40 transition-colors ${
panelOpen ? 'border-blue-300 text-blue-600 bg-blue-50' : 'border-gray-200 text-gray-500 hover:bg-gray-50'}`}>
{panelOpen ? 'Hide panel' : 'Operations'}
{!panelOpen && hasSlice && (
<span className="ml-1 text-blue-500">({slices.length})</span>
)}
</button>
</div>
{msg && (
<span className={`ml-2 text-xs font-medium px-2 py-0.5 rounded flex items-center gap-1.5 ${msg.type === 'error' ? 'bg-red-50 text-red-600' : msg.type === 'warn' ? 'bg-amber-50 text-amber-700' : 'bg-green-50 text-green-600'}`}>
{msg.text}
{msg.type !== 'ok' && (
<button onClick={() => setMsg(null)} className="opacity-60 hover:opacity-100 leading-none">×</button>
)}
</span>
)}
</div>
{/* History modal */}
{showLog && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={() => setShowLog(false)}>
<div className="bg-white rounded-lg shadow-xl w-full max-w-6xl mx-4 flex flex-col max-h-[85vh]" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-200 shrink-0">
<span className="font-medium text-gray-700 text-sm">Change History</span>
<button onClick={() => setShowLog(false)} className="text-gray-400 hover:text-gray-600 text-lg leading-none">×</button>
</div>
<div className="overflow-y-auto flex-1">
{logLoading ? (
<div className="p-8 text-center text-sm text-gray-400">Loading</div>
) : logEntries.length === 0 ? (
<div className="p-8 text-center text-sm text-gray-400">No log entries yet.</div>
) : (
<table className="w-full text-xs border-collapse table-fixed">
<thead className="sticky top-0 bg-gray-50 text-gray-400 uppercase tracking-wide" style={{fontSize:'10px'}}>
<tr>
<th className="text-left px-3 py-2 font-medium w-28">Time</th>
<th className="text-left px-3 py-2 font-medium w-20">Op</th>
<th className="text-left px-3 py-2 font-medium">Slice</th>
<th className="text-left px-3 py-2 font-medium w-32">Tag</th>
<th className="text-left px-3 py-2 font-medium w-48">Note</th>
<th className="text-left px-3 py-2 font-medium w-24">By</th>
<th className="text-right px-3 py-2 font-medium w-28">Value</th>
<th className="text-right px-3 py-2 font-medium w-16">Rows</th>
<th className="px-3 py-2 w-16"></th>
</tr>
</thead>
<tbody>
{logEntries.map(entry => (
<tr key={entry.id} className="border-t border-gray-100 hover:bg-gray-50">
<td className="px-3 py-2 text-gray-400 whitespace-nowrap">{fmtStamp(entry.stamp)}</td>
<td className="px-3 py-2">
<span className={`px-1.5 py-0.5 rounded text-xs font-medium ${opBadge(entry.operation)}`}>
{entry.operation}
</span>
</td>
<td className="px-3 py-2 text-gray-600 font-mono overflow-hidden">
<button
onClick={() => setExpandedLog(prev => prev === entry.id ? null : entry.id)}
className="text-left w-full truncate hover:text-blue-600"
title="Show the payload">
<span className="text-gray-400 mr-1">{expandedLog === entry.id ? '▾' : '▸'}</span>
{fmtSliceSummary(entry.slice)}
</button>
</td>
<LogCell entry={entry} field="tag" canEdit={canUndo(entry)} placeholder="add tag"
editing={editingCell} setEditing={setEditingCell} onSave={saveLogField}
listId="pf-tag-options"
render={(v) => (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full bg-blue-50 text-blue-700 border border-blue-200">
{v}
</span>
)} />
<LogCell entry={entry} field="note" canEdit={canUndo(entry)} placeholder="add note"
editing={editingCell} setEditing={setEditingCell} onSave={saveLogField} />
<td className="px-3 py-2 text-gray-500 truncate" title={entry.pf_user || ''}>
{entry.pf_user || '—'}
</td>
<td className={`px-3 py-2 text-right tabular-nums whitespace-nowrap ${
entry.value_total > 0 ? 'text-green-700' : entry.value_total < 0 ? 'text-red-600' : 'text-gray-400'}`}>
{entry.value_total == null ? '—'
: entry.value_total.toLocaleString(undefined, { maximumFractionDigits: 0 })}
</td>
<td className="px-3 py-2 text-right text-gray-500 tabular-nums">{entry.row_count ?? '—'}</td>
<td className="px-3 py-2">
<button
onClick={() => undoEntry(entry.id)}
disabled={undoingId === entry.id || !canUndo(entry)}
title={canUndo(entry)
? 'Remove this entry and its rows'
: `Only ${entry.pf_user || 'the author'} or an administrator can undo this`}
className={`text-xs rounded px-2 py-0.5 whitespace-nowrap border ${
canUndo(entry)
? 'border-red-200 text-red-400 hover:text-red-600 hover:border-red-400'
: 'border-gray-100 text-gray-300 cursor-not-allowed'
} disabled:opacity-100`}>
{undoingId === entry.id ? '…' : 'Undo'}
</button>
</td>
</tr>
)).flatMap((row, i) => {
const entry = logEntries[i]
if (expandedLog !== entry.id) return [row]
return [row, (
<tr key={`${entry.id}-detail`} className="bg-gray-50 border-t border-gray-100">
<td colSpan={9} className="px-3 py-3">
<div className="grid gap-3 md:grid-cols-2">
<LogJson label="slice" value={entry.slice} />
<LogJson label="params" value={entry.params} />
{entry.env && <LogJson label="env" value={entry.env} />}
</div>
{/* The statement is fetched on demand: it is
kilobytes, and the list is opened to scan rather
than to read SQL. */}
{entry.has_sql && <LogSql logId={entry.id} />}
</td>
</tr>
)]
})}
</tbody>
</table>
)}
</div>
</div>
</div>
)}
<BridgeView
open={showBridge}
onClose={() => setShowBridge(false)}
tableRef={tableRef}
viewerRef={viewerRef}
logMeta={logMeta}
valueCol={colMetaRef.current.find(c => c.role === 'value')?.cname}
unitsCol={colMetaRef.current.find(c => c.role === 'units')?.cname}
colMeta={colMetaRef.current}
slices={slices}
excludeIters={versions.find(v => String(v.id) === String(versionId))?.exclude_iters || ['reference']}
versionName={versions.find(v => String(v.id) === String(versionId))?.name}
// Which bucket *is* the forecast. Not a constant: it is the version's
// adjustment_bucket, which is how an adjustment with no bucket of its
// own gets labelled, so the two always agree by construction.
forecastBucket={versions.find(v => String(v.id) === String(versionId))?.adjustment_bucket
|| '04 - Forecast'}
/>
{/* Main area — the panel lives in one of three shells, chosen by `dock` */}
<div className={`flex-1 min-h-0 flex ${dock === 'bottom' ? 'flex-col' : 'flex-row'}`}>
{/* Perspective viewer */}
<div className="relative flex-1 min-w-0 min-h-0">
{loading && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-gray-50 z-10 gap-2">
<span className="text-sm text-gray-400">
{loadRows ? `Loading ${loadRows.toLocaleString()} rows…` : 'Loading…'}
</span>
{loadProgress && (
<>
<span className="text-xs text-gray-400 font-mono">
{fmtBytes(loadProgress.received)}
{loadProgress.total ? ` / ${fmtBytes(loadProgress.total)}` : ''}
</span>
{loadProgress.total > 0 && (
<div className="w-48 h-1 bg-gray-200 rounded overflow-hidden">
<div
className="h-full bg-blue-400 transition-all"
style={{ width: `${Math.min(100, (loadProgress.received / loadProgress.total) * 100)}%` }}
/>
</div>
)}
</>
)}
</div>
)}
{!loading && largeDataset && (
<div className="absolute top-2 left-1/2 -translate-x-1/2 z-10 bg-amber-50 border border-amber-200 text-amber-800 text-xs px-3 py-1.5 rounded shadow-sm">
Large dataset pivot may take a moment to render
</div>
)}
<perspective-viewer ref={viewerRef} style={{ position: 'absolute', inset: 0 }} />
</div>
{/* Docked panel: bottom strip or right rail, with a resize handle */}
{dock !== 'float' && panelOpen && (
<>
<div
onMouseDown={onDragStart}
className={`${dock === 'bottom' ? 'h-1 w-full cursor-row-resize' : 'w-1 h-full cursor-col-resize'} shrink-0 hover:bg-blue-400 bg-transparent transition-colors`}
/>
<div
className={`shrink-0 bg-white overflow-auto text-xs ${dock === 'bottom' ? 'border-t' : 'border-l'} border-gray-200`}
style={dock === 'bottom' ? { height: panelHeight } : { width: panelWidth }}
>
<PanelChrome dock={dock} setDock={setDock} onClose={() => setPanelOpen(false)} />
<OperationPanel {...panelProps} />
</div>
</>
)}
</div>
{/* Floating panel: the pivot keeps the full window and this floats over it,
draggable by its header and resizable from the bottom-right grip */}
{dock === 'float' && panelOpen && floatRect && (
<div
className="fixed z-40 bg-white border border-gray-200 rounded-lg shadow-2xl flex flex-col text-xs"
style={{ left: floatRect.x, top: floatRect.y, width: floatRect.w, height: floatRect.h }}
>
<PanelChrome dock={dock} setDock={setDock} floating onMouseDown={onFloatMove}
onClose={() => setPanelOpen(false)} />
<div className="flex-1 min-h-0 overflow-auto">
<OperationPanel {...panelProps} />
</div>
<div
onMouseDown={onFloatResize}
title="Drag to resize"
className="absolute bottom-0 right-0 w-4 h-4 cursor-nwse-resize text-gray-300 hover:text-gray-500"
>
<svg viewBox="0 0 16 16" className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M15 6 L6 15 M15 11 L11 15" />
</svg>
</div>
</div>
)}
</div>
)
}
// Dock switcher — the panel goes where you want it, and remembers.
function PanelChrome({ dock, setDock, floating, onMouseDown, onClose }) {
const opts = [
['bottom', 'Dock to bottom', <svg key="b" width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><rect x="1.5" y="1.5" width="13" height="13" rx="1.5"/><rect x="1.5" y="9.5" width="13" height="5" fill="currentColor" opacity="0.35" stroke="none"/></svg>],
['right', 'Dock to right', <svg key="r" width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><rect x="1.5" y="1.5" width="13" height="13" rx="1.5"/><rect x="9.5" y="1.5" width="5" height="13" fill="currentColor" opacity="0.35" stroke="none"/></svg>],
['float', 'Float over grid',<svg key="f" width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><rect x="1.5" y="1.5" width="13" height="13" rx="1.5"/><rect x="5" y="5" width="8" height="7" rx="1" fill="currentColor" opacity="0.35" stroke="none"/></svg>],
]
return (
<div
onMouseDown={onMouseDown}
className={`flex items-center gap-1 px-2 py-1.5 border-b border-gray-100 shrink-0 ${floating ? 'bg-gray-50 rounded-t-lg cursor-move select-none' : ''}`}
>
<span className="text-gray-400 uppercase tracking-wide mr-auto" style={{ fontSize: '10px' }}>
Operations{floating ? ' — drag to move' : ''}
</span>
{opts.map(([val, title, icon]) => (
<button key={val} onClick={() => setDock(val)} title={title}
className={`w-6 h-6 flex items-center justify-center rounded ${dock === val ? 'bg-blue-50 text-blue-600' : 'text-gray-300 hover:text-gray-500 hover:bg-gray-100'}`}>
{icon}
</button>
))}
<span className="w-px h-4 bg-gray-200 mx-0.5" />
<button onClick={onClose} title="Close panel (Esc)"
className="w-6 h-6 flex items-center justify-center rounded text-gray-400 hover:text-red-500 hover:bg-gray-100 text-base leading-none">
×
</button>
</div>
)
}
// One inline-editable annotation cell in the change log. Click to edit, Enter to
// save, Escape to cancel — the same gesture for note and tag.
function LogCell({ entry, field, placeholder, editing, setEditing, onSave, listId, render, canEdit = true }) {
const active = canEdit && editing?.id === entry.id && editing?.field === field
const value = entry[field] || ''
// Someone else's entry: shown, not editable. label and bucket name the
// pivot's columns for everyone, so these are not the private annotations
// they look like.
if (!canEdit) {
return (
<td className="px-3 py-2 text-gray-400 overflow-hidden">
<span className="block truncate px-1 -mx-1"
title={value ? `${value}${entry.pf_user || 'another account'}'s entry` : ''}>
{value ? (render ? render(value) : value) : <span className="text-gray-300"></span>}
</span>
</td>
)
}
if (active) {
return (
<td className="px-3 py-2">
<div className="flex items-center gap-1">
<input autoFocus value={editing.text} list={listId}
onChange={e => setEditing(c => ({ ...c, text: e.target.value }))}
onKeyDown={e => {
if (e.key === 'Enter') onSave(entry.id, field, editing.text)
if (e.key === 'Escape') setEditing(null)
}}
className="border border-blue-400 rounded px-1.5 py-0.5 text-xs flex-1 min-w-0 focus:outline-none" />
<button onClick={() => onSave(entry.id, field, editing.text)}
className="text-blue-600 hover:text-blue-800"></button>
<button onClick={() => setEditing(null)}
className="text-gray-500 hover:text-gray-700"></button>
</div>
</td>
)
}
return (
<td className="px-3 py-2 text-gray-700 overflow-hidden">
<span onClick={() => setEditing({ id: entry.id, field, text: value })}
className="cursor-text hover:bg-blue-50 rounded px-1 -mx-1 block truncate"
title={value || `Click to ${placeholder}`}>
{value
? (render ? render(value) : value)
: <span className="text-gray-500 italic">{placeholder}</span>}
</span>
</td>
)
}
// The statement as executed, with territory and scope already resolved into it.
// Read this when the rows look wrong: params says what was asked for, and this
// says what actually ran -- the gap between them being where the bug lives.
function LogSql({ logId }) {
const [sql, setSql] = useState(null)
const [open, setOpen] = useState(false)
const [err, setErr] = useState(null)
async function toggle() {
const next = !open
setOpen(next)
if (next && sql == null && !err) {
try {
const r = await fetch(`/api/log/${logId}/debug`)
const d = await r.json()
if (!r.ok) throw new Error(d.error || 'Could not load the statement')
setSql(d.sql_text || '(not recorded)')
} catch (e) { setErr(e.message) }
}
}
return (
<div className="mt-3">
<button onClick={toggle} className="text-xs text-blue-600 hover:text-blue-700">
{open ? '▾' : '▸'} executed SQL
</button>
{open && (
<pre className="mt-1 font-mono text-[11px] text-gray-600 bg-white border border-gray-200
rounded p-2 overflow-auto max-h-72 leading-relaxed whitespace-pre">
{err || sql || 'Loading…'}
</pre>
)}
</div>
)
}
// Perspective's internal columns, which are never dimensions. Mirrors isMetaColumn()
// in @perspective-dev/viewer-datagrid — the DuckDB backend emits per-level
// __ROW_PATH_<n>__ columns alongside the __ROW_PATH__ sidecar.
const META_COL_RE = /^__(?:ROW_PATH(?:_\d+)?|ID|GROUPING_ID)__$/
// Perspective encodes a clicked/selected row position as [col, '==', value] triples
// `measures` is the view's `columns` list. Clicking a cell whose column axis is
// collapsed makes Perspective emit the measure name as the value of the first
// hidden split_by dimension -- a bucket subtotal arrives as
// ["smon_e", "==", "sales_usd"] -- because the engine maps split_by positionally
// over a column name that no longer has that many segments. Left in, the slice
// asks for a month equal to a measure, matches nothing, and the operation
// silently has no rows to act on.
function sliceFromFilters(filters, measures = []) {
const measureSet = new Set(measures)
const s = {}
for (const f of filters) {
if (!Array.isArray(f)) continue
const [col, op, val] = f
if (op !== '==' || val == null) continue
if (measureSet.has(String(val))) continue
s[col] = String(val)
}
return s
}
// slices are compared by value — the same row clicked twice is the same slice
function sliceKey(s) {
return JSON.stringify(Object.keys(s).sort().map(k => [k, s[k]]))
}
function addSlice(list, s) {
return list.some(x => sliceKey(x) === sliceKey(s)) ? list : [...list, s]
}
function removeSlice(list, s) {
return list.filter(x => sliceKey(x) !== sliceKey(s))
}
function toggleSlice(list, s) {
return list.some(x => sliceKey(x) === sliceKey(s)) ? removeSlice(list, s) : [...list, s]
}
function fmtBytes(n) {
if (n < 1024) return `${n} B`
if (n < 1048576) return `${(n / 1024).toFixed(1)} KB`
return `${(n / 1048576).toFixed(1)} MB`
}
function fmtStamp(stamp) {
return new Date(stamp).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
}
// A multi-slice operation logs an array of slices, and Object.entries over an
// array yields its indices -- which is where "0 = [object Object]" came from.
// Each slice gets its own line; a date arrives as epoch millis and is useless
// as a number.
function fmtSliceLines(slice) {
if (!slice) return []
const one = (sl) => Object.entries(sl)
.map(([k, v]) => `${k} = ${fmtSliceValue(v)}`)
.join(' · ')
if (!Array.isArray(slice)) return Object.keys(slice).length ? [one(slice)] : []
const list = slice.filter(sl => sl && Object.keys(sl).length)
if (list.length <= 1) return list.map(one)
// A dragged region is one dimension walked across a fixed set of others -- the
// five slices of log 118 differ only in omon. Printing the other four fields
// five times buries the one that actually varies, so pull the shared part out
// and list the varying values on their own line.
const keys = [...new Set(list.flatMap(sl => Object.keys(sl)))]
const varying = keys.filter(k => new Set(list.map(sl => JSON.stringify(sl[k]))).size > 1)
// Two or more independent axes would lose their pairing if flattened this way,
// so only collapse when a single dimension is doing the varying.
if (varying.length !== 1) return list.map(one)
const [vk] = varying
const fixed = Object.fromEntries(keys.filter(k => k !== vk).map(k => [k, list[0][k]]))
const values = [...new Set(list.map(sl => fmtSliceValue(sl[vk])))]
return [
...(Object.keys(fixed).length ? [one(fixed)] : []),
`${vk} = ${values.join(', ')}`,
]
}
// One line, whatever the shape. The full payload is a click away, so this only
// has to say enough to recognise the entry: what varied, and how much of it.
function LogJson({ label, value }) {
const empty = value == null || (typeof value === 'object' && Object.keys(value).length === 0)
return (
<div>
<div className="text-[10px] uppercase tracking-wide text-gray-400 mb-1">{label}</div>
<pre className="text-[11px] leading-snug font-mono text-gray-600 bg-white border border-gray-200
rounded p-2 overflow-auto max-h-64 whitespace-pre">
{empty ? '—' : JSON.stringify(value, null, 2)}
</pre>
</div>
)
}
function fmtSliceSummary(slice) {
const lines = fmtSliceLines(slice)
if (lines.length === 0) return '—'
if (!Array.isArray(slice) || slice.length <= 1) return lines.join(' · ')
const keys = [...new Set(slice.flatMap(sl => Object.keys(sl || {})))]
const varying = keys.filter(k => new Set(slice.map(sl => JSON.stringify(sl?.[k]))).size > 1)
if (varying.length === 1) {
const [vk] = varying
const vals = [...new Set(slice.map(sl => fmtSliceValue(sl[vk])))]
const span = vals.length > 2 ? `${vals[0]}${vals[vals.length - 1]}` : vals.join(', ')
return `${slice.length} slices · ${vk} = ${span}`
}
return `${slice.length} slices · ${varying.join(', ')} vary`
}
function fmtSliceValue(v) {
if (v === null || v === undefined || v === '') return '∅'
if (typeof v === 'number' && v > 1e11) {
const d = new Date(v)
if (!isNaN(d)) return d.toISOString().slice(0, 10)
}
if (typeof v === 'object') return JSON.stringify(v)
return String(v)
}
const OP_BADGE = {
baseline: 'bg-gray-100 text-gray-600',
reference: 'bg-blue-50 text-blue-600',
scale: 'bg-green-50 text-green-700',
recode: 'bg-amber-50 text-amber-700',
clone: 'bg-purple-50 text-purple-700',
}
function opBadge(op) { return OP_BADGE[op] || 'bg-gray-100 text-gray-500' }