diff --git a/CLAUDE.md b/CLAUDE.md index 643331c..e13fdea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/routes/versions.js b/routes/versions.js index b7c62b7..5ecbc59 100644 --- a/routes/versions.js +++ b/routes/versions.js @@ -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. diff --git a/ui/src/views/Baseline.jsx b/ui/src/views/Baseline.jsx index 1587161..906a112 100644 --- a/ui/src/views/Baseline.jsx +++ b/ui/src/views/Baseline.jsx @@ -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 )} + {/* 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 && ( +
| Tag | +Adjustments | +Last used | ++ |
|---|---|---|---|
| + {editingTag?.from === t.tag ? ( + 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" /> + ) : ( + + )} + | +{t.uses} | ++ {t.last_used ? new Date(t.last_used).toLocaleDateString() : ''} + | ++ + | +