mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 04:06:59 +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:
@@ -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