Name the ledger's lines the way everything else names them

The walk read from tag and note, and hardcoded the word "Baseline" for the
baseline load -- so a segment called 03 - New Orders in the pivot, in the
bridge and on the Baseline page read as "Baseline" in the one place you go
to check a number before changing it. label comes first now, the same
precedence pf_segment uses, in the ledger and the bridge alike. logMeta did
not carry label at all, which is why neither could reach it.

The immovable rows split one line per segment. Combined, "01 - YTD Sales ·
02 - Open Orders" said 1.6m was untouchable without saying how much of it was
billed and how much was booked -- different things a forecaster treats
differently. The FINAL badge also gains the space it was missing, having
rendered as "02 - Open Ordersfinal".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-18 09:38:37 -04:00
parent f9424d9c42
commit 2f862a6c8b
3 changed files with 56 additions and 17 deletions

View File

@ -106,7 +106,9 @@ export function buildSteps(rows, {
if (isLoad) { loads.value += v; loads.units += u; loads.rows += 1; continue }
const meta = logMeta[r.pf_logid] || {}
const tag = (meta.tag || '').trim()
// label first, the same precedence pf_segment uses, so the bridge and the
// pivot call a step by the same name
const tag = (meta.label || meta.tag || '').trim()
const label = tag || (meta.note || '').trim() ||
`${(meta.operation || r.pf_iter || 'adj')}${r.pf_logid != null ? ` #${r.pf_logid}` : ''}`
const key = tag ? `tag:${tag}` : `log:${r.pf_logid}`

View File

@ -287,9 +287,16 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se
const loose = []
const byTag = new Map()
for (const e of entries) {
if (e.key === 'baseline') { baseline.push({ ...e, label: 'Baseline', kind: 'baseline' }); continue }
const meta = logMeta[e.logid] || {}
const tag = (meta.tag || '').trim()
// Named like every other line: the label the pivot shows, then the older
// fallbacks. "Baseline" was hardcoded, so a segment called 03 - New Orders
// everywhere else read as "Baseline" here alone.
if (e.key === 'baseline') {
const name = (meta.label || meta.tag || meta.note || '').trim()
baseline.push({ ...e, label: name || 'Baseline', kind: 'baseline' })
continue
}
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 }
@ -302,7 +309,8 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se
const op = meta.operation || e.iter || 'adjustment'
loose.push({
...e, kind: 'entry', count: 1,
label: (meta.note || '').trim() || `${op.charAt(0).toUpperCase()}${op.slice(1)} #${e.logid}`,
label: (meta.label || meta.note || '').trim()
|| `${op.charAt(0).toUpperCase()}${op.slice(1)} #${e.logid}`,
})
}
}
@ -327,6 +335,12 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se
// otherwise prints a row of zeros and leaves the reason to be worked out.
const nothingToAdjust = hasExcl && !total.value && !total.units
// One line per immovable segment. Falls back to the combined figure for a
// selection whose rows carry no segment name.
const exclLines = (currentTotals?.excluded?.bySegment?.length
? currentTotals.excluded.bySegment
: (hasExcl ? [{ name: exclName, ...excl }] : []))
// the basis decides which line the editable rows are measured from
const basisOf = (key) => {
if (!onTotal) return key === 'price' ? curPrice : total[key]
@ -468,21 +482,21 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se
{/* Rows the pivot shows but operations cannot write. Listed so the
panel's figures reconcile with what the grid displays. */}
{hasExcl && (
<tr className="text-amber-700">
<td className="pr-3 whitespace-nowrap max-w-[16rem] truncate" title={exclName}>
{exclName}
<span className="ml-1 px-1 py-0.5 rounded bg-amber-50 text-amber-700 text-[10px] uppercase tracking-wide">
{exclLines.map(seg => (
<tr key={seg.name} className="text-amber-700">
<td className="pr-3 whitespace-nowrap max-w-[16rem] truncate" title={seg.name}>
{seg.name}
<span className="ml-1.5 px-1 py-0.5 rounded bg-amber-50 text-amber-700 text-[10px] uppercase tracking-wide">
final
</span>
</td>
{measures.map(m => (
<td key={m.key} className={`${numCell} text-amber-700`}>
{m.key === 'price' ? fmtNum(priceOf(excl), m.dp) : fmtNum(excl[m.key], m.dp)}
{m.key === 'price' ? fmtNum(priceOf(seg), m.dp) : fmtNum(seg[m.key], m.dp)}
</td>
))}
</tr>
)}
))}
{hasExcl && (
<tr className={onTotal ? 'font-semibold text-gray-700' : 'text-gray-600'}>

View File

@ -497,7 +497,10 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
// rows the pivot shows but operations cannot write (usually 'reference').
// Kept separate rather than filtered away: the grid total includes them,
// so the panel has to account for them or the two disagree.
const excluded = { value: 0, units: 0, rows: 0, names: new Set() }
// Per segment, not one lump: YTD Sales and Open Orders are different
// things, and a single "final" line hides which part of the number is
// which.
const excluded = { value: 0, units: 0, rows: 0, names: new Set(), bySegment: new Map() }
for (const r of rows) {
const k = r.pf_iter || '?'
const val = valueCol ? (parseFloat(r[valueCol]) || 0) : 0
@ -510,7 +513,13 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
// Name them by what they are, not by the iter band that happens to
// exclude them: "02 - Prior Year" means something to a forecaster,
// "reference" is the mechanism.
if (r.pf_segment) excluded.names.add(String(r.pf_segment))
const name = String(r.pf_segment || 'excluded')
excluded.names.add(name)
const seg = excluded.bySegment.get(name) || { name, value: 0, units: 0, rows: 0 }
seg.value += val
seg.units += uni
seg.rows += 1
excluded.bySegment.set(name, seg)
continue
}
@ -583,11 +592,24 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
units: acc.units + (ps.excluded?.units || 0),
rows: acc.rows + (ps.excluded?.rows || 0),
names: new Set([...acc.names, ...(ps.excluded?.names || [])]),
}), { value: 0, units: 0, rows: 0, names: new Set() })
bySegment: (() => {
const m = acc.bySegment
for (const seg of (ps.excluded?.bySegment?.values?.() || [])) {
const t = m.get(seg.name) || { name: seg.name, value: 0, units: 0, rows: 0 }
t.value += seg.value; t.units += seg.units; t.rows += seg.rows
m.set(seg.name, t)
}
return m
})(),
}), { value: 0, units: 0, rows: 0, names: new Set(), bySegment: new Map() })
setCurrentTotals({
byIter, byEntry, total, valueCol, unitsCol, perSlice,
excluded: { ...excluded, names: [...excluded.names].sort() },
excluded: {
...excluded,
names: [...excluded.names].sort(),
bySegment: [...excluded.bySegment.values()].sort((a, b) => a.name.localeCompare(b.name)),
},
excludedIters: [...excludeIters],
})
} catch {
@ -603,8 +625,9 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
const entries = await fetch(`/api/versions/${vid}/log`).then(r => r.json())
const map = {}
for (const e of entries) map[e.id] = {
label: e.label || null,
tag: e.tag || null, note: e.note || null, operation: e.operation,
bucket: e.bucket || null, seq: e.seq ?? null,
bucket: e.bucket || null,
}
setLogMeta(map)
} catch { setLogMeta({}) }
@ -1486,7 +1509,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
.map(([id, m]) => ({
id: Number(id),
operation: m.operation,
label: (m.tag || m.note || '').trim(),
label: (m.label || m.tag || m.note || '').trim(),
}))
.sort((a, b) => a.id - b.id)