mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 10:21:03 +00:00
fix: fire a one-time schedule on the exact chosen date and time (#1497)
Simple "Once" schedules compiled to a 5-field cron, which has no year field, so the scheduler computed the next run as the next annual occurrence. A date chosen for a later year ran a year early, and a time already elapsed today ran a year late, contradicting the UI promise that the task fires on the chosen date. One-time schedules now send the chosen absolute timestamp (run_at) and the backend pins next_run_at to it instead of the cron-derived next run, so the run fires on the exact selected instant including the year. The Simple-mode validation now compares the full chosen instant against the current time, so a time earlier today is rejected as past rather than silently deferred a year. run_at is validated as a finite, future epoch-millisecond timestamp on create and update. The enable/disable toggle preserves a one-shot's pinned next_run_at (its yearless cron cannot reconstruct the chosen year), so re-enabling restores the exact instant. Recurring shapes and Advanced mode are unchanged (cron stays authoritative).
This commit is contained in:
@@ -22,6 +22,7 @@ import {
|
||||
buildCron,
|
||||
parseCron,
|
||||
getSimpleScheduleError,
|
||||
getOnceRunAt,
|
||||
type SimpleSchedule,
|
||||
} from '@/lib/scheduling';
|
||||
import { ScheduleSimplePanel } from './ScheduleSimplePanel';
|
||||
@@ -277,6 +278,11 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
// backend stores; Advanced mode sends the raw expression as-is.
|
||||
const cronExpression = scheduleMode === 'simple' ? buildCron(simpleSchedule) : formCron;
|
||||
|
||||
// A one-time ('once') Simple schedule pins its exact run instant (including
|
||||
// year) via run_at, because the 5-field cron cannot encode a year. null for
|
||||
// every recurring shape and for Advanced mode, where the cron is authoritative.
|
||||
const runAt = scheduleMode === 'simple' ? getOnceRunAt(simpleSchedule) : null;
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
name: formName,
|
||||
target_type: actionDef.targetType,
|
||||
@@ -284,6 +290,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
cron_expression: cronExpression,
|
||||
enabled: formEnabled,
|
||||
delete_after_run: formDeleteAfterRun,
|
||||
run_at: runAt,
|
||||
target_id: actionDef.requiresStack ? formTargetId : null,
|
||||
node_id: actionDef.requiresNode && formNodeId ? parseInt(formNodeId, 10) : null,
|
||||
prune_targets: formAction === 'prune' && formPruneTargets.length > 0 ? formPruneTargets : null,
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
buildCron,
|
||||
parseCron,
|
||||
getSimpleScheduleError,
|
||||
getOnceRunAt,
|
||||
type SimpleSchedule,
|
||||
} from './scheduling';
|
||||
|
||||
@@ -140,6 +141,24 @@ describe('parseCron', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOnceRunAt', () => {
|
||||
it('returns the exact picked instant (date + chosen time) for a once schedule', () => {
|
||||
const runAt = getOnceRunAt(schedule({ frequency: 'once', minute: 30, hour: 23, date: new Date(2027, 6, 1) }));
|
||||
expect(runAt).toBe(new Date(2027, 6, 1, 23, 30, 0, 0).getTime());
|
||||
});
|
||||
|
||||
it('preserves the selected year so a future-year once does not collapse to this year', () => {
|
||||
const runAt = getOnceRunAt(schedule({ frequency: 'once', minute: 0, hour: 9, date: new Date(2030, 0, 15) }));
|
||||
expect(new Date(runAt as number).getFullYear()).toBe(2030);
|
||||
});
|
||||
|
||||
it('returns null for a once schedule with no date and for recurring frequencies', () => {
|
||||
expect(getOnceRunAt(schedule({ frequency: 'once', date: null }))).toBeNull();
|
||||
expect(getOnceRunAt(schedule({ frequency: 'daily' }))).toBeNull();
|
||||
expect(getOnceRunAt(schedule({ frequency: 'monthly', dayOfMonth: 5 }))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSimpleScheduleError', () => {
|
||||
const now = new Date(2026, 5, 28);
|
||||
|
||||
@@ -162,13 +181,24 @@ describe('getSimpleScheduleError', () => {
|
||||
|
||||
it('blocks once with a past date', () => {
|
||||
expect(getSimpleScheduleError(schedule({ frequency: 'once', date: new Date(2026, 5, 1) }), now))
|
||||
.toBe('The selected date is in the past and this schedule would never fire.');
|
||||
.toBe('The selected date and time are in the past and this schedule would never fire.');
|
||||
});
|
||||
|
||||
it('passes once with a future date', () => {
|
||||
expect(getSimpleScheduleError(schedule({ frequency: 'once', date: new Date(2026, 11, 25) }), now)).toBeNull();
|
||||
});
|
||||
|
||||
it('blocks once for a time earlier today that has already passed', () => {
|
||||
const noon = new Date(2026, 5, 28, 12, 0);
|
||||
expect(getSimpleScheduleError(schedule({ frequency: 'once', date: new Date(2026, 5, 28), hour: 1, minute: 0 }), noon))
|
||||
.toBe('The selected date and time are in the past and this schedule would never fire.');
|
||||
});
|
||||
|
||||
it('passes once for a later time today that has not yet passed', () => {
|
||||
const noon = new Date(2026, 5, 28, 12, 0);
|
||||
expect(getSimpleScheduleError(schedule({ frequency: 'once', date: new Date(2026, 5, 28), hour: 23, minute: 0 }), noon)).toBeNull();
|
||||
});
|
||||
|
||||
it('blocks an invalid time', () => {
|
||||
expect(getSimpleScheduleError(schedule({ frequency: 'daily', minute: Number.NaN }), now)).toBe('Enter a valid time.');
|
||||
});
|
||||
|
||||
@@ -70,6 +70,19 @@ export function buildCron(s: SimpleSchedule): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute epoch-ms fire time for a one-time ('once') schedule: the picked date
|
||||
* at the chosen hour and minute. Returns null when the schedule is not 'once' or
|
||||
* has no date. A 5-field cron cannot encode a year, so a one-shot sends this
|
||||
* explicit timestamp to the backend to pin the exact run (year and time of day);
|
||||
* relying on the cron alone fires on the next annual occurrence, which can be a
|
||||
* different year than the date the admin selected.
|
||||
*/
|
||||
export function getOnceRunAt(s: SimpleSchedule): number | null {
|
||||
if (s.frequency !== 'once' || !s.date) return null;
|
||||
return new Date(s.date.getFullYear(), s.date.getMonth(), s.date.getDate(), s.hour, s.minute, 0, 0).getTime();
|
||||
}
|
||||
|
||||
function parseIntField(field: string, min: number, max: number): number | null {
|
||||
if (!/^\d+$/.test(field)) return null;
|
||||
const n = Number(field);
|
||||
@@ -167,9 +180,14 @@ export function getSimpleScheduleError(s: SimpleSchedule, now: Date = new Date()
|
||||
}
|
||||
if (s.frequency === 'once') {
|
||||
if (!s.date) return 'Select a date.';
|
||||
const picked = new Date(s.date.getFullYear(), s.date.getMonth(), s.date.getDate()).getTime();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
|
||||
if (picked < today) return 'The selected date is in the past and this schedule would never fire.';
|
||||
// Compare the full chosen instant (date + time), not just the day: a time
|
||||
// earlier today has already passed. The backend also rejects a past run_at
|
||||
// with a 400; this is the friendlier, save-blocking guard surfaced before
|
||||
// the request is sent.
|
||||
const when = getOnceRunAt(s);
|
||||
if (when !== null && when <= now.getTime()) {
|
||||
return 'The selected date and time are in the past and this schedule would never fire.';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user