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.
## 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
`POST /sources/:id/versions` takes `clone_from`. It copies the template

View File

@ -463,6 +463,13 @@ ${colDefs},
res.json(result.rows[0]);
} catch (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 });
}
});

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
// built-in", so these save on blur like the segment fields and an empty box is
// a meaningful value rather than a missing one.
async function saveVersionName(field, value) {
async function saveVersionName(field, value, opts = {}) {
const next = value.trim()
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 {
const res = await fetch(`/api/versions/${versionId}`, {
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 }
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') }
}
@ -576,6 +585,36 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
)}
</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
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