Stop the ledger printing a row per log entry

A slice as broad as a whole bucket reached every entry on the version, and
the walk stopped being a walk -- roughly 44 lines on the version this was
built against, where there are about a dozen things to say.

Three causes, three fixes.

Untagged adjustments had a line each, labelled "Scale #226" -- a row spent
to say nothing, 28 of them. They collapse to one line that counts them.

Tags grouped by exact text, so "Keystone reduction" and "Keystone
Reduction" reported as two initiatives, as did four spellings of moved to a
different sku. Grouping is case-insensitive now and the line shows whichever
spelling was used most. The tags themselves are untouched; wording variants
are still separate lines, and that is for whoever typed them.

What is left is capped: the loads stay whole, since they are the shape of
the forecast, and the adjustments keep the six largest with the rest rolled
into one line carrying its own subtotal. Still arithmetically the walk --
addition does not care about the order -- and the rollup expands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-21 23:41:25 -04:00
parent 4020a8b78e
commit 318a09b6f2

View File

@ -369,25 +369,77 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se
}
const tag = (meta.label || meta.tag || '').trim()
if (tag) {
const g = byTag.get(tag) ||
{ key: `tag:${tag}`, label: tag, kind: 'tag', value: 0, units: 0, count: 0, first: e.logid }
// Grouped case-insensitively. "Keystone reduction" and "Keystone
// Reduction" are one initiative typed twice, and splitting them here
// reports two lines for one thing. The spelling shown is whichever was
// used most; the tags themselves are untouched.
const k = tag.toLowerCase()
const g = byTag.get(k) ||
{ key: `tag:${k}`, label: tag, kind: 'tag', value: 0, units: 0, count: 0,
first: e.logid, spellings: new Map() }
g.value += e.value || 0
g.units += e.units || 0
g.count += 1
g.first = Math.min(g.first ?? e.logid, e.logid ?? g.first)
byTag.set(tag, g)
g.spellings.set(tag, (g.spellings.get(tag) || 0) + 1)
byTag.set(k, g)
} else {
const op = meta.operation || e.iter || 'adjustment'
loose.push({
...e, kind: 'entry', count: 1,
label: (meta.label || meta.note || '').trim()
|| `${op.charAt(0).toUpperCase()}${op.slice(1)} #${e.logid}`,
})
// One line for all of them, not one line each. An untagged adjustment
// has no name to tell it from the next untagged one, so listing them
// separately spends a row apiece to say "Scale #226" -- 28 of them on
// the version this was built against.
loose.push(e)
}
}
const tagged = [...byTag.values()].sort((a, b) => (a.first ?? 0) - (b.first ?? 0))
return [...baseline, ...tagged, ...loose]
const tagged = [...byTag.values()]
.sort((a, b) => (a.first ?? 0) - (b.first ?? 0))
.map(g => ({
...g,
label: [...g.spellings.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] || g.label,
}))
const untagged = loose.length ? [{
key: 'untagged',
kind: 'untagged',
label: `${loose.length} untagged adjustment${loose.length === 1 ? '' : 's'}`,
count: loose.length,
value: loose.reduce((a, e) => a + (e.value || 0), 0),
units: loose.reduce((a, e) => a + (e.units || 0), 0),
}] : []
return [...baseline, ...tagged, ...untagged]
})()
// A broad slice reaches every initiative on the version -- 15 tags and 48
// adjustments on the one this was built against -- and a walk that long stops
// being a walk. The loads stay whole, since they are the shape of the
// forecast; the adjustments keep the biggest few and roll the rest into one
// line, which is still arithmetically the walk: addition does not care about
// the order, and the line carries its own subtotal.
const MAX_ADJ_LINES = 6
const [showAllLines, setShowAllLines] = useState(false)
const loadLines = lines.filter(l => l.kind === 'baseline')
const adjLines = lines.filter(l => l.kind !== 'baseline')
const rolled = !showAllLines && adjLines.length > MAX_ADJ_LINES + 1
const displayLines = (() => {
if (!rolled) return lines
// Biggest by absolute value, then back into the order they happened, so the
// ones that are kept still read chronologically.
const rank = [...adjLines].sort((a, b) => Math.abs(b.value || 0) - Math.abs(a.value || 0))
const keep = new Set(rank.slice(0, MAX_ADJ_LINES).map(l => l.key))
const kept = adjLines.filter(l => keep.has(l.key))
const rest = adjLines.filter(l => !keep.has(l.key))
const sum = rest.reduce((a, l) => ({
value: a.value + (l.value || 0),
units: a.units + (l.units || 0),
count: a.count + (l.count || 1),
}), { value: 0, units: 0, count: 0 })
return [...loadLines, ...kept,
{ key: 'rollup', kind: 'rollup', label: `${rest.length} smaller changes`, ...sum }]
})()
const perSlice = multi && applyMode === 'each'
// rows the pivot shows but this operation cannot write
@ -559,10 +611,10 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se
const out = new Map()
let acc = 0
for (const seg of exclLines) { acc += seg.value || 0; out.set(`final:${seg.name}`, acc) }
for (const e of lines) { acc += e.value || 0; out.set(e.key, acc) }
for (const e of displayLines) { acc += e.value || 0; out.set(e.key, acc) }
return out
})()
const showRunning = !!valueCol && (lines.length + exclLines.length) > 1
const showRunning = !!valueCol && (displayLines.length + exclLines.length) > 1
// Which measure the plug holds still. Only meaningful while dollars are the
// sole input -- that is the one case where price and volume are both unknown
@ -628,15 +680,27 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se
</tr>
))}
{showAllLines && adjLines.length > MAX_ADJ_LINES + 1 && (
<tr>
<td colSpan={measures.length + 1 + (showRunning ? 1 : 0)} className="pr-3">
<button onClick={() => setShowAllLines(false)}
className="text-gray-400 hover:text-gray-600">▾ collapse the smaller changes</button>
</td>
</tr>
)}
{exclLines.length > 0 && (
<tr>{rule}{measures.map(m => <td key={m.key} className="p-0 px-2"><div className="border-t border-gray-300 my-1" /></td>)}{showRunning && <td className="p-0 px-2"><div className="border-t border-gray-300 my-1" /></td>}</tr>
)}
{/* the walk from baseline to current, by initiative */}
{lines.map(e => (
<tr key={e.key} className="text-gray-600">
{displayLines.map(e => (
<tr key={e.key}
className={e.kind === 'rollup' ? 'text-gray-500 cursor-pointer hover:text-gray-700' : 'text-gray-600'}
onClick={e.kind === 'rollup' ? () => setShowAllLines(true) : undefined}>
<td className="pr-3 whitespace-nowrap max-w-[14rem] truncate" title={e.label}>
{e.kind === 'tag' && <span className="text-blue-600">▪ </span>}
{(e.kind === 'rollup' || e.kind === 'untagged') && <span className="text-gray-400">▸ </span>}
{e.label}
{e.kind === 'tag' && e.count > 1 && <span className="text-gray-500"> ×{e.count}</span>}
</td>