Give a segment an editable label and bucket, at creation and after

Two gaps you hit. There was nowhere to set "counts toward" while defining a
segment -- only in the list afterwards -- and the Edit buttons disappear
entirely once any adjustment exists.

That guard is right in principle and too broad in practice. Editing a
segment's filters or date offset after a scale would silently recalibrate a
distribution that was sized against the old rows, so it stays gated. But the
label and the bucket are presentation: they change what the pivot shows and
what the segment counts toward, never which rows were loaded. Those are now
editable in the list at any time, and settable on the create form.

pf.log.label is new: the segment's display name, falling back to tag then
note. Separate from both because those have jobs already -- tag groups
adjustments into initiatives for the bridge, note is commentary -- and
because the label is where sort order lives. Perspective orders column
groups by the value string, so a leading "01 - " is how ordering gets
expressed, and putting that in the note would put it in every note.

The load routes do not yet carry bucket and label through: they return only
rows_affected, with no log id to attach them to. That comes with the switch
away from computed prefixes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-17 22:15:25 -04:00
parent 0fdb08291d
commit af9e6de88e
3 changed files with 81 additions and 9 deletions

View File

@ -131,9 +131,12 @@ module.exports = function(pool) {
// a closed version, where relabelling history is still legitimate. // a closed version, where relabelling history is still legitimate.
router.patch('/log/:logid', async (req, res) => { router.patch('/log/:logid', async (req, res) => {
const logId = parseInt(req.params.logid); const logId = parseInt(req.params.logid);
const { note, tag, bucket, seq } = req.body; const { note, tag, bucket, seq, label } = req.body;
if (note === undefined && tag === undefined && bucket === undefined && seq === undefined) { if (note === undefined && tag === undefined && bucket === undefined
return res.status(400).json({ error: 'Nothing to update — send note, tag, bucket and/or seq' }); && seq === undefined && label === undefined) {
return res.status(400).json({
error: 'Nothing to update — send note, tag, bucket, label and/or seq'
});
} }
try { try {
// COALESCE on the flag, not the value: an explicit null or '' must be // COALESCE on the flag, not the value: an explicit null or '' must be
@ -143,7 +146,8 @@ module.exports = function(pool) {
note = CASE WHEN $2::bool THEN $3::text ELSE note END, note = CASE WHEN $2::bool THEN $3::text ELSE note END,
tag = CASE WHEN $4::bool THEN $5::text ELSE tag END, tag = CASE WHEN $4::bool THEN $5::text ELSE tag END,
bucket = CASE WHEN $6::bool THEN $7::text ELSE bucket END, bucket = CASE WHEN $6::bool THEN $7::text ELSE bucket END,
seq = CASE WHEN $8::bool THEN $9::int ELSE seq END seq = CASE WHEN $8::bool THEN $9::int ELSE seq END,
label = CASE WHEN $10::bool THEN $11::text ELSE label END
WHERE id = $1 RETURNING *`, WHERE id = $1 RETURNING *`,
[ [
logId, logId,
@ -152,6 +156,7 @@ module.exports = function(pool) {
bucket !== undefined, bucket === undefined ? null : (String(bucket).trim() || null), bucket !== undefined, bucket === undefined ? null : (String(bucket).trim() || null),
seq !== undefined, (seq === undefined || seq === null || seq === '') seq !== undefined, (seq === undefined || seq === null || seq === '')
? null : parseInt(seq), ? null : parseInt(seq),
label !== undefined, label === undefined ? null : (String(label).trim() || null),
] ]
); );
if (!result.rows.length) return res.status(404).json({ error: 'Log entry not found' }); if (!result.rows.length) return res.status(404).json({ error: 'Log entry not found' });

View File

@ -92,6 +92,15 @@ WHERE TRUE
-- bucket_order lives on the version rather than the source because the Baseline -- bucket_order lives on the version rather than the source because the Baseline
-- page, where it is maintained, is version-scoped. log.seq orders the segments -- page, where it is maintained, is version-scoped. log.seq orders the segments
-- within that. -- within that.
-- The segment's display name in the pivot, falling back to tag then note.
--
-- Separate from both because those have jobs already -- tag groups adjustments
-- into initiatives for the bridge, note is free commentary -- and because the
-- label carries the sort order. Perspective orders column groups by the value
-- string, so a leading "01 - " is how ordering is expressed; putting that in the
-- note would put it in every note.
ALTER TABLE pf.log ADD COLUMN IF NOT EXISTS label text;
ALTER TABLE pf.version ADD COLUMN IF NOT EXISTS bucket_order jsonb; ALTER TABLE pf.version ADD COLUMN IF NOT EXISTS bucket_order jsonb;
ALTER TABLE pf.log ADD COLUMN IF NOT EXISTS seq integer; ALTER TABLE pf.log ADD COLUMN IF NOT EXISTS seq integer;

View File

@ -103,6 +103,10 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
const [rawSql, setRawSql] = useState('') const [rawSql, setRawSql] = useState('')
const [offset, setOffset] = useState('0 days') const [offset, setOffset] = useState('0 days')
const [segNote, setSegNote] = useState('') const [segNote, setSegNote] = useState('')
// Presentation, not definition: what the segment counts toward and how it is
// labelled in the pivot. Safe to set at any time, unlike its filters.
const [segBucket, setSegBucket] = useState('')
const [segLabel, setSegLabel] = useState('')
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [editingLogId, setEditingLogId] = useState(null) const [editingLogId, setEditingLogId] = useState(null)
const [showAddForm, setShowAddForm] = useState(false) const [showAddForm, setShowAddForm] = useState(false)
@ -140,6 +144,25 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
const [seqs, setSeqs] = useState({}) const [seqs, setSeqs] = useState({})
const [bucketOrder, setBucketOrder] = useState([]) const [bucketOrder, setBucketOrder] = useState([])
// Label and bucket are presentation, not definition: they change what the pivot
// shows and what the segment counts toward, never which rows were loaded. So
// they stay editable after adjustments exist, unlike the filters and offset,
// where an edit would silently recalibrate scales sized against the old rows.
async function saveLogField(entry, field, value) {
const next = value.trim()
if (next === (entry[field] || '')) return
try {
const res = await fetch(`/api/log/${entry.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [field]: next }),
})
if (!res.ok) { const d = await res.json(); flash(d.error, 'error'); return }
loadLog()
flash('Saved')
} catch (err) { flash(err.message, 'error') }
}
async function saveBucket(entry, value) { async function saveBucket(entry, value) {
const next = value.trim() const next = value.trim()
if (next === (entry.bucket || '')) return if (next === (entry.bucket || '')) return
@ -236,6 +259,8 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
where_clause: clause, where_clause: clause,
note: description || segNote, note: description || segNote,
date_offset: offsetStr, date_offset: offsetStr,
...(segBucket.trim() ? { bucket: segBucket.trim() } : {}),
...(segLabel.trim() ? { label: segLabel.trim() } : {}),
...(useRaw ? { raw_where: clause } : { filters }), ...(useRaw ? { raw_where: clause } : { filters }),
} }
setSubmitting(true) setSubmitting(true)
@ -462,6 +487,7 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
<th className="px-3 py-1.5 font-medium w-6"></th> <th className="px-3 py-1.5 font-medium w-6"></th>
<th className="px-3 py-1.5 font-medium">#</th> <th className="px-3 py-1.5 font-medium">#</th>
<th className="px-3 py-1.5 font-medium w-14 text-right">seq</th> <th className="px-3 py-1.5 font-medium w-14 text-right">seq</th>
<th className="px-3 py-1.5 font-medium w-40">label</th>
<th className="px-3 py-1.5 font-medium">note</th> <th className="px-3 py-1.5 font-medium">note</th>
<th className="px-3 py-1.5 font-medium w-36">counts toward</th> <th className="px-3 py-1.5 font-medium w-36">counts toward</th>
<th className="px-3 py-1.5 font-medium text-right">rows</th> <th className="px-3 py-1.5 font-medium text-right">rows</th>
@ -473,11 +499,11 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
</thead> </thead>
<tbody> <tbody>
{log.length === 0 && ( {log.length === 0 && (
<tr><td colSpan={10} className="px-3 py-3 text-gray-300 italic">No segments loaded yet</td></tr> <tr><td colSpan={11} className="px-3 py-3 text-gray-300 italic">No segments loaded yet</td></tr>
)} )}
{!showAddForm && !editingLogId && ( {!showAddForm && !editingLogId && (
<tr className="border-t border-gray-100"> <tr className="border-t border-gray-100">
<td colSpan={10} className="p-0"> <td colSpan={11} className="p-0">
<button <button
onClick={() => setShowAddForm(true)} onClick={() => setShowAddForm(true)}
className="w-full px-3 py-2 text-xs text-blue-600 hover:bg-blue-50 text-left font-medium" className="w-full px-3 py-2 text-xs text-blue-600 hover:bg-blue-50 text-left font-medium"
@ -509,6 +535,16 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
focus:border-blue-400 rounded px-1 py-0.5 text-xs focus:border-blue-400 rounded px-1 py-0.5 text-xs
focus:outline-none bg-transparent tabular-nums" /> focus:outline-none bg-transparent tabular-nums" />
</td> </td>
<td className="px-3 py-2" onClick={e => e.stopPropagation()}>
<input
defaultValue={entry.label || ''}
key={`label-${entry.id}-${entry.label || ''}`}
onBlur={e => saveLogField(entry, 'label', e.target.value)}
placeholder={entry.tag || entry.note || '—'}
className="w-full border border-transparent hover:border-gray-200
focus:border-blue-400 rounded px-1 py-0.5 text-xs
focus:outline-none bg-transparent" />
</td>
<td className="px-3 py-2"> <td className="px-3 py-2">
<span className={`inline-block mr-2 px-1.5 py-0.5 rounded text-xs font-medium ${entry.operation === 'reference' ? 'bg-purple-50 text-purple-600' : 'bg-blue-50 text-blue-600'}`}> <span className={`inline-block mr-2 px-1.5 py-0.5 rounded text-xs font-medium ${entry.operation === 'reference' ? 'bg-purple-50 text-purple-600' : 'bg-blue-50 text-blue-600'}`}>
{entry.operation} {entry.operation}
@ -542,7 +578,7 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
</tr> </tr>
{isOpen && ( {isOpen && (
<tr key={`${entry.id}-detail`} className="bg-blue-50 border-t border-blue-100"> <tr key={`${entry.id}-detail`} className="bg-blue-50 border-t border-blue-100">
<td colSpan={8} className="px-2 py-2"> <td colSpan={9} className="px-2 py-2">
<div className="bg-white border border-gray-200 rounded"> <div className="bg-white border border-gray-200 rounded">
<SegmentForm mode="view" {...view} filterCols={filterCols} /> <SegmentForm mode="view" {...view} filterCols={filterCols} />
</div> </div>
@ -583,6 +619,8 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
rawSql={rawSql} setRawSql={setRawSql} rawSql={rawSql} setRawSql={setRawSql}
description={description} setDescription={setDescription} description={description} setDescription={setDescription}
segNote={segNote} setSegNote={setSegNote} segNote={segNote} setSegNote={setSegNote}
segBucket={segBucket} setSegBucket={setSegBucket}
segLabel={segLabel} setSegLabel={setSegLabel}
offset={offset} setOffset={setOffset} offset={offset} setOffset={setOffset}
filterCols={filterCols} filterCols={filterCols}
onSubmit={loadSegment} onSubmit={loadSegment}
@ -610,6 +648,8 @@ function segmentValuesFor(entry, filterCols) {
rawSql: params.where_clause || '', rawSql: params.where_clause || '',
description: '', description: '',
segNote: entry.note || '', segNote: entry.note || '',
segBucket: entry.bucket || '',
segLabel: entry.label || '',
offset: params.date_offset || '0 days', offset: params.date_offset || '0 days',
} }
} }
@ -622,6 +662,8 @@ function SegmentForm({
rawSql, setRawSql, rawSql, setRawSql,
description, setDescription, description, setDescription,
segNote, setSegNote, segNote, setSegNote,
segBucket, setSegBucket,
segLabel, setSegLabel,
offset, setOffset, offset, setOffset,
filterCols, filterCols,
onSubmit, onSubmit,
@ -830,8 +872,24 @@ function SegmentForm({
</div> </div>
)} )}
{/* Note + submit */} {/* Label, bucket, note + submit.
<div className="flex items-end gap-3"> Label and bucket are presentation: the label is what the pivot shows for
this segment, the bucket is what it counts toward. Both are free text and
both sort by what is typed, so a leading "01 - " is how ordering is set
which is why they belong here, at the point the segment is defined, as
well as being editable in the list afterwards. */}
<div className="flex items-end gap-3 flex-wrap">
<div className="flex flex-col gap-1 max-w-xs">
<label className="text-xs text-gray-500">Label</label>
<input disabled={disabled} value={segLabel} onChange={e => setSegLabel(e.target.value)}
placeholder="defaults to the note" className={`${baseInp} text-sm py-1.5`} />
</div>
<div className="flex flex-col gap-1 max-w-xs">
<label className="text-xs text-gray-500">Counts toward</label>
<input disabled={disabled} value={segBucket} onChange={e => setSegBucket(e.target.value)}
list="pf-bucket-options" placeholder="e.g. 02 - Forecast"
className={`${baseInp} text-sm py-1.5`} />
</div>
<div className="flex flex-col gap-1 flex-1 max-w-xs"> <div className="flex flex-col gap-1 flex-1 max-w-xs">
<label className="text-xs text-gray-500">Note</label> <label className="text-xs text-gray-500">Note</label>
<input disabled={disabled} value={segNote} onChange={e => setSegNote(e.target.value)} placeholder="optional" className={`${baseInp} text-sm py-1.5`} /> <input disabled={disabled} value={segNote} onChange={e => setSegNote(e.target.value)} placeholder="optional" className={`${baseInp} text-sm py-1.5`} />