pf_app/ui/src/views/Forecast.jsx
Paul Trowbridge 15d9ecf319 Fetch col_meta in the member-list effect instead of reading a ref
The effect decided which dim_groups to load from colMetaRef.current, but
that ref is filled inside initViewer, which is async. The effect ran first,
saw an empty array, found no groups, and fetched nothing -- and a ref
changing does not re-run an effect, so it never recovered. dimMembers stayed
{} for the whole session.

Everything therefore fell back to the source lookup, which is precisely the
query pf.dim_member exists to replace: XCP06500G18B112 has two attribute
combinations across history, so DISTINCT ... LIMIT 2 returned two rows and
the route answered null. The member row had the answer all along, picked as
the most recent by order date.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 00:53:12 -04:00

1831 lines
82 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 { PF_OBSERVER_EVENT } from '../observerShim.js'
import useTheme from '../theme.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'
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 }
const exprNames = new Set(Object.keys(cfg.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
}
export default function Forecast({ sources = [], sourceId, versions = [], versionId, refreshSources }) {
const { dark } = useTheme()
const [loading, setLoading] = useState(false)
const [largeDataset, setLargeDataset] = useState(false)
const [loadProgress, setLoadProgress] = useState(null) // { received, total }
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({})
// 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([])
const expandDepthRef = useRef(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([])
// set while our own restore is in flight, so the config-update listener can tell
// a collapse from the user rearranging split_by themselves
const collapsingRef = useRef(false)
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.
// ---------------------------------------------------------------- DEBUG
// Tracing for the "row groups re-expand on refocus" bug. set_depth() and
// per-node expand/collapse live on the *view*, not in ViewConfig, so every
// view rebuild starts fully expanded and we have to re-apply depth ourselves.
// Silent unless localStorage.pf_debug is set, since this is the kind of thing
// you want back the next time the timing shifts.
//
// localStorage.setItem('pf_debug', '1') // then reload
const viewSeqRef = useRef(0)
const viewIdsRef = useRef(new WeakMap())
const lastViewIdRef = useRef(null)
function dbg(msg, extra) {
let on = false
try { on = !!localStorage.getItem('pf_debug') } catch { /* private mode */ }
if (!on) return
const t = new Date().toISOString().slice(11, 23)
if (extra !== undefined) console.log(`[pf-depth ${t}] ${msg}`, extra)
else console.log(`[pf-depth ${t}] ${msg}`)
}
function viewId(v) {
if (!v) return 'none'
if (!viewIdsRef.current.has(v)) viewIdsRef.current.set(v, ++viewSeqRef.current)
return '#' + viewIdsRef.current.get(v)
}
// Read the viewer's current view and report whether it is the same object we
// saw last time. A changed id means the view was discarded and rebuilt.
async function probeView(where) {
let on = false
try { on = !!localStorage.getItem('pf_debug') } catch { /* private mode */ }
if (!on) return
const viewer = viewerRef.current
if (!viewer) return dbg(`probe(${where}): no viewer`)
try {
const v = await viewer.getView()
const id = viewId(v)
const changed = lastViewIdRef.current !== null && lastViewIdRef.current !== id
dbg(`probe(${where}): view ${id}${changed ? ` <-- REBUILT (was ${lastViewIdRef.current})` : ''}`)
lastViewIdRef.current = id
} catch (err) { dbg(`probe(${where}) threw`, err) }
}
// Around a rebuild the viewer throws "No table set" from getView() -- note it
// is the *view* that is missing, not the table: getTable() resolves happily
// through the same window, which is why gating on it did not help. There is no
// "is the view ready" predicate to poll, so just attempt the thing we want and
// retry until it stops throwing.
async function applyDepthWhenReady(d, deadlineMs = 5000, stepMs = 80) {
const started = Date.now()
let attempts = 0
for (;;) {
attempts++
try {
await applyDepth(d)
if (attempts > 1) dbg(`depth ${d} applied on attempt ${attempts} after ${Date.now() - started}ms`)
return true
} catch (err) {
if (Date.now() - started >= deadlineMs) {
dbg(`gave up re-applying depth ${d} after ${attempts} attempts`, err)
return false
}
await new Promise(r => setTimeout(r, stepMs))
}
}
}
// -------------------------------------------------------------- END DEBUG
useEffect(() => {
let queued = false
const reapply = async (ev) => {
dbg(`event ${ev?.type || '?'}`, {
visibility: document.visibilityState,
expandDepthRef: expandDepthRef.current,
hasViewer: !!viewerRef.current,
})
// Probe the view identity before touching anything: if the id has changed
// since the last log line, the viewer rebuilt its view and that is where
// the depth went, rather than the re-apply below losing a race.
await probeView('on ' + (ev?.type || '?'))
if (document.visibilityState !== 'visible') return dbg('bail: not visible')
if (!viewerRef.current) return dbg('bail: no viewer')
if (expandDepthRef.current == null) return dbg('bail: no depth recorded (toolbar EXPAND never used, or per-node +/- only)')
if (queued) return dbg('bail: already queued')
queued = true
// Let the viewer finish its own redraw first, then wait for it to actually
// have a table again. A fixed delay loses this race most of the time -- the
// viewer is detached from its table for a while after a refocus, and
// set_depth on a detached viewer throws "No table set" and the tree renders
// fully expanded. queued stays set until the work is done, so a focus and a
// visibilitychange for the same switch don't both re-apply.
requestAnimationFrame(async () => {
try {
const d = expandDepthRef.current
if (d == null) return dbg('re-apply aborted: depth became null')
if (await applyDepthWhenReady(d)) dbg(`re-apply done depth=${d}`)
} catch (err) {
dbg('re-apply THREW', err)
} finally {
queued = false
}
})
}
// The observer callback is the real trigger. The engine has no notion of
// focus -- it re-renders because IntersectionObserver or ResizeObserver told
// it the element is visible again, and that re-render is what discards the
// view. observerShim.js sits in front of those callbacks and re-emits them,
// so this fires at the rebuild rather than at a proxy for it.
//
// The focus/visibilitychange listeners stay as a backstop: if the shim was
// loaded too late to patch anything, or a browser refused the assignment,
// they still catch the common case.
// ResizeObserver fires on every frame of a panel drag, and each event would
// otherwise queue its own re-apply. Trail the burst and act once it settles.
let settle = null
const onObserved = (ev) => {
const d = ev.detail || {}
// A resize to zero, or losing intersection, is the element going away --
// nothing to re-apply to yet. Only act on it coming back.
const returning = d.entries?.some(e =>
e.isIntersecting === true || (e.width > 0 && e.height > 0))
if (!returning) return
clearTimeout(settle)
settle = setTimeout(() => {
dbg(`observer ${d.kind} settled -> viewer visible again`)
reapply({ type: `observer:${d.kind}` })
}, 150)
}
window.addEventListener(PF_OBSERVER_EVENT, onObserved)
document.addEventListener('visibilitychange', reapply)
window.addEventListener('focus', reapply)
window.addEventListener('pageshow', reapply)
return () => {
clearTimeout(settle)
window.removeEventListener(PF_OBSERVER_EVENT, onObserved)
document.removeEventListener('visibilitychange', reapply)
window.removeEventListener('focus', reapply)
window.removeEventListener('pageshow', reapply)
}
}, [])
// 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
const colFilters = splitBy
.map((col, ix) => {
const v = key.split('|')[ix]
return (v && !META_COL_RE.test(v)) ? [col, '==', v] : null
})
.filter(Boolean)
const slice = sliceFromFilters([...base, ...rowFilters, ...colFilters])
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) {
dbg(`theme effect -> ${dark ? 'Pro Dark' : 'Pro Light'} (a theme change rebuilds the view)`)
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']
async function totalsFor(sliceObj) {
const filters = [
...Object.entries(sliceObj)
.filter(([col]) => dimNames.has(col))
.map(([col, val]) => [col, '==', val]),
...Object.entries(sliceObj)
.filter(([col]) => dateNames.has(col))
.map(([col, val]) => [col, '==', Number(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.
const excluded = { value: 0, units: 0, rows: 0 }
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
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),
}), { value: 0, units: 0, rows: 0 })
setCurrentTotals({
byIter, byEntry, total, excluded, valueCol, unitsCol, perSlice,
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] = { tag: e.tag || null, note: e.note || null, operation: e.operation }
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])
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
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
dbg(`initViewer(version=${vid}, source=${sid}) -- FULL RELOAD, depth reset to null`)
const myId = ++initIdRef.current
setLoading(true)
setLargeDataset(false)
setLoadProgress(null)
setSlices([])
expandDepthRef.current = 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_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_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 {}
const opts = { name: tableName, index: indexCol }
tableRef.current = await (rowCount > 0 ? worker.table(buffer, opts) : worker.table([], opts))
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')
// 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.
const saved = localStorage.getItem(LAYOUT_KEY(vid))
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 outlives split_by: a layout saved while collapsed still knows
// the levels it was collapsed from
adoptSplit(cfg.split_full?.length ? cfg.split_full : cfg.split_by, (cfg.split_by || []).length)
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)
}
// auto-persist viewer state (formatting, columns, etc.) to the last-used cache
if (viewer._pspUpdate) viewer.removeEventListener('perspective-config-update', viewer._pspUpdate)
viewer._pspUpdate = async () => {
dbg(`perspective-config-update (collapsing=${collapsingRef.current})`)
await probeView('config-update')
try {
// A split_by change that is not ours is the user rearranging the pivot, and
// it redefines the hierarchy. Ours is a collapse, and must not overwrite it.
if (!collapsingRef.current) {
const live = await viewer.save()
adoptSplit(live.split_by || [], (live.split_by || []).length)
}
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 || [])
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()
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.
function adoptSplit(full, depth) {
const list = Array.isArray(full) ? full : []
splitFullRef.current = list
setSplitFull(list)
setSplitDepth(depth == null ? list.length : Math.min(depth, list.length))
}
// Collapse or expand the column axis to `n` split_by levels.
//
// The row axis gets this for free: its GROUP BY ROLLUP view holds every level at
// once and view.set_depth() hides the deeper ones. The column axis has no
// equivalent — there is no split_by_depth in ViewConfig and expand()/collapse()
// take a row index — so collapsing means restoring a truncated split_by, which
// rebuilds the view. Two consequences fall out of that: the row depth has to be
// re-applied afterwards (it lives on the discarded view), and it is whole-axis,
// not per-branch — every column group collapses to the same level together.
async function applySplitDepth(n) {
const viewer = viewerRef.current
const full = splitFullRef.current
if (!viewer || !full.length) return
const depth = Math.max(0, Math.min(n, full.length))
collapsingRef.current = true
try {
await viewer.restore({ split_by: full.slice(0, depth) })
setSplitDepth(depth)
// restore() rebuilt the view, so the row depth that lived on the old one is gone
if (expandDepthRef.current != null) await applyDepth(expandDepthRef.current)
} catch (err) {
console.error('[applySplitDepth]', err)
flash(err.message || String(err), 'error')
return
} finally {
collapsingRef.current = false
}
// a slice names the split_by dimensions it was cut from, and the highlight is
// keyed on grid coordinates — neither survives a column axis that just changed
setSlices([])
try {
const cfg = await captureConfig()
if (cfg) await persistLayout(versionId, cfg)
} catch (err) {
console.error('[applySplitDepth persist]', err)
}
}
async function applyDepth(d) {
const viewer = viewerRef.current
if (!viewer) return
const view = await viewer.getView()
dbg(`applyDepth(${d}) on view ${viewId(view)}`)
lastViewIdRef.current = viewId(view)
await view.set_depth(d)
const plugin = await viewer.getPlugin()
await plugin.draw(view)
expandDepthRef.current = d
}
async function captureConfig() {
const viewer = viewerRef.current
if (!viewer) return null
const cfg = await viewer.save()
return { ...cfg, expand_depth: expandDepthRef.current, split_full: splitFullRef.current }
}
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, expand_depth, ...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 || []).length)
if (cfg.expand_depth != null) await applyDepth(cfg.expand_depth)
setActiveLayoutId(layout.id)
await persistLayout(versionId, 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 }
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 }
}
if ((op === 'recode' || op === 'clone') && !Object.keys(body.set || {}).length) {
flash(op === 'recode' ? 'Enter at least one new dimension value' : 'Enter at least one override value', '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
})
}
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)) {
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
}
function buildPayload(op) {
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,
...(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 }
}
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,
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,
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" />
{/* 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
${expandDepthRef.current === 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-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" 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" placeholder="add note"
editing={editingCell} setEditing={setEditingCell} onSave={saveLogField} />
<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}
className="text-xs border border-red-200 text-red-400 hover:text-red-600 hover:border-red-400 rounded px-2 py-0.5 disabled:opacity-40 whitespace-nowrap">
{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={8} 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} />
</div>
</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}
/>
{/* 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">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 }) {
const active = editing?.id === entry.id && editing?.field === field
const value = entry[field] || ''
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>
)
}
// 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
function sliceFromFilters(filters) {
const s = {}
for (const f of filters) {
if (!Array.isArray(f)) continue
const [col, op, val] = f
if (op === '==' && val != null) 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' }