Let a forecast be renamed

PUT /versions/:id has taken name and description since it existed; only the
fallback-name fields ever called it, so the one thing a version's name could
not be was changed. A This forecast panel on the Baseline page now saves
both on blur.

Safe at any time, closed versions included: nothing derives from the name.
The forecast table is keyed on the id, as are pf.log, pf.layout and the
UI's localStorage.

Two edges the route left open. (source_id, name) is unique, so a rename can
collide -- it now answers 409 with the same sentence the POST has always
used, rather than a raw Postgres message that reads as a server fault. And
the UPDATE COALESCEs a falsy name back to the existing one, so a blank box
would have looked saved and changed nothing; that is refused where it is
typed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-21 10:26:37 -04:00
parent b014f7815a
commit 738e177793
3 changed files with 64 additions and 2 deletions

View File

@ -323,6 +323,22 @@ together. `columns` selects which *measures* appear, not individual split
combinations. combinations.
## Renaming a version
Safe at any time, including on a closed one. Nothing derives from the name: the
forecast table is `fc_{tname}_{version_id}`, `pf.log` and `pf.layout` key on the
id, and the UI's `localStorage` keys are id-based. The Baseline page's **This
forecast** panel saves `name` and `description` on blur through
`PUT /api/versions/:id`, which has accepted both all along — it was only ever
called for the fallback names.
Two edges the panel handles: `(source_id, name)` is unique, so the route
answers 409 on a collision rather than a raw Postgres error; and the `UPDATE`
COALESCEs a falsy name back to the existing one, so a blank box is refused
client-side rather than looking saved and changing nothing.
---
## Cloning a version's definition ## Cloning a version's definition
`POST /sources/:id/versions` takes `clone_from`. It copies the template `POST /sources/:id/versions` takes `clone_from`. It copies the template

View File

@ -463,6 +463,13 @@ ${colDefs},
res.json(result.rows[0]); res.json(result.rows[0]);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
// (source_id, name) is unique, so a rename can collide. The POST has
// said so for as long as it has existed; this said it with a raw
// Postgres message, which reads as a server fault rather than a name
// that is already taken.
if (err.code === '23505') {
return res.status(409).json({ error: 'A version with that name already exists for this source' });
}
res.status(500).json({ error: err.message }); res.status(500).json({ error: err.message });
} }
}); });

View File

@ -529,9 +529,15 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
// The names a row falls back to when nobody has named it. Blank means "use the // The names a row falls back to when nobody has named it. Blank means "use the
// built-in", so these save on blur like the segment fields and an empty box is // built-in", so these save on blur like the segment fields and an empty box is
// a meaningful value rather than a missing one. // a meaningful value rather than a missing one.
async function saveVersionName(field, value) { async function saveVersionName(field, value, opts = {}) {
const next = value.trim() const next = value.trim()
if (next === (selectedVersion?.[field] || '')) return if (next === (selectedVersion?.[field] || '')) return
// The route COALESCEs a falsy name back to the existing one, so a blank box
// would look saved and change nothing. Say so instead.
if (field === 'name' && !next) {
flash('A version needs a name', 'error')
return
}
try { try {
const res = await fetch(`/api/versions/${versionId}`, { const res = await fetch(`/api/versions/${versionId}`, {
method: 'PUT', method: 'PUT',
@ -540,7 +546,10 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
}) })
if (!res.ok) { const d = await res.json(); flash(d.error, 'error'); return } if (!res.ok) { const d = await res.json(); flash(d.error, 'error'); return }
await refreshVersions(sourceId) await refreshVersions(sourceId)
flash('Saved — reload the Forecast view to see it') // A rename shows everywhere the version is listed and needs no reload; a
// fallback name is baked into the aggregated row, so the pivot cannot
// re-derive it in place.
flash(opts.message || 'Saved — reload the Forecast view to see it')
} catch (err) { flash(err.message, 'error') } } catch (err) { flash(err.message, 'error') }
} }
@ -576,6 +585,36 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
)} )}
</div> </div>
{/* Name and description. Nothing depends on the name -- the forecast table
is fc_{tname}_{version_id}, pf.log and pf.layout key on the id, and the
UI's localStorage keys are id-based -- so it is safe to change at any
time, including on a closed version, where correcting a name is still
legitimate. Saves on blur, like the fallback names below. */}
{versionId && (
<div className="bg-white border border-gray-200 rounded p-3 flex items-end gap-3 flex-wrap">
<span className="text-xs text-gray-500 uppercase tracking-wide w-full">This forecast</span>
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-500">Name</label>
<input
key={`name-${versionId}-${selectedVersion?.name || ''}`}
defaultValue={selectedVersion?.name || ''}
onBlur={e => saveVersionName('name', e.target.value, { message: 'Renamed' })}
onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur() }}
className="border border-gray-200 rounded px-2 py-1 text-sm w-64" />
</div>
<div className="flex flex-col gap-1 flex-1 min-w-[16rem]">
<label className="text-xs text-gray-500">Description</label>
<input
key={`desc-${versionId}-${selectedVersion?.description || ''}`}
defaultValue={selectedVersion?.description || ''}
onBlur={e => saveVersionName('description', e.target.value, { message: 'Saved' })}
onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur() }}
placeholder="optional"
className="border border-gray-200 rounded px-2 py-1 text-sm w-full" />
</div>
</div>
)}
{/* Fallback names. Not part of any segment -- they are what the pivot shows {/* Fallback names. Not part of any segment -- they are what the pivot shows
for rows nobody has named, so they belong to the version rather than to for rows nobody has named, so they belong to the version rather than to
a log entry. Blank falls back to the built-in in sql_generator's a log entry. Blank falls back to the built-in in sql_generator's