diff --git a/ui/src/components/OperationPanel.jsx b/ui/src/components/OperationPanel.jsx index 0bfa855..f83f07f 100644 --- a/ui/src/components/OperationPanel.jsx +++ b/ui/src/components/OperationPanel.jsx @@ -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 ))} + {showAllLines && adjLines.length > MAX_ADJ_LINES + 1 && ( + + + + + + )} + {exclLines.length > 0 && ( {rule}{measures.map(m =>
)}{showRunning &&
} )} {/* the walk from baseline to current, by initiative */} - {lines.map(e => ( - + {displayLines.map(e => ( + setShowAllLines(true) : undefined}> {e.kind === 'tag' && ▪ } + {(e.kind === 'rollup' || e.kind === 'untagged') && ▸ } {e.label} {e.kind === 'tag' && e.count > 1 && ×{e.count}}