Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions frontend/app/agent/schedules/schedules-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,21 @@ export async function fetchHistory(id: string, signal?: AbortSignal): Promise<Fi
if (!res.ok) throw new Error(`history ${res.status}`);
return envelope(await res.json(), "firings") as FiringRecord[];
}

/**
* The next runs a cron and zone would have if enabled now, from the
* scheduler itself (`GET /api/automations/preview`): what an editor shows
* while a schedule is typed, before anything is saved. Up to three ISO
* UTC times, fewer when the cron names fewer dates. Throws on a non-2xx,
* including the 400 for a cron or zone the scheduler cannot read.
*/
export async function fetchSchedulePreview(cron: string, timezone: string, signal?: AbortSignal): Promise<string[]> {
const query = new URLSearchParams({ cron, timezone });
const res = await backendFetch(`/api/automations/preview?${query}`, {
cache: "no-store",
signal,
});
if (!res.ok) throw new Error(`schedule preview ${res.status}`);
const body = (await res.json()) as { next_runs?: string[] };
return body.next_runs ?? [];
}
198 changes: 195 additions & 3 deletions frontend/app/agent/schedules/schedules-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,9 @@ function clockLabel(hour: number, minute: number): string {
export function cadenceLabel(cron: string): string {
const fields = cron.trim().split(/\s+/);
if (fields.length !== 5) return cron;
// Never verbalize an expression the scheduler would reject: an out-of-range
// field (minute 61) would otherwise read as a plausible time ("9:61 AM").
if (!isValidCron(cron)) return cron;
const [minuteField, hourField, day, month, weekday] = fields;
if (day !== "*" || month !== "*") return cron;

Expand All @@ -131,9 +134,198 @@ export function cadenceLabel(cron: string): string {
if (!Number.isInteger(minute) || !Number.isInteger(hour)) return cron;
const time = clockLabel(hour, minute);
if (weekday === "*") return `Every day at ${time}`;
if (weekday === "1-5") return `Weekdays at ${time}`;
const weekdayName = WEEKDAY_NAMES[Number(weekday)];
return weekdayName ? `Every ${weekdayName} at ${time}` : cron;
const days = weekdaySet(weekday);
if (!days) return cron;
if (days.length === 7) return `Every day at ${time}`;
if (days.join(",") === "1,2,3,4,5") return `Weekdays at ${time}`;
if (days.join(",") === "0,6") return `Weekends at ${time}`;
if (days.length === 1) return `Every ${WEEKDAY_NAMES[days[0] ?? 0]} at ${time}`;
const names = days.map((day) => `${WEEKDAY_NAMES[day]}s`);
return `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]} at ${time}`;
}

/**
* The days a weekday field selects, as sorted day numbers (Sunday is 0, and
* 7 reads as Sunday like the scheduler treats it). Handles single days,
* ranges (`1-5`), and lists of either (`2,4`, `1-3,5`). Anything fancier
* (steps, names) returns null so the caller can fall back to the raw cron.
*/
function weekdaySet(field: string): number[] | null {
const days = new Set<number>();
for (const part of field.split(",")) {
const range = part.match(/^(\d+)-(\d+)$/);
if (range) {
const lo = Number(range[1]);
const hi = Number(range[2]);
if (lo > 7 || hi > 7 || lo > hi) return null;
for (let day = lo; day <= hi; day++) days.add(day % 7);
} else if (/^\d+$/.test(part)) {
const day = Number(part);
if (day > 7) return null;
days.add(day % 7);
} else {
return null;
}
}
return [...days].sort((a, b) => a - b);
}

/**
* Cron field parsing, kept only to validate an expression before it is
* turned into words or sent anywhere: same field syntax the scheduler
* accepts (lists, ranges, steps). When a cron fires is the server's call
* (it returns the next run on the API), never computed here.
*/
function expandCronField(field: string, min: number, max: number): Set<number> | null {
if (field === "") return null;
const out = new Set<number>();

for (const part of field.split(",")) {
if (part === "") return null;

let range = part;
let step = 1;
const slash = part.indexOf("/");
if (slash !== -1) {
range = part.slice(0, slash);
step = Number(part.slice(slash + 1));
if (!Number.isInteger(step) || step <= 0) return null;
}

let lo: number;
let hi: number;
if (range === "*") {
lo = min;
hi = max;
} else if (range.includes("-")) {
const bounds = range.split("-");
if (bounds.length !== 2) return null;
lo = Number(bounds[0]);
hi = Number(bounds[1]);
if (!Number.isInteger(lo) || !Number.isInteger(hi)) return null;
} else {
lo = Number(range);
if (!Number.isInteger(lo)) return null;
hi = slash !== -1 ? max : lo;
}

if (lo < min || hi > max || lo > hi) return null;
for (let v = lo; v <= hi; v += step) out.add(v);
}

return out;
}

interface ParsedCron {
minute: Set<number>;
hour: Set<number>;
dom: Set<number>;
month: Set<number>;
dow: Set<number>;
domRestricted: boolean;
dowRestricted: boolean;
}

function parseCron(expr: string): ParsedCron | null {
const fields = expr.trim().split(/\s+/);
if (fields.length !== 5) return null;
const [minuteField, hourField, domField, monthField, dowField] = fields as [string, string, string, string, string];

const minute = expandCronField(minuteField, 0, 59);
const hour = expandCronField(hourField, 0, 23);
const dom = expandCronField(domField, 1, 31);
const month = expandCronField(monthField, 1, 12);
const dow = expandCronField(dowField, 0, 7);
if (!minute || !hour || !dom || !month || !dow) return null;

// Both 0 and 7 mean Sunday; normalize 7 to 0 (JS `Date.getDay()` style).
if (dow.has(7)) dow.add(0);

return {
minute,
hour,
dom,
month,
dow,
domRestricted: domField !== "*",
dowRestricted: dowField !== "*",
};
}

/** True if `cron` is a syntactically valid 5-field cron expression. */
export function isValidCron(cron: string): boolean {
return parseCron(cron) !== null;
}

const WEEKDAY_INDEX: Record<string, number> = {
Sun: 0,
Mon: 1,
Tue: 2,
Wed: 3,
Thu: 4,
Fri: 5,
Sat: 6,
};

interface ZonedParts {
year: number;
month: number;
day: number;
hour: number;
minute: number;
second: number;
weekday: number;
}

const zonedFormatters = new Map<string, Intl.DateTimeFormat>();

function zonedParts(date: Date, timeZone: string): ZonedParts {
let formatter = zonedFormatters.get(timeZone);
if (!formatter) {
formatter = new Intl.DateTimeFormat("en-US", {
timeZone,
hourCycle: "h23",
year: "numeric",
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
second: "numeric",
weekday: "short",
});
zonedFormatters.set(timeZone, formatter);
}
const parts = formatter.formatToParts(date);
const get = (type: string): string => parts.find((p) => p.type === type)?.value ?? "";
return {
year: Number(get("year")),
month: Number(get("month")),
day: Number(get("day")),
hour: Number(get("hour")),
minute: Number(get("minute")),
second: Number(get("second")),
weekday: WEEKDAY_INDEX[get("weekday")] ?? date.getUTCDay(),
};
}

/**
* The next run in plain words: "Tuesday at 9:00 AM", in the schedule's
* timezone. The instant itself is the server's: `nextDueAt` on a routine,
* `next_due_at` on an automation, or the first entry of the preview
* endpoint's `next_runs`. The scheduler owns when a cron fires; the
* browser only renders the instant it is given. Null when the server
* names no next run, or when there is no readable zone to render it in.
*/
export function nextRunLabelAt(nextRun: string | null, timezone: string | null): string | null {
if (!nextRun || !timezone) return null;
const at = new Date(nextRun);
if (Number.isNaN(at.getTime())) return null;
try {
const wc = zonedParts(at, timezone);
return `${WEEKDAY_NAMES[wc.weekday]} at ${clockLabel(wc.hour, wc.minute)}`;
} catch {
return null;
}
}

export function scheduleZone(schedule: Pick<ScheduleRecord, "timezone">): string {
Expand Down
48 changes: 48 additions & 0 deletions frontend/components/automations/automation-data.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {
automationEngineOptions,
cadenceLabel,
engineLabel,
isValidCron,
nextRunLabelAt,
reconcileAutomationEngine,
scheduleZone,
} from "@/app/agent/schedules/schedules-data";
Expand Down Expand Up @@ -56,3 +58,49 @@ describe("automation presentation", () => {
expect(reconcileAutomationEngine([], "codex")).toBe("");
});
});

describe("plain-words schedules and next run", () => {
test("reads lists and ranges of days as words", () => {
expect(cadenceLabel("30 8 * * 2,4")).toBe("Tuesdays and Thursdays at 8:30 AM");
expect(cadenceLabel("0 9 * * 1,3,5")).toBe("Mondays, Wednesdays and Fridays at 9:00 AM");
expect(cadenceLabel("0 9 * * 2-4")).toBe("Tuesdays, Wednesdays and Thursdays at 9:00 AM");
expect(cadenceLabel("0 9 * * 0,6")).toBe("Weekends at 9:00 AM");
expect(cadenceLabel("0 9 * * 6,0")).toBe("Weekends at 9:00 AM");
expect(cadenceLabel("0 9 * * 7")).toBe("Every Sunday at 9:00 AM");
});

test("never turns an invalid expression into words", () => {
// Minute 61 would otherwise read as "9:61 AM" on the list rows.
expect(cadenceLabel("61 9 * * 2,4")).toBe("61 9 * * 2,4");
expect(cadenceLabel("0 25 * * *")).toBe("0 25 * * *");
});

test("rejects expressions the scheduler would reject", () => {
expect(isValidCron("30 8 * * 2,4")).toBe(true);
expect(isValidCron("0 9 * * 1-5")).toBe(true);
expect(isValidCron("0 9 * *")).toBe(false);
expect(isValidCron("61 9 * * *")).toBe(false);
expect(isValidCron("0 9 * * 8")).toBe(false);
expect(isValidCron("0 9 32 * *")).toBe(false);
expect(isValidCron("not a cron")).toBe(false);
expect(isValidCron("")).toBe(false);
});

test("renders the next run the server names, in the schedule's zone", () => {
// 13:00 UTC is 9:00 in New York in October, on a Tuesday.
expect(nextRunLabelAt("2026-10-13T13:00:00.000Z", "America/New_York")).toBe("Tuesday at 9:00 AM");
// The same instant is already Wednesday in Auckland.
expect(nextRunLabelAt("2026-10-13T13:00:00.000Z", "Pacific/Auckland")).toBe("Wednesday at 2:00 AM");
// A fall-back instant the server picks renders at its own wall time.
expect(nextRunLabelAt("2026-11-01T06:30:00.000Z", "America/New_York")).toBe("Sunday at 1:30 AM");
});

test("shows no next run when the server names none", () => {
// Paused, or a cron that names no date: the server answers null.
expect(nextRunLabelAt(null, "UTC")).toBeNull();
// No zone to render the instant in.
expect(nextRunLabelAt("2026-10-13T13:00:00.000Z", null)).toBeNull();
expect(nextRunLabelAt("2026-10-13T13:00:00.000Z", "Not/AZone")).toBeNull();
expect(nextRunLabelAt("not a date", "UTC")).toBeNull();
});
});
23 changes: 23 additions & 0 deletions frontend/components/automations/schedules-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
createSchedule,
deleteSchedule,
fetchHistory,
fetchSchedulePreview,
fetchSchedules,
runScheduleNow,
updateSchedule,
Expand Down Expand Up @@ -211,6 +212,28 @@ describe("Automations API client", () => {
]);
});

test("previews a schedule's next runs from the scheduler, with its zone", async () => {
responses.push(Response.json({ next_runs: ["2026-10-13T13:00:00.000Z", "2026-10-14T13:00:00.000Z"] }));
const controller = new AbortController();

const result = await fetchSchedulePreview("0 9 * * *", "America/New_York", controller.signal);

expect(result).toEqual(["2026-10-13T13:00:00.000Z", "2026-10-14T13:00:00.000Z"]);
expect(calls).toHaveLength(1);
const input = String(calls[0]?.input);
expect(input.startsWith("/api/automations/preview?")).toBeTrue();
const query = new URLSearchParams(input.slice(input.indexOf("?") + 1));
expect(query.get("cron")).toBe("0 9 * * *");
expect(query.get("timezone")).toBe("America/New_York");
expect(calls[0]?.init?.signal).toBe(controller.signal);
});

test("surfaces the preview endpoint's refusal for an unreadable cron", async () => {
responses.push(Response.json({ error: "cron must be a valid 5-field expression" }, { status: 400 }));

await expect(fetchSchedulePreview("61 9 * * *", "UTC")).rejects.toThrow("schedule preview 400");
});

test("deletes an Automation through its canonical item endpoint", async () => {
responses.push(new Response(null, { status: 204 }));

Expand Down
Loading
Loading