diff --git a/CLAUDE.md b/CLAUDE.md index 24f77bf..9cc8d22 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/routes/versions.js b/routes/versions.js index e77a01d..aa03e22 100644 --- a/routes/versions.js +++ b/routes/versions.js @@ -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 }); } }); diff --git a/ui/src/views/Baseline.jsx b/ui/src/views/Baseline.jsx index 9fd6b35..bf07df8 100644 --- a/ui/src/views/Baseline.jsx +++ b/ui/src/views/Baseline.jsx @@ -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 )} + {/* 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 && ( +