From 05631817aecf2686b590fc4a3dd5530070f870bc Mon Sep 17 00:00:00 2001 From: Paul Trowbridge Date: Mon, 21 Sep 2026 15:03:41 -0400 Subject: [PATCH] Scope tag completions to the version The list came from every version of the source, joined through pf.version, so adjusting this year's plan offered last year's initiatives and counted uses earned somewhere else. A tag names an initiative inside a scenario. A new version now completes from nothing until it has a tag of its own, which is what it actually has: cloning a definition copies no adjustments. The bucket datalist on the Baseline page was already per version, so the two agree now. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 21 ++++++++++++++++++++- routes/versions.js | 19 +++++++++++++++---- ui/src/views/Forecast.jsx | 12 ++++++------ 3 files changed, 41 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5d318bf..899fb7c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,7 +27,7 @@ routes/ auth.js POST /api/login, /api/logout, GET /api/me; login throttle tables.js GET /api/tables, /api/tables/:schema/:tname/preview sources.js Source registration, col_meta, SQL generation - versions.js Version CRUD, baseline/reference load, data stream + versions.js Version CRUD, baseline/reference load, data stream, tag completions operations.js scale, recode, clone, undo — the core forecast ops log.js GET /api/versions/:id/log, DELETE /api/log/:logid layouts.js Named pivot layouts — list per version, create, patch, delete @@ -323,6 +323,25 @@ together. `columns` selects which *measures* appear, not individual split combinations. +## Tag completions are per version + +`GET /versions/:id/tags` — the tags already used on **this** version, with their +use counts, for completing the next adjustment. `Forecast.jsx` refetches on +`versionId` and after every tagged write. + +It used to be `GET /sources/:id/tags`, joined through `pf.version` and grouped +across every version of the source. A tag names an initiative *inside* a +scenario, so that put last year's work in front of this year's and reported a +`uses` count earned on a version the tag did not belong to. The cost of the +narrower scope is that a fresh version completes from nothing until it has a tag +of its own — which is the honest state of it, since cloning a definition copies +no adjustments. + +The `pf_bucket` datalist on the Baseline page was already per version +(`bucketsInUse`), so the two now agree. + +--- + ## Renaming a version Safe at any time, including on a closed one. Nothing derives from the name: the diff --git a/routes/versions.js b/routes/versions.js index aa03e22..356f191 100644 --- a/routes/versions.js +++ b/routes/versions.js @@ -356,18 +356,29 @@ ${colDefs}, // Tags already used on this source, newest first — feeds the tag autocomplete. // Scoped to the source rather than the version so an initiative name carries // across versions, which is the point of naming it. - router.get('/sources/:id/tags', async (req, res) => { + // Tags already used on one version, for completing the next adjustment. + // + // Scoped to the version, not the source. A tag names an initiative inside a + // scenario -- what this plan is doing -- so offering every tag anyone has + // ever used on the source put last year's work in the way of this year's, + // and the `uses` count read as activity on a version it did not belong to. + // The cost is that a fresh version completes from nothing until it has a + // tag of its own, which is the honest state of it: cloning a definition + // copies no adjustments, so it has no initiatives yet. + router.get('/versions/:id/tags', async (req, res) => { try { const result = await pool.query(` SELECT l.tag, count(*)::int AS uses, max(l.stamp) AS last_used FROM pf.log l - JOIN pf.version v ON v.id = l.version_id - WHERE v.source_id = $1 AND l.tag IS NOT NULL AND l.tag <> '' + WHERE TRUE + AND l.version_id = $1 + AND l.tag IS NOT NULL + AND l.tag <> '' GROUP BY l.tag ORDER BY max(l.stamp) DESC - `, [req.params.id]); + `, [parseInt(req.params.id)]); res.json(result.rows); } catch (err) { console.error(err); diff --git a/ui/src/views/Forecast.jsx b/ui/src/views/Forecast.jsx index 318f828..8d8d528 100644 --- a/ui/src/views/Forecast.jsx +++ b/ui/src/views/Forecast.jsx @@ -664,10 +664,10 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio } catch { setLogMeta({}) } } - async function refreshTags(sid) { - if (!sid) { setKnownTags([]); return } + async function refreshTags(vid) { + if (!vid) { setKnownTags([]); return } try { - const rows = await fetch(`/api/sources/${sid}/tags`).then(r => r.json()) + const rows = await fetch(`/api/versions/${vid}/tags`).then(r => r.json()) setKnownTags(Array.isArray(rows) ? rows : []) } catch { setKnownTags([]) } } @@ -705,7 +705,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio useEffect(() => { refreshLogMeta(versionId) }, [versionId]) // Relabelling on the Baseline page needs a reload to show here: the label is // part of the aggregated row, so the pivot cannot re-derive it in place. - useEffect(() => { refreshTags(sourceId) }, [sourceId]) + useEffect(() => { refreshTags(versionId) }, [versionId]) // Stream an Arrow IPC endpoint into one buffer, reporting download progress. // Both /data and /agg speak the same protocol — a single record batch plus an @@ -1621,7 +1621,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio // let the status bar re-read the forecast table's row count window.dispatchEvent(new CustomEvent('pf-data-changed')) refreshLogMeta(versionId) - if (opTag.trim()) refreshTags(sourceId) + if (opTag.trim()) refreshTags(versionId) if (op === 'scale') { setScaleInputs({}); setScaleNote(''); fetchCurrentTotals(slices) } if (op === 'recode') { setRecodeNote('') } if (op === 'clone') { setCloneNote(''); setCloneScale('1') } @@ -1849,7 +1849,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio if (field === 'tag') { // the ledger names its lines from this, and the tag list gains a new entry refreshLogMeta(versionId) - refreshTags(sourceId) + refreshTags(versionId) } } catch (err) { flash(err.message, 'error')