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 } if (isLoad) { loads.value += v; loads.units += u; loads.rows += 1; continue }
const meta = logMeta[r.pf_logid] || {} 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() || const label = tag || (meta.note || '').trim() ||
`${(meta.operation || r.pf_iter || 'adj')}${r.pf_logid != null ? ` #${r.pf_logid}` : ''}` `${(meta.operation || r.pf_iter || 'adj')}${r.pf_logid != null ? ` #${r.pf_logid}` : ''}`
const key = tag ? `tag:${tag}` : `log:${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 loose = []
const byTag = new Map() const byTag = new Map()
for (const e of entries) { for (const e of entries) {
if (e.key === 'baseline') { baseline.push({ ...e, label: 'Baseline', kind: 'baseline' }); continue }
const meta = logMeta[e.logid] || {} 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) { if (tag) {
const g = byTag.get(tag) || const g = byTag.get(tag) ||
{ key: `tag:${tag}`, label: tag, kind: 'tag', value: 0, units: 0, count: 0, first: e.logid } { 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' const op = meta.operation || e.iter || 'adjustment'
loose.push({ loose.push({
...e, kind: 'entry', count: 1, ...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. // otherwise prints a row of zeros and leaves the reason to be worked out.
const nothingToAdjust = hasExcl && !total.value && !total.units 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 // the basis decides which line the editable rows are measured from
const basisOf = (key) => { const basisOf = (key) => {
if (!onTotal) return key === 'price' ? curPrice : total[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 {/* Rows the pivot shows but operations cannot write. Listed so the
panel's figures reconcile with what the grid displays. */} panel's figures reconcile with what the grid displays. */}
{hasExcl && ( {exclLines.map(seg => (
<tr className="text-amber-700"> <tr key={seg.name} className="text-amber-700">
<td className="pr-3 whitespace-nowrap max-w-[16rem] truncate" title={exclName}> <td className="pr-3 whitespace-nowrap max-w-[16rem] truncate" title={seg.name}>
{exclName} {seg.name}
<span className="ml-1 px-1 py-0.5 rounded bg-amber-50 text-amber-700 text-[10px] uppercase tracking-wide"> <span className="ml-1.5 px-1 py-0.5 rounded bg-amber-50 text-amber-700 text-[10px] uppercase tracking-wide">
final final
</span> </span>
</td> </td>
{measures.map(m => ( {measures.map(m => (
<td key={m.key} className={`${numCell} text-amber-700`}> <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> </td>
))} ))}
</tr> </tr>
)} ))}
{hasExcl && ( {hasExcl && (
<tr className={onTotal ? 'font-semibold text-gray-700' : 'text-gray-600'}> <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'). // rows the pivot shows but operations cannot write (usually 'reference').
// Kept separate rather than filtered away: the grid total includes them, // Kept separate rather than filtered away: the grid total includes them,
// so the panel has to account for them or the two disagree. // 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) { for (const r of rows) {
const k = r.pf_iter || '?' const k = r.pf_iter || '?'
const val = valueCol ? (parseFloat(r[valueCol]) || 0) : 0 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 // Name them by what they are, not by the iter band that happens to
// exclude them: "02 - Prior Year" means something to a forecaster, // exclude them: "02 - Prior Year" means something to a forecaster,
// "reference" is the mechanism. // "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 continue
} }
@ -583,11 +592,24 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
units: acc.units + (ps.excluded?.units || 0), units: acc.units + (ps.excluded?.units || 0),
rows: acc.rows + (ps.excluded?.rows || 0), rows: acc.rows + (ps.excluded?.rows || 0),
names: new Set([...acc.names, ...(ps.excluded?.names || [])]), 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({ setCurrentTotals({
byIter, byEntry, total, valueCol, unitsCol, perSlice, 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], excludedIters: [...excludeIters],
}) })
} catch { } 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 entries = await fetch(`/api/versions/${vid}/log`).then(r => r.json())
const map = {} const map = {}
for (const e of entries) map[e.id] = { for (const e of entries) map[e.id] = {
label: e.label || null,
tag: e.tag || null, note: e.note || null, operation: e.operation, 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) setLogMeta(map)
} catch { setLogMeta({}) } } catch { setLogMeta({}) }
@ -1486,7 +1509,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
.map(([id, m]) => ({ .map(([id, m]) => ({
id: Number(id), id: Number(id),
operation: m.operation, operation: m.operation,
label: (m.tag || m.note || '').trim(), label: (m.label || m.tag || m.note || '').trim(),
})) }))
.sort((a, b) => a.id - b.id) .sort((a, b) => a.id - b.id)