fix: persist a one-time schedule's run time so edit and disable keep its year (#1499)

PR #1497 pinned an enabled one-shot's next_run_at on creation, but the chosen
instant did not survive two paths: editing reconstructed the date from the
yearless cron (current year), and a disabled one-shot nulled next_run_at with no
other store, so enabling it later recomputed from the cron. Both moved a
future-year one-shot to a different annual occurrence than the date displayed.

Persist the one-shot's absolute fire time in a dedicated run_at column
(additive, nullable; recurring schedules leave it null). Create and update store
run_at independently of the enabled state; next_run_at is derived from it when
enabled and null while disabled, so a disabled one-shot keeps its run_at and the
enable toggle restores the exact instant from the column rather than the cron.
The editor reconstructs a one-shot's date from the persisted run_at, so opening
and re-saving without changes preserves the originally chosen year.

No behavior change for recurring schedules or fresh installs; the column is added
by an additive migration safe for upgrades from v0.92.0.
This commit is contained in:
Anso
2026-06-28 05:23:23 -04:00
committed by GitHub
parent 60536aa614
commit ba57c67048
6 changed files with 139 additions and 31 deletions
@@ -241,7 +241,15 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
setFormTargetId(task.target_id || '');
setFormNodeId(task.node_id != null ? String(task.node_id) : '');
setFormCron(task.cron_expression);
const parsed = parseCron(task.cron_expression, (task.delete_after_run ?? 0) === 1);
let parsed = parseCron(task.cron_expression, (task.delete_after_run ?? 0) === 1);
// The cron has no year field, so parseCron reconstructs a one-shot's date in
// the current year. Rebuild it from the persisted run_at instead, so editing
// (and re-saving) preserves the originally chosen instant rather than moving
// it to this year's occurrence.
if (parsed && parsed.frequency === 'once' && task.run_at != null) {
const pinned = new Date(task.run_at);
parsed = { ...parsed, date: pinned, hour: pinned.getHours(), minute: pinned.getMinutes() };
}
setScheduleMode(parsed ? 'simple' : 'advanced');
setSimpleSchedule(parsed ?? DEFAULT_SIMPLE_SCHEDULE);
setSimpleReplacedCron(false);
@@ -647,6 +647,29 @@ describe('ScheduledOperationsView', () => {
expect(screen.queryByText(/one-time schedule fires on the chosen date/i)).not.toBeInTheDocument();
});
it('preserves the chosen year when editing a future-year one-shot without changing it', async () => {
// The cron (0 23 1 7 *) is yearless; the persisted run_at carries the real
// year. Editing and re-saving must send that year, not this year's occurrence.
const runAt = new Date(new Date().getFullYear() + 1, 6, 1, 23, 0, 0, 0).getTime();
tasksFixture = [makeTask({
id: 7, name: 'once-next-year', cron_expression: '0 23 1 7 *',
delete_after_run: 1, run_at: runAt, next_run_at: runAt,
})];
render(<ScheduledOperationsView />);
await userEvent.click(await screen.findByRole('button', { name: /All tasks/ }));
await userEvent.click(await screen.findByTitle('Edit'));
await userEvent.click(screen.getByRole('button', { name: 'Update' }));
await waitFor(() => {
const putCall = mockedFetch.mock.calls.find(
([url, opts]) => url === '/scheduled-tasks/7' && opts?.method === 'PUT',
);
expect(putCall).toBeTruthy();
expect(JSON.parse(putCall![1].body).run_at).toBe(runAt);
});
});
it('opens a non-simple cron in Advanced mode', async () => {
tasksFixture = [makeTask({ id: 6, name: 'every-15', cron_expression: '*/15 * * * *' })];
render(<ScheduledOperationsView />);