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) <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-21 15:03:41 -04:00
parent fe456d4e90
commit 05631817ae
3 changed files with 41 additions and 11 deletions

View File

@ -27,7 +27,7 @@ routes/
auth.js POST /api/login, /api/logout, GET /api/me; login throttle auth.js POST /api/login, /api/logout, GET /api/me; login throttle
tables.js GET /api/tables, /api/tables/:schema/:tname/preview tables.js GET /api/tables, /api/tables/:schema/:tname/preview
sources.js Source registration, col_meta, SQL generation 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 operations.js scale, recode, clone, undo — the core forecast ops
log.js GET /api/versions/:id/log, DELETE /api/log/:logid log.js GET /api/versions/:id/log, DELETE /api/log/:logid
layouts.js Named pivot layouts — list per version, create, patch, delete 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. 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 ## Renaming a version
Safe at any time, including on a closed one. Nothing derives from the name: the Safe at any time, including on a closed one. Nothing derives from the name: the

View File

@ -356,18 +356,29 @@ ${colDefs},
// Tags already used on this source, newest first — feeds the tag autocomplete. // 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 // Scoped to the source rather than the version so an initiative name carries
// across versions, which is the point of naming it. // 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 { try {
const result = await pool.query(` const result = await pool.query(`
SELECT l.tag, SELECT l.tag,
count(*)::int AS uses, count(*)::int AS uses,
max(l.stamp) AS last_used max(l.stamp) AS last_used
FROM pf.log l FROM pf.log l
JOIN pf.version v ON v.id = l.version_id WHERE TRUE
WHERE v.source_id = $1 AND l.tag IS NOT NULL AND l.tag <> '' AND l.version_id = $1
AND l.tag IS NOT NULL
AND l.tag <> ''
GROUP BY l.tag GROUP BY l.tag
ORDER BY max(l.stamp) DESC ORDER BY max(l.stamp) DESC
`, [req.params.id]); `, [parseInt(req.params.id)]);
res.json(result.rows); res.json(result.rows);
} catch (err) { } catch (err) {
console.error(err); console.error(err);

View File

@ -664,10 +664,10 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
} catch { setLogMeta({}) } } catch { setLogMeta({}) }
} }
async function refreshTags(sid) { async function refreshTags(vid) {
if (!sid) { setKnownTags([]); return } if (!vid) { setKnownTags([]); return }
try { 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 : []) setKnownTags(Array.isArray(rows) ? rows : [])
} catch { setKnownTags([]) } } catch { setKnownTags([]) }
} }
@ -705,7 +705,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
useEffect(() => { refreshLogMeta(versionId) }, [versionId]) useEffect(() => { refreshLogMeta(versionId) }, [versionId])
// Relabelling on the Baseline page needs a reload to show here: the label is // 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. // 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. // 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 // 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 // let the status bar re-read the forecast table's row count
window.dispatchEvent(new CustomEvent('pf-data-changed')) window.dispatchEvent(new CustomEvent('pf-data-changed'))
refreshLogMeta(versionId) refreshLogMeta(versionId)
if (opTag.trim()) refreshTags(sourceId) if (opTag.trim()) refreshTags(versionId)
if (op === 'scale') { setScaleInputs({}); setScaleNote(''); fetchCurrentTotals(slices) } if (op === 'scale') { setScaleInputs({}); setScaleNote(''); fetchCurrentTotals(slices) }
if (op === 'recode') { setRecodeNote('') } if (op === 'recode') { setRecodeNote('') }
if (op === 'clone') { setCloneNote(''); setCloneScale('1') } if (op === 'clone') { setCloneNote(''); setCloneScale('1') }
@ -1849,7 +1849,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
if (field === 'tag') { if (field === 'tag') {
// the ledger names its lines from this, and the tag list gains a new entry // the ledger names its lines from this, and the tag list gains a new entry
refreshLogMeta(versionId) refreshLogMeta(versionId)
refreshTags(sourceId) refreshTags(versionId)
} }
} catch (err) { } catch (err) {
flash(err.message, 'error') flash(err.message, 'error')