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) <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-17 02:11:04 -04:00
parent 0a50122add
commit 13e49c14b6
2 changed files with 17 additions and 0 deletions

View File

@ -734,6 +734,21 @@ module.exports = function(pool) {
const scaleFactor = (scale != null) ? parseFloat(scale) : 1.0; const scaleFactor = (scale != null) ? parseFloat(scale) : 1.0;
const dateOffset = (date_offset || '0 days').trim() || '0 days'; 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 // 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 // shape: pick the ledger line -- prior year, plan -- and copy its mix
// forward. That means reading rows exclude_iters normally keeps // forward. That means reading rows exclude_iters normally keeps

View File

@ -817,6 +817,8 @@ export default function OperationPanel({
<datalist id="pf-clone-offsets"> <datalist id="pf-clone-offsets">
<option value="12 months" /> <option value="12 months" />
<option value="24 months" /> <option value="24 months" />
<option value="-90 days" />
<option value="-12 months" />
<option value="0 days" /> <option value="0 days" />
</datalist> </datalist>
</div> </div>