From 2f862a6c8bf06b59af606006f4360992a6ed5b49 Mon Sep 17 00:00:00 2001 From: Paul Trowbridge Date: Fri, 18 Sep 2026 09:38:37 -0400 Subject: [PATCH] Name the ledger's lines the way everything else names them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ui/src/components/BridgeView.jsx | 4 +++- ui/src/components/OperationPanel.jsx | 34 +++++++++++++++++++-------- ui/src/views/Forecast.jsx | 35 +++++++++++++++++++++++----- 3 files changed, 56 insertions(+), 17 deletions(-) diff --git a/ui/src/components/BridgeView.jsx b/ui/src/components/BridgeView.jsx index c4781ae..f61b71d 100644 --- a/ui/src/components/BridgeView.jsx +++ b/ui/src/components/BridgeView.jsx @@ -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}` diff --git a/ui/src/components/OperationPanel.jsx b/ui/src/components/OperationPanel.jsx index df16b0b..fcdb223 100644 --- a/ui/src/components/OperationPanel.jsx +++ b/ui/src/components/OperationPanel.jsx @@ -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 && ( - - - {exclName} - + {exclLines.map(seg => ( + + + {seg.name} + final {measures.map(m => ( - {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)} ))} - )} + ))} {hasExcl && ( diff --git a/ui/src/views/Forecast.jsx b/ui/src/views/Forecast.jsx index d0f2d9b..3618c8f 100644 --- a/ui/src/views/Forecast.jsx +++ b/ui/src/views/Forecast.jsx @@ -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)