mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-27 10:46:51 +00:00
feat: add a Simple mode to the New Schedule flow (#1495)
The schedule editor exposed only a raw 5-field cron input, which is unfriendly for the common "daily at 3am" or "one-time next week" cases. Add a Simple mode (now the default) that builds the cron from a frequency and time: Once, Hourly, Daily, Weekly, and Monthly. Advanced mode keeps the raw cron input as an escape hatch. - Simple mode compiles to the existing cron_expression on save; no schema or scheduler changes. - One-time schedules reuse the existing delete-after-run flag: selecting Once turns it on and locks it so the task runs a single time (cron has no year field, so without it the task would repeat yearly). - Editing a task opens in Simple mode when its cron maps to one of the simple shapes, otherwise in Advanced; switching a custom cron to Simple warns that it will be replaced. - Day-of-week uses frosted toggle chips and the time uses hour/minute selects so the controls match the rest of the editor.
This commit is contained in:
@@ -1,5 +1,23 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getCronFieldError } from './scheduling';
|
||||
import {
|
||||
getCronFieldError,
|
||||
buildCron,
|
||||
parseCron,
|
||||
getSimpleScheduleError,
|
||||
type SimpleSchedule,
|
||||
} from './scheduling';
|
||||
|
||||
function schedule(overrides: Partial<SimpleSchedule> = {}): SimpleSchedule {
|
||||
return {
|
||||
frequency: 'daily',
|
||||
minute: 0,
|
||||
hour: 3,
|
||||
weekdays: [],
|
||||
dayOfMonth: 1,
|
||||
date: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('getCronFieldError', () => {
|
||||
it('accepts a standard 5-field expression', () => {
|
||||
@@ -27,3 +45,135 @@ describe('getCronFieldError', () => {
|
||||
expect(getCronFieldError(' 0 3 * * * ')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildCron', () => {
|
||||
it('hourly emits a literal minute against every hour', () => {
|
||||
expect(buildCron(schedule({ frequency: 'hourly', minute: 15 }))).toBe('15 * * * *');
|
||||
});
|
||||
|
||||
it('daily emits minute and hour', () => {
|
||||
expect(buildCron(schedule({ frequency: 'daily', minute: 0, hour: 3 }))).toBe('0 3 * * *');
|
||||
});
|
||||
|
||||
it('weekly emits a sorted, de-duplicated weekday list', () => {
|
||||
expect(buildCron(schedule({ frequency: 'weekly', minute: 0, hour: 3, weekdays: [5, 1, 3] }))).toBe('0 3 * * 1,3,5');
|
||||
expect(buildCron(schedule({ frequency: 'weekly', minute: 0, hour: 3, weekdays: [1, 3, 1] }))).toBe('0 3 * * 1,3');
|
||||
});
|
||||
|
||||
it('monthly emits the day of month', () => {
|
||||
expect(buildCron(schedule({ frequency: 'monthly', minute: 30, hour: 14, dayOfMonth: 15 }))).toBe('30 14 15 * *');
|
||||
});
|
||||
|
||||
it('once pins the day and month from the date', () => {
|
||||
expect(buildCron(schedule({ frequency: 'once', minute: 0, hour: 9, date: new Date(2026, 5, 30) }))).toBe('0 9 30 6 *');
|
||||
});
|
||||
|
||||
it('once returns an empty string when no date is set (total, never throws)', () => {
|
||||
expect(buildCron(schedule({ frequency: 'once', date: null }))).toBe('');
|
||||
});
|
||||
|
||||
it('weekly returns an empty string when no weekdays are selected (no malformed cron)', () => {
|
||||
expect(buildCron(schedule({ frequency: 'weekly', weekdays: [] }))).toBe('');
|
||||
});
|
||||
|
||||
it('every frequency with valid input produces a 5-field expression', () => {
|
||||
const cases: SimpleSchedule[] = [
|
||||
schedule({ frequency: 'hourly', minute: 5 }),
|
||||
schedule({ frequency: 'daily' }),
|
||||
schedule({ frequency: 'weekly', weekdays: [2] }),
|
||||
schedule({ frequency: 'monthly', dayOfMonth: 10 }),
|
||||
schedule({ frequency: 'once', date: new Date(2026, 0, 1) }),
|
||||
];
|
||||
for (const c of cases) {
|
||||
expect(buildCron(c).split(/\s+/)).toHaveLength(5);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseCron', () => {
|
||||
it('round-trips every buildCron output at the cron-string level', () => {
|
||||
const cases: SimpleSchedule[] = [
|
||||
schedule({ frequency: 'hourly', minute: 5 }),
|
||||
schedule({ frequency: 'daily', minute: 0, hour: 3 }),
|
||||
schedule({ frequency: 'weekly', minute: 0, hour: 0, weekdays: [1, 3, 5] }),
|
||||
schedule({ frequency: 'monthly', minute: 30, hour: 14, dayOfMonth: 1 }),
|
||||
schedule({ frequency: 'once', minute: 0, hour: 9, date: new Date(new Date().getFullYear(), 5, 30) }),
|
||||
];
|
||||
for (const c of cases) {
|
||||
const cron = buildCron(c);
|
||||
const parsed = parseCron(cron, c.frequency === 'once');
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(buildCron(parsed as SimpleSchedule)).toBe(cron);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns null for non-matching shapes', () => {
|
||||
expect(parseCron('*/15 * * * *', false)).toBeNull();
|
||||
expect(parseCron('@daily', false)).toBeNull();
|
||||
expect(parseCron('0 3 * * 1-5', false)).toBeNull(); // range in weekday field
|
||||
expect(parseCron('0 3 1 * 1', false)).toBeNull(); // both day-of-month and weekday pinned
|
||||
expect(parseCron('30 0 3 * * *', false)).toBeNull(); // 6 fields
|
||||
});
|
||||
|
||||
it('reads a daily expression', () => {
|
||||
expect(parseCron('0 0 * * *', false)).toMatchObject({ frequency: 'daily', minute: 0, hour: 0 });
|
||||
});
|
||||
|
||||
it('reads a weekly expression with a weekday list', () => {
|
||||
expect(parseCron('0 0 * * 1,3,5', false)).toMatchObject({ frequency: 'weekly', minute: 0, hour: 0, weekdays: [1, 3, 5] });
|
||||
});
|
||||
|
||||
it('reads a monthly expression', () => {
|
||||
expect(parseCron('30 14 1 * *', false)).toMatchObject({ frequency: 'monthly', minute: 30, hour: 14, dayOfMonth: 1 });
|
||||
});
|
||||
|
||||
it('reads a one-time expression only when delete-after-run is set', () => {
|
||||
expect(parseCron('0 0 1 1 *', false)).toBeNull();
|
||||
const parsed = parseCron('0 0 1 1 *', true);
|
||||
expect(parsed).toMatchObject({ frequency: 'once', minute: 0, hour: 0, dayOfMonth: 1 });
|
||||
expect(parsed?.date?.getMonth()).toBe(0); // January
|
||||
expect(parsed?.date?.getDate()).toBe(1);
|
||||
});
|
||||
|
||||
it('rejects an impossible day/month combination for once', () => {
|
||||
expect(parseCron('0 0 31 2 *', true)).toBeNull(); // Feb 31
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSimpleScheduleError', () => {
|
||||
const now = new Date(2026, 5, 28);
|
||||
|
||||
it('passes a valid daily schedule', () => {
|
||||
expect(getSimpleScheduleError(schedule({ frequency: 'daily' }), now)).toBeNull();
|
||||
});
|
||||
|
||||
it('blocks weekly with no weekdays', () => {
|
||||
expect(getSimpleScheduleError(schedule({ frequency: 'weekly', weekdays: [] }), now)).toBe('Choose at least one weekday.');
|
||||
});
|
||||
|
||||
it('blocks an out-of-range day of month', () => {
|
||||
expect(getSimpleScheduleError(schedule({ frequency: 'monthly', dayOfMonth: 0 }), now)).toBe('Day of month must be 1-31.');
|
||||
expect(getSimpleScheduleError(schedule({ frequency: 'monthly', dayOfMonth: 32 }), now)).toBe('Day of month must be 1-31.');
|
||||
});
|
||||
|
||||
it('blocks once with no date', () => {
|
||||
expect(getSimpleScheduleError(schedule({ frequency: 'once', date: null }), now)).toBe('Select a date.');
|
||||
});
|
||||
|
||||
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.');
|
||||
});
|
||||
|
||||
it('passes once with a future date', () => {
|
||||
expect(getSimpleScheduleError(schedule({ frequency: 'once', date: new Date(2026, 11, 25) }), now)).toBeNull();
|
||||
});
|
||||
|
||||
it('blocks an invalid time', () => {
|
||||
expect(getSimpleScheduleError(schedule({ frequency: 'daily', minute: Number.NaN }), now)).toBe('Enter a valid time.');
|
||||
});
|
||||
|
||||
it('ignores the hour for hourly schedules', () => {
|
||||
expect(getSimpleScheduleError(schedule({ frequency: 'hourly', hour: 25, minute: 0 }), now)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,3 +26,150 @@ export function formatTimestamp(ts: number | null): string {
|
||||
if (ts == null) return '-';
|
||||
return new Date(ts).toLocaleString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple schedule mode: a friendly, structured way to describe a schedule that
|
||||
* compiles down to the same 5-field `cron_expression` the backend already
|
||||
* stores. Anything outside these five shapes is edited as raw cron (Advanced).
|
||||
*/
|
||||
export type SimpleFrequency = 'once' | 'hourly' | 'daily' | 'weekly' | 'monthly';
|
||||
|
||||
export interface SimpleSchedule {
|
||||
frequency: SimpleFrequency;
|
||||
minute: number; // 0-59
|
||||
hour: number; // 0-23, ignored for 'hourly'
|
||||
weekdays: number[]; // 0-6 (Sun-Sat), used for 'weekly'
|
||||
dayOfMonth: number; // 1-31, used for 'monthly'
|
||||
date: Date | null; // used for 'once' (supplies day + month)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the 5-field cron expression for a simple schedule. Never throws: an
|
||||
* incomplete schedule (a 'once' with no date, or a 'weekly' with no days)
|
||||
* returns an empty string, so a render that reaches it before validation cannot
|
||||
* crash and a malformed expression is never produced.
|
||||
*/
|
||||
export function buildCron(s: SimpleSchedule): string {
|
||||
const m = s.minute;
|
||||
const h = s.hour;
|
||||
switch (s.frequency) {
|
||||
case 'hourly':
|
||||
return `${m} * * * *`;
|
||||
case 'daily':
|
||||
return `${m} ${h} * * *`;
|
||||
case 'weekly': {
|
||||
if (s.weekdays.length === 0) return '';
|
||||
const days = [...new Set(s.weekdays)].sort((a, b) => a - b).join(',');
|
||||
return `${m} ${h} * * ${days}`;
|
||||
}
|
||||
case 'monthly':
|
||||
return `${m} ${h} ${s.dayOfMonth} * *`;
|
||||
case 'once':
|
||||
if (!s.date) return '';
|
||||
return `${m} ${h} ${s.date.getDate()} ${s.date.getMonth() + 1} *`;
|
||||
}
|
||||
}
|
||||
|
||||
function parseIntField(field: string, min: number, max: number): number | null {
|
||||
if (!/^\d+$/.test(field)) return null;
|
||||
const n = Number(field);
|
||||
return n >= min && n <= max ? n : null;
|
||||
}
|
||||
|
||||
function parseWeekdayList(field: string): number[] | null {
|
||||
const days: number[] = [];
|
||||
for (const token of field.split(',')) {
|
||||
const n = parseIntField(token, 0, 6);
|
||||
if (n === null) return null;
|
||||
days.push(n);
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse of `buildCron`. Returns the structured schedule when `cron` matches
|
||||
* one of the five generatable shapes, or null when it doesn't (so the editor
|
||||
* falls back to Advanced cron). A fully pinned `M H D MO *` is only read as a
|
||||
* one-time schedule when `deleteAfterRun` is set, since cron has no year field
|
||||
* and Simple mode has no recurring "yearly" frequency. Strict and total.
|
||||
*/
|
||||
export function parseCron(cron: string, deleteAfterRun: boolean): SimpleSchedule | null {
|
||||
const parts = cron.trim().split(/\s+/);
|
||||
if (parts.length !== 5) return null;
|
||||
const [minF, hrF, domF, monF, dowF] = parts;
|
||||
|
||||
const minute = parseIntField(minF, 0, 59);
|
||||
if (minute === null) return null;
|
||||
|
||||
const base: SimpleSchedule = {
|
||||
frequency: 'daily', minute, hour: 0, weekdays: [], dayOfMonth: 1, date: null,
|
||||
};
|
||||
|
||||
// hourly: M * * * *
|
||||
if (hrF === '*' && domF === '*' && monF === '*' && dowF === '*') {
|
||||
return { ...base, frequency: 'hourly' };
|
||||
}
|
||||
|
||||
const hour = parseIntField(hrF, 0, 23);
|
||||
if (hour === null) return null;
|
||||
|
||||
// daily: M H * * *
|
||||
if (domF === '*' && monF === '*' && dowF === '*') {
|
||||
return { ...base, frequency: 'daily', hour };
|
||||
}
|
||||
|
||||
// weekly: M H * * <weekday list>
|
||||
if (domF === '*' && monF === '*' && dowF !== '*') {
|
||||
const weekdays = parseWeekdayList(dowF);
|
||||
if (!weekdays) return null;
|
||||
return { ...base, frequency: 'weekly', hour, weekdays };
|
||||
}
|
||||
|
||||
// monthly: M H D * *
|
||||
if (domF !== '*' && monF === '*' && dowF === '*') {
|
||||
const dayOfMonth = parseIntField(domF, 1, 31);
|
||||
if (dayOfMonth === null) return null;
|
||||
return { ...base, frequency: 'monthly', hour, dayOfMonth };
|
||||
}
|
||||
|
||||
// once: M H D MO * (only a one-time schedule when delete-after-run is set)
|
||||
if (domF !== '*' && monF !== '*' && dowF === '*') {
|
||||
if (!deleteAfterRun) return null;
|
||||
const dayOfMonth = parseIntField(domF, 1, 31);
|
||||
const month = parseIntField(monF, 1, 12);
|
||||
if (dayOfMonth === null || month === null) return null;
|
||||
const date = new Date(new Date().getFullYear(), month - 1, dayOfMonth);
|
||||
// Reject impossible day/month combos (JS Date rolls Feb 31 into March).
|
||||
if (date.getMonth() !== month - 1 || date.getDate() !== dayOfMonth) return null;
|
||||
return { ...base, frequency: 'once', hour, dayOfMonth, date };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a simple schedule. Returns a human-readable, save-blocking message
|
||||
* when the fields can't produce a schedule that will fire, or null when valid.
|
||||
* Returns the same `string | null` shape `getCronFieldError` uses for Advanced.
|
||||
*/
|
||||
export function getSimpleScheduleError(s: SimpleSchedule, now: Date = new Date()): string | null {
|
||||
if (!Number.isInteger(s.minute) || s.minute < 0 || s.minute > 59) {
|
||||
return 'Enter a valid time.';
|
||||
}
|
||||
if (s.frequency !== 'hourly' && (!Number.isInteger(s.hour) || s.hour < 0 || s.hour > 23)) {
|
||||
return 'Enter a valid time.';
|
||||
}
|
||||
if (s.frequency === 'weekly' && s.weekdays.length === 0) {
|
||||
return 'Choose at least one weekday.';
|
||||
}
|
||||
if (s.frequency === 'monthly' && (!Number.isInteger(s.dayOfMonth) || s.dayOfMonth < 1 || s.dayOfMonth > 31)) {
|
||||
return 'Day of month must be 1-31.';
|
||||
}
|
||||
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.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user