Drop Shift years

Nobody asked for it. It was my own addition to the plans panel, guessing
that cloning a definition forward a year would be the common case; the
actual edit wanted was two dates moved by five days, typed by hand. A bulk
rewrite of filter values is a lot of machinery to leave sitting next to Run
all on the chance it is ever right.

Never used, so nothing to unwind: every plan on the cloned version still
carries the template's filters verbatim apart from the two dates edited in
the form.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-21 12:39:07 -04:00
parent 738e177793
commit fe456d4e90
3 changed files with 1 additions and 93 deletions

View File

@ -31,7 +31,7 @@ routes/
operations.js scale, recode, clone, undo — the core forecast ops
log.js GET /api/versions/:id/log, DELETE /api/log/:logid
layouts.js Named pivot layouts — list per version, create, patch, delete
plans.js Planned load segments — list, create, patch, delete, shift-years
plans.js Planned load segments — list, create, patch, delete
lib/
sql_generator.js buildFilterClause, token substitution helpers
auth.js scrypt hash/verify, requireAuth, sessionUser; `node lib/auth.js hash` CLI
@ -376,14 +376,6 @@ from its `baseline`/`reference` log entries when it does not — which is every
version built before plans existed. `pf.log.params` already stores `filters`,
`raw_where` and `date_offset`, so this is a read, not a reconstruction.
**`POST /versions/:id/plans/shift-years`** adds a whole number of years to every
*unrun* plan's `date_offset` and to any bare four-digit `20xx` in its filter
**values**. Values only, and only a bare year: a season reads `2027`, a month
reads `04 - Sep`, and a part number that happens to look like a year would be
corrupted by anything looser. Cloning a definition forward a year is the reason
plans exist, and doing it by hand across eight segments is where the mistakes
come from.
**Run all stops at the first failure.** The segments of a definition are not
independent — a baseline followed by references that date-shift off it — so
carrying on past a broken one builds something nobody described.

View File

@ -121,64 +121,5 @@ module.exports = function(pool) {
}
});
// Shift every unrun plan's date_offset and any four-digit year in its filter
// values by a whole number of years. Cloning a definition forward is the
// reason plans exist, and doing it by hand across eight segments is where
// the mistakes come from.
//
// Years in filter *values* only, and only a bare four-digit 20xx: a season
// reads "2027", a month reads "04 - Sep", and a part number that happens to
// look like a year would be corrupted by anything looser.
router.post('/versions/:id/plans/shift-years', async (req, res) => {
const versionId = parseInt(req.params.id);
const years = parseInt(req.body?.years);
if (!Number.isInteger(years) || years === 0) {
return res.status(400).json({ error: 'years must be a non-zero whole number' });
}
const bumpYear = (v) => (typeof v === 'string' && /^20\d{2}$/.test(v.trim()))
? String(parseInt(v, 10) + years)
: v;
const bumpOffset = (off) => {
// '0 days' stays put: an offset of nothing is not a date to shift.
const m = /^\s*(-?\d+)\s+year/.exec(off || '');
if (!m) return off;
return `${parseInt(m[1], 10) + years} years`;
};
const client = await pool.connect();
try {
await client.query('BEGIN');
const { rows } = await client.query(
`SELECT * FROM pf.load_plan
WHERE version_id = $1 AND run_logid IS NULL
ORDER BY seq, id`,
[versionId]
);
const out = [];
for (const p of rows) {
const filters = Array.isArray(p.filters)
? p.filters.map(group => group.map(f => ({
...f,
values: Array.isArray(f.values) ? f.values.map(bumpYear) : f.values
})))
: p.filters;
const upd = await client.query(
`UPDATE pf.load_plan
SET filters = $2::jsonb, date_offset = $3, updated_at = now()
WHERE id = $1
RETURNING *`,
[p.id, filters ? JSON.stringify(filters) : null, bumpOffset(p.date_offset)]
);
out.push(upd.rows[0]);
}
await client.query('COMMIT');
res.json({ shifted: out.length, plans: out });
} catch (err) {
await client.query('ROLLBACK').catch(() => {});
res.status(500).json({ error: err.message });
} finally {
client.release();
}
});
return router;
};

View File

@ -102,7 +102,6 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
const [plans, setPlans] = useState([])
const [editingPlanId, setEditingPlanId] = useState(null)
const [runningAll, setRunningAll] = useState(false)
const [shiftYears, setShiftYears] = useState('1')
// segment form
const [segType, setSegType] = useState('baseline')
@ -277,22 +276,6 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
flash(`Loaded ${done} segment${done === 1 ? '' : 's'}`)
}
async function shiftPlanYears() {
const years = parseInt(shiftYears)
if (!Number.isInteger(years) || years === 0) { flash('Enter a whole number of years', 'error'); return }
try {
const res = await fetch(`/api/versions/${versionId}/plans/shift-years`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ years }),
})
const data = await res.json()
if (!res.ok) { flash(data.error, 'error'); return }
loadPlans()
flash(`Shifted ${data.shifted} plan${data.shifted === 1 ? '' : 's'} by ${years > 0 ? '+' : ''}${years} year${Math.abs(years) === 1 ? '' : 's'}`)
} catch (err) { flash(err.message, 'error') }
}
async function deletePlan(plan) {
await fetch(`/api/plans/${plan.id}`, { method: 'DELETE' })
if (editingPlanId === plan.id) cancelEdit()
@ -687,14 +670,6 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
{plans.filter(p => !p.run_logid).length} of {plans.length} not yet loaded
</span>
<div className="ml-auto flex items-center gap-3 normal-case font-normal">
<div className="flex items-center gap-1">
<span className="text-gray-400">Shift years</span>
<input value={shiftYears} onChange={e => setShiftYears(e.target.value)}
className="border border-gray-200 rounded px-1 py-0.5 w-12 text-center" />
<button onClick={shiftPlanYears}
title="Add this many years to every unrun plan's date offset, and to any bare four-digit year in its filter values"
className="text-blue-600 hover:text-blue-700">Apply</button>
</div>
<button onClick={runAllPlans}
disabled={runningAll || submitting || !plans.some(p => !p.run_logid)}
className="border border-blue-200 text-blue-600 hover:text-blue-700 rounded