Manage a version's tags as a list, not one entry at a time

A tag was only ever a value typed on a pf.log entry, so the only way to
change one was to find every entry carrying it in the change log and edit
each. Nothing showed what the list was, and the bridge groups on this
column -- so "Pull Forward" and "Pulled Forward" read as two initiatives
with no way to make them one.

PATCH /versions/:id/tags rewrites every entry carrying `from`. A `to` that
already exists merges the two, an empty one clears the tag; the panel
confirms both, since neither is visible as a diff afterwards.

Permission is the pf.log rule applied to every entry the tag is on rather
than most of them: a half-applied rename leaves the initiative split, which
is what the rename was for. GET /versions/:id/tags now returns owners and
can_edit so the panel can grey the row out instead of offering a click that
answers 403.

On the Baseline page, next to the other version-level settings -- the
Forecast page edits one entry's tag, this edits the column.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-22 09:00:27 -04:00
parent 91c53ac6d8
commit 99d7fe72d8
3 changed files with 215 additions and 4 deletions

View File

@ -348,6 +348,25 @@ no adjustments.
The `pf_bucket` datalist on the Baseline page was already per version
(`bucketsInUse`), so the two now agree.
**The list is managed on the Baseline page, not the Forecast one.** A tag is
not a row anywhere — only a value repeated across this version's `pf.log`
entries — so there is no `/tags/:tag` to address and no list to curate ahead of
use: a tag exists because somebody typed it on an adjustment.
`PATCH /versions/:id/tags` takes `{ from, to }` and rewrites every entry
carrying `from`. `to` naming a tag that already exists **merges** the two, which
is usually the reason to be there: the bridge groups on this column, so
`Pull Forward` and `Pulled Forward` are one initiative reported as two. An empty
`to` clears the tag. The Baseline panel confirms both before sending, since
neither is visible as a diff afterwards.
Permission is the `pf.log` rule applied to *every* entry the tag is on, not
most of them — a half-applied rename leaves the initiative split, which is the
state the rename was fixing. `GET /versions/:id/tags` therefore returns `owners`
and `can_edit`, and the panel greys the row out rather than offering a rename
that answers 403. Editing one entry's tag is still the change log on the
Forecast page; this is the whole-version view of the same column.
**Completion matches on words, not substrings.** `TagInput` in
`OperationPanel.jsx` replaces the native datalist on the tag field: both sides
are lowercased, stripped of punctuation, relieved of stopwords and lightly

View File

@ -369,8 +369,9 @@ ${colDefs},
try {
const result = await pool.query(`
SELECT l.tag,
count(*)::int AS uses,
max(l.stamp) AS last_used
count(*)::int AS uses,
max(l.stamp) AS last_used,
array_agg(DISTINCT l.pf_user) AS owners
FROM pf.log l
WHERE TRUE
AND l.version_id = $1
@ -379,13 +380,82 @@ ${colDefs},
GROUP BY l.tag
ORDER BY max(l.stamp) DESC
`, [parseInt(req.params.id)]);
res.json(result.rows);
// Renaming a tag rewrites every entry that carries it, so the right
// to do it is the right to edit all of them -- not most of them.
// The tag manager greys the row out rather than offering a rename
// that would half-apply.
const me = sessionUser(req);
const admin = !!req.session?.user?.is_admin;
res.json(result.rows.map(r => ({
...r,
can_edit: admin || (r.owners || []).every(o => o === me)
})));
} catch (err) {
console.error(err);
res.status(500).json({ error: err.message });
}
});
// Rename a tag across the whole version, or clear it. `to` empty means
// clear; `to` naming a tag that already exists merges the two, which is the
// point -- "Pull Forward" and "Pulled Forward" are one initiative reported
// as two, and the bridge groups on this column.
//
// A PATCH on the version rather than on a tag: a tag is not a row anywhere,
// only a value repeated across pf.log, so there is no /tags/:tag to address.
router.patch('/versions/:id/tags', async (req, res) => {
const versionId = parseInt(req.params.id);
const from = req.body.from === undefined ? null : String(req.body.from).trim();
const to = req.body.to === undefined ? null : String(req.body.to).trim();
if (!from) return res.status(400).json({ error: 'Which tag? Send `from`' });
if (to === from) return res.status(400).json({ error: 'That is the same tag' });
const client = await pool.connect();
try {
await client.query('BEGIN');
// Lock the rows before counting them, so the owner check and the
// rewrite see the same set.
const rows = await client.query(
`SELECT id, pf_user FROM pf.log
WHERE version_id = $1 AND tag = $2
FOR UPDATE`,
[versionId, from]
);
if (!rows.rows.length) {
await client.query('ROLLBACK');
return res.status(404).json({ error: `No entry on this version is tagged "${from}"` });
}
// Same rule as undo and PATCH /log: your own entries, or an admin's
// override. Applied to every entry carrying the tag, because a
// partial rename leaves the initiative split in two.
const me = sessionUser(req);
if (!req.session?.user?.is_admin) {
const others = [...new Set(rows.rows.map(r => r.pf_user).filter(u => u !== me))];
if (others.length) {
await client.query('ROLLBACK');
return res.status(403).json({
error: `"${from}" is also on entries made by ${others.join(', ')} — only they or an administrator can change those`
});
}
}
const result = await client.query(
`UPDATE pf.log SET tag = $3 WHERE version_id = $1 AND tag = $2`,
[versionId, from, to || null]
);
await client.query('COMMIT');
res.json({ from, to: to || null, rows_affected: result.rowCount });
} catch (err) {
await client.query('ROLLBACK').catch(() => {});
console.error(err);
res.status(500).json({ error: err.message });
} finally {
client.release();
}
});
// Bridge: how this version got from its baseline to where it stands, grouped by
// initiative. Amounts come from the version's own forecast table, so the figures
// reconcile with the pivot rather than being recomputed from the log's params.

View File

@ -103,6 +103,13 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
const [editingPlanId, setEditingPlanId] = useState(null)
const [runningAll, setRunningAll] = useState(false)
// Tags in use on this version, and the rename in progress. A tag is not a row
// anywhere -- only a value repeated across pf.log -- so the list is derived
// and editing one rewrites every entry that carries it.
const [tags, setTags] = useState([])
const [editingTag, setEditingTag] = useState(null) // { from, text }
const [savingTag, setSavingTag] = useState(false)
// segment form
const [segType, setSegType] = useState('baseline')
const [filters, setFilters] = useState([]) // [[cond,...], [cond,...]]
@ -136,9 +143,10 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
}, [sourceId])
useEffect(() => {
if (!versionId) { setLog([]); setPlans([]); return }
if (!versionId) { setLog([]); setPlans([]); setTags([]); return }
loadLog()
loadPlans()
loadTags()
}, [versionId])
// A segment's banner: what it counts toward, independent of pf_iter. Held
@ -203,6 +211,40 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
})
}
function loadTags() {
fetch(`/api/versions/${versionId}/tags`)
.then(r => r.ok ? r.json() : [])
.then(d => setTags(Array.isArray(d) ? d : []))
.catch(() => setTags([]))
}
// Renaming onto a tag that already exists merges the two, which is usually
// the reason to be here at all -- so say so before it happens rather than
// reporting it afterwards.
async function saveTag(from, raw) {
const to = (raw || '').trim()
if (to === from) { setEditingTag(null); return }
const merges = to && tags.some(t => t.tag === to)
if (merges && !confirm(`Merge "${from}" into "${to}"? Both become one initiative in the bridge.`)) return
if (!to && !confirm(`Clear "${from}" from every entry that carries it?`)) return
setSavingTag(true)
try {
const res = await fetch(`/api/versions/${versionId}/tags`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ from, to }),
})
const d = await res.json()
if (!res.ok) { flash(d.error, 'error'); return }
setEditingTag(null)
loadTags(); loadLog()
flash(to
? `${d.rows_affected} ${d.rows_affected === 1 ? 'entry' : 'entries'} now tagged "${to}"`
: `Cleared from ${d.rows_affected} ${d.rows_affected === 1 ? 'entry' : 'entries'}`)
} catch (err) { flash(err.message, 'error') }
finally { setSavingTag(false) }
}
function loadPlans() {
fetch(`/api/versions/${versionId}/plans`)
.then(r => r.ok ? r.json() : [])
@ -653,6 +695,86 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
</div>
)}
{/* Tags. Not a table anywhere -- a tag is a value repeated across this
version's pf.log entries, and the bridge groups on it. So renaming
one here rewrites every entry that carries it, and renaming onto an
existing tag merges two initiatives that were only ever one. Editing
a single entry's tag is still the Change log, on the Forecast page;
this is the whole-version view of the same column. */}
{versionId && (
<div className="bg-white border border-gray-200 rounded p-3 flex flex-col gap-2">
<span className="text-xs text-gray-500 uppercase tracking-wide">
Tags <span className="normal-case tracking-normal text-gray-400">— the initiatives named on this forecast</span>
</span>
{tags.length === 0 ? (
<span className="text-sm text-gray-500">
No tags yet. One is created by typing it on a scale, recode or clone in the Operations panel.
</span>
) : (
<table className="text-sm">
<thead>
<tr className="text-xs text-gray-500 text-left">
<th className="font-medium pb-1 pr-4">Tag</th>
<th className="font-medium pb-1 pr-4 text-right">Adjustments</th>
<th className="font-medium pb-1 pr-4">Last used</th>
<th className="font-medium pb-1"></th>
</tr>
</thead>
<tbody>
{tags.map(t => (
<tr key={t.tag} className="border-t border-gray-100">
<td className="py-1 pr-4">
{editingTag?.from === t.tag ? (
<input
autoFocus
value={editingTag.text}
disabled={savingTag}
list="pf-version-tags"
onChange={e => setEditingTag({ from: t.tag, text: e.target.value })}
onBlur={e => saveTag(t.tag, e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') e.currentTarget.blur()
if (e.key === 'Escape') setEditingTag(null)
}}
className="border border-gray-200 rounded px-2 py-0.5 text-sm w-64" />
) : (
<button
disabled={!t.can_edit}
onClick={() => setEditingTag({ from: t.tag, text: t.tag })}
title={t.can_edit
? 'Rename across every entry — type an existing tag to merge'
: `Also on entries made by ${(t.owners || []).join(', ')} — only they or an administrator can change it`}
className={`text-left ${t.can_edit ? 'hover:underline' : 'opacity-50 cursor-default'}`}>
{t.tag}
</button>
)}
</td>
<td className="py-1 pr-4 text-right tabular-nums text-gray-600">{t.uses}</td>
<td className="py-1 pr-4 text-gray-500">
{t.last_used ? new Date(t.last_used).toLocaleDateString() : ''}
</td>
<td className="py-1">
<button
disabled={!t.can_edit || savingTag}
onClick={() => saveTag(t.tag, '')}
title="Clear this tag from every entry that carries it"
className={`text-xs ${t.can_edit ? 'text-gray-400 hover:text-red-600' : 'text-gray-300 cursor-default'}`}>
clear
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
{/* Completing a rename against the list is what makes a merge
deliberate rather than a near-miss spelling. */}
<datalist id="pf-version-tags">
{tags.map(t => <option key={t.tag} value={t.tag} />)}
</datalist>
</div>
)}
{showNewVersion && (
<div className="bg-white border border-gray-200 rounded p-3 flex flex-col gap-3">
<div className="flex items-end gap-3">