pf_app/ui/src/views/Forecast.jsx
Paul Trowbridge 4fde752b54 Default forecast pivot to value column with pf_iter rows
New forecasts opened the pivot with all dimensions stacked as
group_by and the date column as split_by — wide and slow to read.
Open with just the value column showing and pf_iter as rows so the
first thing you see is iteration totals.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 11:08:43 -04:00

842 lines
37 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({ sourceId, versionId }) {
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 filters = [
...Object.entries(sliceObj)
.filter(([col]) => dimNames.has(col))
.map(([col, val]) => [col, '==', 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)
}
}
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 {
const [perspective, dataResult, meta] = await Promise.all([
loadPerspective(),
fetch(`/api/versions/${vid}/data`).then(async r => {
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
setLoadProgress({ received: 0, total })
while (true) {
const { done, value } = await reader.read()
if (done) break
chunks.push(value)
received += value.byteLength
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 }
}),
fetch(`/api/sources/${sid}/cols`).then(r => r.json()),
])
const { buffer, rowCount } = dataResult
colMetaRef.current = meta
const validCols = new Set([
...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
if (tableRef.current) {
try { await tableRef.current.delete() } catch {}
tableRef.current = null
}
const opts = { name: tableName, index: 'pf_id' }
const makeTable = async () => rowCount > 0 ? worker.table(buffer, opts) : worker.table([], opts)
try {
tableRef.current = await makeTable()
} catch (err) {
if (/already exists/i.test(String(err?.message || err))) {
try {
const existing = await worker.open_table(tableName)
if (existing) await existing.delete()
} catch {}
tableRef.current = await makeTable()
} else {
throw err
}
}
if (myId !== initIdRef.current) {
try { await tableRef.current.delete() } catch {}
tableRef.current = null
return
}
await viewer.load(worker)
viewer.setAttribute('theme', dark ? 'Pro Dark' : 'Pro Light')
// restore last-used layout or build default
const saved = localStorage.getItem(LAYOUT_KEY(vid))
if (saved) {
const cfg = cleanLayout(JSON.parse(saved), 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)
} else {
const valueCol = meta.find(c => c.role === 'value')?.cname
const cfg = {
table: tableName,
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) {
flash(err.message, '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 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 }
let body = { pf_user: 'admin', slice }
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') }
}
function flash(text, type = 'ok') {
setMsg({ text, type })
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))
if (data.pf_ids?.length && tableRef.current) {
await tableRef.current.remove(data.pf_ids)
}
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>
{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 ${msg.type === 'error' ? 'bg-red-50 text-red-600' : 'bg-green-50 text-green-600'}`}>
{msg.text}
</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>
<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.cname}>
<input value={recodeSet[c.cname] || ''} onChange={e => setRecodeSet(s => ({ ...s, [c.cname]: e.target.value }))}
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>
<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.cname}>
<input value={cloneSet[c.cname] || ''} onChange={e => setCloneSet(s => ({ ...s, [c.cname]: e.target.value }))}
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>
<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>
)
}