From 13e49c14b60aa94087574b0e206f5cd70d2d71f2 Mon Sep 17 00:00:00 2001 From: Paul Trowbridge Date: Thu, 17 Sep 2026 02:11:04 -0400 Subject: [PATCH] Offer negative clone offsets, and validate the interval before using it Shifting backwards already worked -- the offset is added as a Postgres interval, and '-90 days' is a perfectly good one -- but the suggestions only listed forward shifts, so nothing said so. Pulling a plan back a quarter is a normal thing to want. A day-level shift is also the case that most needs the dim_period derivation: 15 Mar 2027 less 90 days is 15 Dec 2026, which crosses from 2027/10 - Mar into 2027/07 - Dec. Copying the period columns across would have labelled it March. The offset is interpolated into the statement, so a typo surfaced as a Postgres parse error from the middle of a CTE. It is parsed on its own first, where the failure is cheap and can name the field. Co-Authored-By: Claude Opus 5 (1M context) --- routes/operations.js | 15 +++++++++++++++ ui/src/components/OperationPanel.jsx | 2 ++ 2 files changed, 17 insertions(+) diff --git a/routes/operations.js b/routes/operations.js index 7ff76c3..7566c4a 100644 --- a/routes/operations.js +++ b/routes/operations.js @@ -734,6 +734,21 @@ module.exports = function(pool) { const scaleFactor = (scale != null) ? parseFloat(scale) : 1.0; const dateOffset = (date_offset || '0 days').trim() || '0 days'; + // The offset is interpolated into the SQL as an interval literal, so a + // typo would surface as a Postgres parse error mid-statement. Ask + // Postgres to parse it on its own first, where the failure is cheap and + // can be reported against the field the user typed it into. Negative + // intervals are fine and useful -- '-90 days' pulls a plan back a + // quarter -- so this checks validity, not sign. + try { + await pool.query(`SELECT $1::interval`, [dateOffset]); + } catch { + return res.status(400).json({ + error: `"${dateOffset}" is not a valid interval. Try something like ` + + `"12 months", "-90 days" or "0 days".` + }); + } + // Cloning from a named segment is how a period with no baseline gets a // shape: pick the ledger line -- prior year, plan -- and copy its mix // forward. That means reading rows exclude_iters normally keeps diff --git a/ui/src/components/OperationPanel.jsx b/ui/src/components/OperationPanel.jsx index ba622da..ce17a72 100644 --- a/ui/src/components/OperationPanel.jsx +++ b/ui/src/components/OperationPanel.jsx @@ -817,6 +817,8 @@ export default function OperationPanel({