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')