Compact the log's slice column, show value impact, expand the payload on click
Three things about the change log, from using it. The slice column grew to a block per entry once it listed lines, which is too much for a table you scan. It is one truncated line now: a single slice reads as its fields, and a dragged region as "5 slices · omon = 07 - Dec … 11 - Apr" -- enough to recognise the entry. There was no indication of what an entry did to the numbers, which is the first thing you want from a change log. The route already returned value_total and it simply was not rendered. Added, signed and coloured, so a list of adjustments reads as a list of impacts. And clicking a row now opens slice and params as formatted JSON -- the exact payload that went over the route, including the increments and the plug, which no amount of summarising in the cell was going to convey. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c566aab014
commit
1d2fc97eee
@ -64,6 +64,8 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
// volume flat, 'volume' keeps price flat. Mirrors the Excel form's
|
// volume flat, 'volume' keeps price flat. Mirrors the Excel form's
|
||||||
// Plug Price / Plug Volume choice.
|
// Plug Price / Plug Volume choice.
|
||||||
const [scalePlug, setScalePlug] = useState(() => localStorage.getItem('pf_scale_plug') || 'price')
|
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)
|
||||||
// what a target/percentage is measured against: the rows this operation can
|
// what a target/percentage is measured against: the rows this operation can
|
||||||
// write, or everything the pivot shows for the slice (excluded rows included)
|
// write, or everything the pivot shows for the slice (excluded rows included)
|
||||||
const [targetBasis, setTargetBasis] = useState('selected')
|
const [targetBasis, setTargetBasis] = useState('selected')
|
||||||
@ -1160,6 +1162,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
|
|
||||||
async function openLog() {
|
async function openLog() {
|
||||||
setShowLog(true)
|
setShowLog(true)
|
||||||
|
setExpandedLog(null)
|
||||||
setLogLoading(true)
|
setLogLoading(true)
|
||||||
try {
|
try {
|
||||||
// Baseline and reference loads are segment construction, not forecasting.
|
// Baseline and reference loads are segment construction, not forecasting.
|
||||||
@ -1411,6 +1414,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
<th className="text-left px-4 py-2 font-medium">Slice</th>
|
<th className="text-left px-4 py-2 font-medium">Slice</th>
|
||||||
<th className="text-left px-4 py-2 font-medium w-40">Tag</th>
|
<th className="text-left px-4 py-2 font-medium w-40">Tag</th>
|
||||||
<th className="text-left px-4 py-2 font-medium">Note</th>
|
<th className="text-left px-4 py-2 font-medium">Note</th>
|
||||||
|
<th className="text-right px-4 py-2 font-medium w-28">Value</th>
|
||||||
<th className="text-right px-4 py-2 font-medium w-16">Rows</th>
|
<th className="text-right px-4 py-2 font-medium w-16">Rows</th>
|
||||||
<th className="px-4 py-2 w-16"></th>
|
<th className="px-4 py-2 w-16"></th>
|
||||||
</tr>
|
</tr>
|
||||||
@ -1424,23 +1428,14 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
{entry.operation}
|
{entry.operation}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2 text-gray-600 font-mono align-top">
|
<td className="px-4 py-2 text-gray-600 font-mono max-w-xs">
|
||||||
{(() => {
|
<button
|
||||||
const lines = fmtSliceLines(entry.slice)
|
onClick={() => setExpandedLog(prev => prev === entry.id ? null : entry.id)}
|
||||||
if (lines.length === 0) return '—'
|
className="text-left w-full truncate hover:text-blue-600"
|
||||||
// the raw payload is on the title, for the case where the
|
title="Show the payload">
|
||||||
// rendering has flattened something worth seeing
|
<span className="text-gray-400 mr-1">{expandedLog === entry.id ? '▾' : '▸'}</span>
|
||||||
return (
|
{fmtSliceSummary(entry.slice)}
|
||||||
<div title={JSON.stringify(entry.slice, null, 2)}>
|
</button>
|
||||||
{lines.map((l, i) => (
|
|
||||||
<div key={i} className="whitespace-nowrap">
|
|
||||||
{lines.length > 1 && <span className="text-gray-400 mr-1">{i + 1}.</span>}
|
|
||||||
{l}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
</td>
|
</td>
|
||||||
<LogCell entry={entry} field="tag" placeholder="add tag"
|
<LogCell entry={entry} field="tag" placeholder="add tag"
|
||||||
editing={editingCell} setEditing={setEditingCell} onSave={saveLogField}
|
editing={editingCell} setEditing={setEditingCell} onSave={saveLogField}
|
||||||
@ -1452,6 +1447,11 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
)} />
|
)} />
|
||||||
<LogCell entry={entry} field="note" placeholder="add note"
|
<LogCell entry={entry} field="note" placeholder="add note"
|
||||||
editing={editingCell} setEditing={setEditingCell} onSave={saveLogField} />
|
editing={editingCell} setEditing={setEditingCell} onSave={saveLogField} />
|
||||||
|
<td className={`px-4 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-4 py-2 text-right text-gray-500 tabular-nums">{entry.row_count ?? '—'}</td>
|
<td className="px-4 py-2 text-right text-gray-500 tabular-nums">{entry.row_count ?? '—'}</td>
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
<button
|
<button
|
||||||
@ -1462,7 +1462,20 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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-4 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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
@ -1708,6 +1721,38 @@ function fmtSliceLines(slice) {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
function fmtSliceValue(v) {
|
||||||
if (v === null || v === undefined || v === '') return '∅'
|
if (v === null || v === undefined || v === '') return '∅'
|
||||||
if (typeof v === 'number' && v > 1e11) {
|
if (typeof v === 'number' && v > 1e11) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user