pf_app/ui/src/views/Forecast.jsx
Paul Trowbridge 654a368672 Add static display-grain pre-aggregation (col_meta.in_grain)
Ship rows pre-aggregated to the grain the pivot displays instead of raw
forecast rows. This is Path B from pf_perspective_options.md: it keeps
Perspective's native WASM engine — so expand/collapse/depth/sort/filter
all still work — and fixes load time by cutting rows, not transport.

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

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

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

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

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

991 lines
44 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'
const LAYOUT_KEY = (vid) => `pf_layout_v${vid}` // last-used layout (auto restore)
const LAYOUTS_KEY = (vid) => `pf_layouts_v${vid}` // named layout list
let perspectivePromise = null
function loadPerspective() {
if (perspectivePromise) return perspectivePromise
perspectivePromise = Promise.all([
import(/* @vite-ignore */ 'https://cdn.jsdelivr.net/npm/@perspective-dev/client@4.4.0/dist/cdn/perspective.js'),
import(/* @vite-ignore */ 'https://cdn.jsdelivr.net/npm/@perspective-dev/viewer@4.4.0/dist/cdn/perspective-viewer.js'),
import(/* @vite-ignore */ 'https://cdn.jsdelivr.net/npm/@perspective-dev/viewer-datagrid@4.4.0/dist/cdn/perspective-viewer-datagrid.js'),
import(/* @vite-ignore */ 'https://cdn.jsdelivr.net/npm/@perspective-dev/viewer-d3fc@4.4.0/dist/cdn/perspective-viewer-d3fc.js'),
]).then(([{ default: perspective }]) => perspective)
return perspectivePromise
}
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)
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, 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
const [slice, setSlice] = useState({})
const [activeOp, setActiveOp] = useState('scale')
const [currentTotals, setCurrentTotals] = useState(null) // { value, units }
const [scaleMode, setScaleMode] = useState('target') // 'target' | 'delta'
const [scaleValue, setScaleValue] = useState('')
const [scaleUnits, setScaleUnits] = useState('')
const [scalePrice, setScalePrice] = useState('')
const [scalePct, setScalePct] = useState(false)
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('')
const [panelWidth, setPanelWidth] = useState(224)
// history modal
const [showLog, setShowLog] = useState(false)
const [logEntries, setLogEntries] = useState([])
const [logLoading, setLogLoading] = useState(false)
const [editingNote, setEditingNote] = useState(null) // { id, 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)
const initIdRef = useRef(0)
function onDragStart(e) {
e.preventDefault()
const startX = e.clientX
const startW = panelWidth
const onMove = (ev) => setPanelWidth(Math.max(160, Math.min(480, startW - (ev.clientX - startX))))
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 blank = Object.fromEntries(Object.keys(slice).map(k => [k, '']))
setRecodeSet(blank)
setCloneSet(blank)
setScaleValue('')
setScaleUnits('')
setScalePrice('')
if (Object.keys(slice).length > 0) fetchCurrentTotals(slice)
else setCurrentTotals(null)
}, [slice])
async function fetchCurrentTotals(sliceObj) {
if (!tableRef.current) 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
try {
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 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)]),
['pf_iter', '!=', 'reference'],
]
const view = await tableRef.current.view({ filter: filters })
const rows = await view.to_json()
await view.delete()
const buckets = new Map()
for (const r of rows) {
const k = r.pf_iter || '?'
const t = buckets.get(k) || { value: 0, units: 0 }
if (valueCol) t.value += parseFloat(r[valueCol]) || 0
if (unitsCol) t.units += parseFloat(r[unitsCol]) || 0
buckets.set(k, t)
}
const ITER_ORDER = ['baseline', 'scale', 'recode', 'clone']
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((s, r) => ({ value: s.value + (r.value || 0), units: s.units + (r.units || 0) }), { value: 0, units: 0 })
setCurrentTotals({ byIter, total, valueCol, unitsCol })
} catch {
setCurrentTotals(null)
}
}
// 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
const myId = ++initIdRef.current
setLoading(true)
setLargeDataset(false)
setLoadProgress(null)
setSlice({})
expandDepthRef.current = null
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 [perspective, dataResult] = await Promise.all([
loadPerspective(),
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',
]
: [
...meta.filter(c => ['dimension','value','units','date'].includes(c.role)).map(c => c.cname),
'pf_id', 'pf_iter', 'pf_logid', 'pf_user', 'created_at',
])
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: { edit_mode: 'SELECT_REGION', ...(rest.plugin_config || {}) } }
await viewer.restore(cfg)
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: { edit_mode: 'SELECT_REGION', ...(rest.plugin_config || {}) } }
} 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)
}
// 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 () => {
try {
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)
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 eventFilters = (detail.config || {}).filter || []
const s = {}
eventFilters.forEach(([col, op, val]) => {
if (op === '==' && val != null) s[col] = String(val)
})
if (Object.keys(s).length > 0) setSlice(s)
}
viewer.addEventListener('perspective-click', viewer._pspClick)
setLargeDataset(false)
} catch (err) {
console.error('[initViewer]', err)
flash(err.message || String(err), 'error')
} finally {
setLoading(false)
}
}
async function applyDepth(d) {
const viewer = viewerRef.current
if (!viewer) return
const view = await viewer.getView()
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 }
}
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 = { edit_mode: 'SELECT_REGION', ...(cfg.plugin_config || {}) }
await viewer.restore(cfg)
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 (!Object.keys(slice).length) { flash('Select a slice first', 'error'); return }
const effectiveSlice = buildEffectiveSlice(slice)
if (!Object.keys(effectiveSlice).length) { flash('No dimension or date columns in slice — check col_meta', 'error'); return }
let body = { pf_user: 'admin', slice: effectiveSlice }
if (op === 'scale') {
let vi = null, ui = null
if (scaleMode === 'target') {
const curValue = currentTotals?.total?.value
const curUnits = currentTotals?.total?.units
if (scalePrice !== '' && curUnits != null && curValue != null) {
// hold units constant; new value = price × current units
vi = (parseFloat(scalePrice) * curUnits) - curValue
}
if (scaleValue !== '' && curValue != null)
vi = parseFloat(scaleValue) - curValue
if (scaleUnits !== '' && curUnits != null)
ui = parseFloat(scaleUnits) - curUnits
} else {
if (scaleValue !== '') vi = scalePct ? parseFloat(scaleValue) : parseFloat(scaleValue)
if (scaleUnits !== '') ui = scalePct ? parseFloat(scaleUnits) : parseFloat(scaleUnits)
}
if (vi == null && ui == null) { flash('Enter a target or increment', 'error'); return }
body = { ...body, note: scaleNote, value_incr: vi, units_incr: ui, pct: scaleMode === 'delta' && scalePct }
} else if (op === 'recode') {
const set = Object.fromEntries(Object.entries(recodeSet).filter(([, v]) => v.trim()))
if (!Object.keys(set).length) { flash('Enter at least one new dimension value', 'error'); return }
body = { ...body, note: recodeNote, set }
} else if (op === 'clone') {
const set = Object.fromEntries(Object.entries(cloneSet).filter(([, v]) => v.trim()))
if (!Object.keys(set).length) { flash('Enter at least one override value', 'error'); return }
body = { ...body, note: cloneNote, set, scale: parseFloat(cloneScale) || 1 }
}
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)
flash(`${op}: ${data.rows_affected ?? data.rows?.length ?? ''} rows`)
if (op === 'scale') { setScaleValue(''); setScaleUnits(''); setScalePrice(''); setScaleNote(''); fetchCurrentTotals(slice) }
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 res = await fetch(`/api/sources/${sourceId}/lookup?col=${encodeURIComponent(col)}&value=${encodeURIComponent(value)}`)
if (!res.ok) return
const derived = await res.json()
if (!derived) return
setter(prev => {
const next = { ...prev }
for (const [k, v] of Object.entries(derived)) {
if (!prev[k] || prev[k] === '') 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 (!Object.keys(slice).length) return null
const effectiveSlice = buildEffectiveSlice(slice)
let body = { pf_user: 'admin', slice: effectiveSlice }
if (op === 'scale') {
let vi = null, ui = null
if (scaleMode === 'target') {
const curValue = currentTotals?.total?.value
const curUnits = currentTotals?.total?.units
if (scalePrice !== '' && curUnits != null && curValue != null)
vi = (parseFloat(scalePrice) * curUnits) - curValue
if (scaleValue !== '' && curValue != null)
vi = parseFloat(scaleValue) - curValue
if (scaleUnits !== '' && curUnits != null)
ui = parseFloat(scaleUnits) - curUnits
} else {
if (scaleValue !== '') vi = parseFloat(scaleValue)
if (scaleUnits !== '') ui = parseFloat(scaleUnits)
}
body = { ...body, note: scaleNote || undefined, value_incr: vi, units_incr: ui, pct: scaleMode === 'delta' && scalePct }
} 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 })
if (type !== 'error') setTimeout(() => setMsg(null), 3000)
}
async function openLog() {
setShowLog(true)
setLogLoading(true)
try {
const data = await fetch(`/api/versions/${versionId}/log`).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)
}
}
async function saveNote(logId, text) {
try {
const res = await fetch(`/api/log/${logId}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ note: text })
})
if (!res.ok) { flash('Failed to save note', 'error'); return }
setLogEntries(prev => prev.map(e => e.id === logId ? { ...e, note: text } : e))
setEditingNote(null)
} catch (err) {
flash(err.message, 'error')
}
}
const dimCols = colMetaRef.current.filter(c => c.role === 'dimension')
const hasSlice = Object.keys(slice).length > 0
return (
<div className="h-full flex flex-col">
{/* 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>
<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>
</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' : 'bg-green-50 text-green-600'}`}>
{msg.text}
{msg.type === 'error' && (
<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-4xl mx-4 flex flex-col max-h-[80vh]" 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">
<thead className="sticky top-0 bg-gray-50 text-gray-400 uppercase tracking-wide" style={{fontSize:'10px'}}>
<tr>
<th className="text-left px-4 py-2 font-medium w-32">Time</th>
<th className="text-left px-4 py-2 font-medium w-24">Op</th>
<th className="text-left px-4 py-2 font-medium">Slice</th>
<th className="text-left px-4 py-2 font-medium">Note</th>
<th className="text-right px-4 py-2 font-medium w-16">Rows</th>
<th className="px-4 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-4 py-2 text-gray-400 whitespace-nowrap">{fmtStamp(entry.stamp)}</td>
<td className="px-4 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-4 py-2 text-gray-600 font-mono">{fmtSlice(entry.slice)}</td>
<td className="px-4 py-2 text-gray-600 max-w-xs">
{editingNote?.id === entry.id ? (
<div className="flex items-center gap-1">
<input autoFocus value={editingNote.text}
onChange={e => setEditingNote(n => ({ ...n, text: e.target.value }))}
onKeyDown={e => {
if (e.key === 'Enter') saveNote(entry.id, editingNote.text)
if (e.key === 'Escape') setEditingNote(null)
}}
className="border border-blue-300 rounded px-1.5 py-0.5 text-xs flex-1 focus:outline-none" />
<button onClick={() => saveNote(entry.id, editingNote.text)} className="text-blue-600 hover:text-blue-800"></button>
<button onClick={() => setEditingNote(null)} className="text-gray-400 hover:text-gray-600"></button>
</div>
) : (
<span onClick={() => setEditingNote({ id: entry.id, text: entry.note || '' })}
className="cursor-text hover:bg-blue-50 rounded px-1 -mx-1 block truncate"
title={entry.note || 'Click to add note'}>
{entry.note || <span className="text-gray-300 italic">add note</span>}
</span>
)}
</td>
<td className="px-4 py-2 text-right text-gray-500 tabular-nums">{entry.row_count ?? '—'}</td>
<td className="px-4 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>
))}
</tbody>
</table>
)}
</div>
</div>
</div>
)}
{/* Main area */}
<div className="flex-1 flex min-h-0">
{/* Perspective viewer */}
<div className="relative flex-1 min-w-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>
{/* Drag handle */}
<div onMouseDown={onDragStart} className="w-1 shrink-0 cursor-col-resize hover:bg-blue-400 bg-transparent transition-colors" />
{/* Operation panel */}
<div className="shrink-0 border-l border-gray-200 bg-white flex flex-col overflow-y-auto text-xs" style={{ width: panelWidth }}>
<div className="p-3 border-b border-gray-100">
<div className="font-medium text-gray-400 uppercase tracking-wide mb-2" style={{fontSize:'10px'}}>Slice</div>
{!hasSlice ? (
<div className="text-gray-300 italic">Click a pivot row to select a slice</div>
) : (
<div className="flex flex-col gap-1">
{Object.entries(slice).map(([k, v]) => (
<div key={k} className="text-gray-700">
<span className="text-gray-400">{k}</span> = <span className="font-medium">{v}</span>
</div>
))}
<button onClick={() => setSlice({})} className="text-gray-300 hover:text-red-500 mt-1 text-left">Clear</button>
</div>
)}
</div>
{hasSlice && currentTotals?.byIter?.length > 0 && (
<div className="px-3 py-2 border-b border-gray-100">
<div className="font-medium text-gray-400 uppercase tracking-wide mb-1.5" style={{fontSize:'10px'}}>Current</div>
<table className="w-full text-xs">
<thead>
<tr className="text-gray-400" style={{fontSize:'10px'}}>
<th className="text-left font-normal pb-1"></th>
{currentTotals.valueCol && <th className="text-right font-normal pb-1 pl-1">{currentTotals.valueCol}</th>}
{currentTotals.unitsCol && <th className="text-right font-normal pb-1 pl-1">{currentTotals.unitsCol}</th>}
{currentTotals.valueCol && currentTotals.unitsCol && <th className="text-right font-normal pb-1 pl-1">price</th>}
</tr>
</thead>
<tbody>
{currentTotals.byIter.map(r => (
<tr key={r.iter}>
<td className="text-gray-500 capitalize pr-1">{r.iter}</td>
{currentTotals.valueCol && <td className="text-right font-mono text-gray-700 pl-1">{fmtNum(r.value)}</td>}
{currentTotals.unitsCol && <td className="text-right font-mono text-gray-700 pl-1">{fmtNum(r.units)}</td>}
{currentTotals.valueCol && currentTotals.unitsCol && <td className="text-right font-mono text-gray-700 pl-1">{fmtNum(r.units ? r.value / r.units : null, 4)}</td>}
</tr>
))}
{currentTotals.byIter.length > 1 && (
<tr className="border-t border-gray-100">
<td className="text-gray-600 font-medium pt-1 pr-1">total</td>
{currentTotals.valueCol && <td className="text-right font-mono font-semibold text-gray-800 pt-1 pl-1">{fmtNum(currentTotals.total.value)}</td>}
{currentTotals.unitsCol && <td className="text-right font-mono font-semibold text-gray-800 pt-1 pl-1">{fmtNum(currentTotals.total.units)}</td>}
{currentTotals.valueCol && currentTotals.unitsCol && <td className="text-right font-mono font-semibold text-gray-800 pt-1 pl-1">{fmtNum(currentTotals.total.units ? currentTotals.total.value / currentTotals.total.units : null, 4)}</td>}
</tr>
)}
</tbody>
</table>
</div>
)}
{hasSlice && (
<>
<div className="flex border-b border-gray-100">
{['scale', 'recode', 'clone'].map(op => (
<button key={op} onClick={() => setActiveOp(op)}
className={`flex-1 py-2 capitalize ${activeOp === op ? 'border-b-2 border-blue-500 text-blue-600 font-medium' : 'text-gray-400 hover:text-gray-600'}`}>
{op}
</button>
))}
</div>
<div className="p-3 flex flex-col gap-2.5">
{activeOp === 'scale' && <>
{/* Mode toggle */}
<div className="flex rounded border border-gray-200 overflow-hidden">
{[['target','= Target'],['delta','Δ Increment']].map(([m, label]) => (
<button key={m} onClick={() => { setScaleMode(m); setScaleValue(''); setScaleUnits(''); setScalePrice('') }}
className={`flex-1 py-1 text-xs ${scaleMode === m ? 'bg-blue-600 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'}`}>
{label}
</button>
))}
</div>
{currentTotals?.valueCol && (
<Row label={currentTotals.valueCol}>
<input type="number" step="any" value={scaleValue}
onChange={e => setScaleValue(e.target.value)}
placeholder={scaleMode === 'target' ? 'target total' : '+ / amount'}
className={inp} />
</Row>
)}
{currentTotals?.unitsCol && (
<Row label={currentTotals.unitsCol}>
<input type="number" step="any" value={scaleUnits}
onChange={e => setScaleUnits(e.target.value)}
placeholder={scaleMode === 'target' ? 'target total' : '+ / amount'}
className={inp} />
</Row>
)}
{scaleMode === 'target' && currentTotals?.valueCol && currentTotals?.unitsCol && (
<Row label="price">
<input type="number" step="any" value={scalePrice}
onChange={e => setScalePrice(e.target.value)}
placeholder="target price (holds units)"
className={inp} />
</Row>
)}
{scaleMode === 'delta' && (
<label className="flex items-center gap-2 text-gray-500">
<input type="checkbox" checked={scalePct} onChange={e => setScalePct(e.target.checked)} /> % of slice
</label>
)}
<Row label="Note"><input value={scaleNote} onChange={e => setScaleNote(e.target.value)} placeholder="optional" className={inp} /></Row>
<PayloadPreview payload={buildPayload('scale')} />
<Submit onClick={() => submitOp('scale')}>Apply Scale</Submit>
</>}
{activeOp === 'recode' && <>
<p className="text-gray-400">New values for dimensions to replace. Leave blank to keep.</p>
{dimCols.map(c => (
<Row key={c.cname} label={c.label || c.cname}>
<input
value={recodeSet[c.cname] || ''}
onChange={e => setRecodeSet(s => ({ ...s, [c.cname]: e.target.value }))}
onBlur={c.is_key && c.dim_group
? e => lookupDerivedCols(c.cname, e.target.value, setRecodeSet)
: undefined}
placeholder={slice[c.cname] || '—'}
className={`${inp} font-mono`} />
</Row>
))}
<Row label="Note"><input value={recodeNote} onChange={e => setRecodeNote(e.target.value)} placeholder="optional" className={inp} /></Row>
<PayloadPreview payload={buildPayload('recode')} />
<Submit onClick={() => submitOp('recode')}>Apply Recode</Submit>
</>}
{activeOp === 'clone' && <>
<p className="text-gray-400">Override dimensions on cloned rows. Leave blank to keep.</p>
{dimCols.map(c => (
<Row key={c.cname} label={c.label || c.cname}>
<input
value={cloneSet[c.cname] || ''}
onChange={e => setCloneSet(s => ({ ...s, [c.cname]: e.target.value }))}
onBlur={c.is_key && c.dim_group
? e => lookupDerivedCols(c.cname, e.target.value, setCloneSet)
: undefined}
placeholder={slice[c.cname] || '—'}
className={`${inp} font-mono`} />
</Row>
))}
<Row label="Scale"><input type="number" step="any" value={cloneScale} onChange={e => setCloneScale(e.target.value)} className={inp} /></Row>
<Row label="Note"><input value={cloneNote} onChange={e => setCloneNote(e.target.value)} placeholder="optional" className={inp} /></Row>
<PayloadPreview payload={buildPayload('clone')} />
<Submit onClick={() => submitOp('clone')}>Apply Clone</Submit>
</>}
</div>
</>
)}
</div>
</div>
</div>
)
}
const inp = 'border border-gray-200 rounded px-2 py-1 text-xs flex-1 bg-white min-w-0'
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 fmtNum(n, decimals = 2) {
if (n == null || !isFinite(n)) return '—'
return n.toLocaleString(undefined, { maximumFractionDigits: decimals })
}
function fmtStamp(stamp) {
return new Date(stamp).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
}
function fmtSlice(slice) {
if (!slice || !Object.keys(slice).length) return '—'
return Object.entries(slice).map(([k, v]) => `${k} = ${v}`).join(', ')
}
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' }
function Row({ label, children }) {
return (
<div className="flex items-center gap-2">
<span className="text-gray-400 w-14 shrink-0 truncate" title={label}>{label}</span>
{children}
</div>
)
}
function Submit({ onClick, children }) {
return (
<button onClick={onClick} className="mt-1 bg-blue-600 text-white text-xs px-3 py-1.5 rounded hover:bg-blue-700 w-full">
{children}
</button>
)
}
function PayloadPreview({ payload }) {
if (!payload) return null
return (
<pre className="text-xs font-mono text-gray-400 bg-gray-50 border border-gray-100 rounded p-2 overflow-auto max-h-36 leading-relaxed">
{JSON.stringify(payload, null, 2)}
</pre>
)
}