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:
Paul Trowbridge 2026-09-16 23:39:21 -04:00
parent c566aab014
commit 1d2fc97eee

View File

@ -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) {