diff --git a/frontend/app/agent/schedules/schedules-api.ts b/frontend/app/agent/schedules/schedules-api.ts index dbfb2e6a4..cde3a80f1 100644 --- a/frontend/app/agent/schedules/schedules-api.ts +++ b/frontend/app/agent/schedules/schedules-api.ts @@ -81,3 +81,21 @@ export async function fetchHistory(id: string, signal?: AbortSignal): Promise { + 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 ?? []; +} diff --git a/frontend/app/agent/schedules/schedules-data.ts b/frontend/app/agent/schedules/schedules-data.ts index f065fb326..57ee5cc33 100644 --- a/frontend/app/agent/schedules/schedules-data.ts +++ b/frontend/app/agent/schedules/schedules-data.ts @@ -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; @@ -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(); + 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 | null { + if (field === "") return null; + const out = new Set(); + + 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; + hour: Set; + dom: Set; + month: Set; + dow: Set; + 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 = { + 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(); + +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): string { diff --git a/frontend/components/automations/automation-data.test.ts b/frontend/components/automations/automation-data.test.ts index a39ecd91e..1f3cb38c5 100644 --- a/frontend/components/automations/automation-data.test.ts +++ b/frontend/components/automations/automation-data.test.ts @@ -4,6 +4,8 @@ import { automationEngineOptions, cadenceLabel, engineLabel, + isValidCron, + nextRunLabelAt, reconcileAutomationEngine, scheduleZone, } from "@/app/agent/schedules/schedules-data"; @@ -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(); + }); +}); diff --git a/frontend/components/automations/schedules-api.test.ts b/frontend/components/automations/schedules-api.test.ts index 1dd6356c6..55c6db555 100644 --- a/frontend/components/automations/schedules-api.test.ts +++ b/frontend/components/automations/schedules-api.test.ts @@ -3,6 +3,7 @@ import { createSchedule, deleteSchedule, fetchHistory, + fetchSchedulePreview, fetchSchedules, runScheduleNow, updateSchedule, @@ -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 })); diff --git a/frontend/components/bots/routine-form.tsx b/frontend/components/bots/routine-form.tsx index 0f8afff25..e168a67c3 100644 --- a/frontend/components/bots/routine-form.tsx +++ b/frontend/components/bots/routine-form.tsx @@ -1,6 +1,8 @@ "use client"; -import { useId, useRef, useState } from "react"; +import { useEffect, useId, useRef, useState } from "react"; +import { fetchSchedulePreview } from "@/app/agent/schedules/schedules-api"; +import { cadenceLabel, isValidCron, nextRunLabelAt } from "@/app/agent/schedules/schedules-data"; import { Button } from "@/components/base/buttons/button"; import { Input } from "@/components/base/input/input"; import * as Textarea from "@/components/base/textarea/textarea"; @@ -28,6 +30,7 @@ type Field = "name" | "prompt" | "cron"; export function RoutineForm({ bot, routine, onSaved, onCancel }: { bot: ApiBot; routine?: ApiRoutine; onSaved: (saved: ApiRoutine) => void; onCancel: () => void }) { const promptErrorId = useId(); + const previewId = useId(); const nameRef = useRef(null); const promptRef = useRef(null); const cronRef = useRef(null); @@ -38,6 +41,43 @@ export function RoutineForm({ bot, routine, onSaved, onCancel }: { bot: ApiBot; const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const timezone = routine ? routine.timezone : browserTimezone(); + /** The zone the preview runs in: a create that names none runs in UTC, as the server does. */ + const previewZone = timezone ?? "UTC"; + const [nextRunAt, setNextRunAt] = useState(null); + + /** Live read of the cron field: the schedule in words and when it fires next. */ + const cronText = cron.trim(); + const cronValid = cronText.length > 0 && isValidCron(cronText); + const words = cronValid && cadenceLabel(cronText) !== cronText ? cadenceLabel(cronText) : null; + + // The next run comes from the scheduler itself, debounced while typing; + // a failed or empty answer shows no next run rather than a guessed one. + useEffect(() => { + if (!cronValid) { + setNextRunAt(null); + return; + } + const controller = new AbortController(); + const timer = setTimeout(() => { + void fetchSchedulePreview(cronText, previewZone, controller.signal) + .then((runs) => setNextRunAt(runs[0] ?? null)) + .catch(() => { + if (!controller.signal.aborted) setNextRunAt(null); + }); + }, 300); + return () => { + clearTimeout(timer); + controller.abort(); + }; + }, [cronText, cronValid, previewZone]); + + const nextRun = nextRunLabelAt(nextRunAt, previewZone); + const preview = + cronText.length === 0 + ? null + : !cronValid + ? "That expression is invalid." + : [words, nextRun ? `next run ${nextRun}` : null].filter(Boolean).join(" · ") || null; const clear = (field: Field) => setInvalid((current) => ({ ...current, [field]: undefined })); @@ -135,9 +175,19 @@ export function RoutineForm({ bot, routine, onSaved, onCancel }: { bot: ApiBot; clear("cron"); }} isInvalid={Boolean(invalid.cron)} - hint={invalid.cron ?? (timezone ? `Times in ${timezone}.` : "Times in the server's timezone.")} + hint={invalid.cron ?? `Times in ${previewZone}.`} fieldClassName="font-mono" + aria-describedby={preview ? previewId : undefined} /> + {preview && ( +

+ {preview} +

+ )} {error && (

diff --git a/frontend/components/bots/routines-section.tsx b/frontend/components/bots/routines-section.tsx index 2fdb40300..c80da77d0 100644 --- a/frontend/components/bots/routines-section.tsx +++ b/frontend/components/bots/routines-section.tsx @@ -4,7 +4,7 @@ import { RiAddLine, RiDeleteBinLine, RiEditLine, RiHistoryLine, RiPlayLine } fro import Link from "next/link"; import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; -import { cadenceLabel } from "@/app/agent/schedules/schedules-data"; +import { cadenceLabel, nextRunLabelAt } from "@/app/agent/schedules/schedules-data"; import { Badge } from "@/components/base/badges/badge"; import { Button } from "@/components/base/buttons/button"; import * as Modal from "@/components/base/modal/modal"; @@ -40,6 +40,9 @@ function RoutineRow({ const [started, setStarted] = useState(null); const [error, setError] = useState(null); + /** When this routine fires next, as the server reports it; a paused routine reports no next run. */ + const nextRun = nextRunLabelAt(routine.nextDueAt, routine.timezone); + const guard = async (kind: "toggle" | "run" | "delete", work: () => Promise) => { if (busy) return; setBusy(kind); @@ -79,8 +82,9 @@ function RoutineRow({

{cadenceLabel(routine.cron)} - {routine.timezone ? ` (${routine.timezone})` : ""} + {` (${routine.timezone})`} {routine.lastFiredAt ? ` · last run ${relativeTime(routine.lastFiredAt, now) || "just now"}` : " · never run"} + {nextRun ? ` · next run ${nextRun}` : ""}