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:
parent
91c53ac6d8
commit
99d7fe72d8
19
CLAUDE.md
19
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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user