From 664e18f3c0ba4640871b5b1a9799b3451fda8ad2 Mon Sep 17 00:00:00 2001 From: Ryan Cotten Date: Tue, 13 Oct 2026 06:55:57 -0400 Subject: [PATCH 1/3] Show when a routine runs next, and read custom schedules in plain words --- .../app/agent/schedules/schedules-data.ts | 294 +++++++++++++++++- .../automations/automation-data.test.ts | 60 ++++ frontend/components/bots/routine-form.tsx | 16 + frontend/components/bots/routines-section.tsx | 6 +- 4 files changed, 372 insertions(+), 4 deletions(-) diff --git a/frontend/app/agent/schedules/schedules-data.ts b/frontend/app/agent/schedules/schedules-data.ts index f065fb326..d2b21872b 100644 --- a/frontend/app/agent/schedules/schedules-data.ts +++ b/frontend/app/agent/schedules/schedules-data.ts @@ -131,9 +131,297 @@ 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 matching mirrored from the scheduler (`backend/src/schedules/cron.ts`) + * so the next run shown here is the one the scheduler will actually fire: + * same field syntax (lists, ranges, steps), same day-of-month/day-of-week + * rule, same timezone handling. Keep the two in step. + */ +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 zone a schedule's wall clock lives in: the routine's own zone, or the + * viewer's zone when the routine carries none (the scheduler falls back to + * its local zone the same way). An unknown zone also falls back. + */ +function resolveZone(timezone: string | null): string { + const fallback = Intl.DateTimeFormat().resolvedOptions().timeZone; + if (!timezone) return fallback; + try { + new Intl.DateTimeFormat("en-US", { timeZone: timezone }); + return timezone; + } catch { + return fallback; + } +} + +function cronMatchesAt(parsed: ParsedCron, date: Date, timeZone: string): boolean { + const wc = zonedParts(date, timeZone); + if (!parsed.minute.has(wc.minute)) return false; + if (!parsed.hour.has(wc.hour)) return false; + if (!parsed.month.has(wc.month)) return false; + + const domMatch = parsed.dom.has(wc.day); + const dowMatch = parsed.dow.has(wc.weekday); + + // Vixie cron: when both day fields are restricted the day matches if + // either does; otherwise a restricted field must match. + if (parsed.domRestricted && parsed.dowRestricted) { + return domMatch || dowMatch; + } + return domMatch && dowMatch; +} + +/** Milliseconds the given zone is ahead of UTC at `instant`. */ +function zoneOffsetMs(instant: number, timeZone: string): number { + const wc = zonedParts(new Date(instant), timeZone); + const asUtc = Date.UTC(wc.year, wc.month - 1, wc.day, wc.hour, wc.minute, wc.second); + return asUtc - Math.floor(instant / 1000) * 1000; +} + +/** The UTC instant of a wall-clock time in `timeZone`. */ +function zonedTimeToUtc(year: number, month: number, day: number, hour: number, minute: number, timeZone: string): number { + const guess = Date.UTC(year, month - 1, day, hour, minute); + let utc = guess - zoneOffsetMs(guess, timeZone); + // The offset can change between the guess and the answer (DST switch); + // one correction lands on the right instant for real zones. + const corrected = guess - zoneOffsetMs(utc, timeZone); + if (corrected !== utc) utc = corrected; + return utc; +} + +/** + * The next instant `cron` fires at or after the current minute, following + * the scheduler's own rules in the schedule's timezone; null when the + * expression is invalid or never fires within four years (February 30). + */ +export function nextRunAt(cron: string, timezone: string | null, from: Date = new Date()): Date | null { + const parsed = parseCron(cron); + if (!parsed) return null; + const zone = resolveZone(timezone); + + // The scheduler ticks on minute marks; the next run is the first matching + // mark, counting the current minute when `from` sits exactly on it. + let start = new Date(from); + start.setSeconds(0, 0); + if (start.getTime() < from.getTime()) start = new Date(start.getTime() + 60_000); + const startMs = start.getTime(); + + const hours = [...parsed.hour].sort((a, b) => a - b); + const minutes = [...parsed.minute].sort((a, b) => a - b); + + const first = zonedParts(start, zone); + let cursor = Date.UTC(first.year, first.month - 1, first.day); + // Four years of days covers the longest real gap (February 29). + for (let dayOffset = 0; dayOffset <= 1466; dayOffset++) { + const date = new Date(cursor); + const year = date.getUTCFullYear(); + const month = date.getUTCMonth() + 1; + const day = date.getUTCDate(); + const weekday = date.getUTCDay(); + + if (parsed.month.has(month)) { + const domMatch = parsed.dom.has(day); + const dowMatch = parsed.dow.has(weekday); + const dayMatches = parsed.domRestricted && parsed.dowRestricted ? domMatch || dowMatch : domMatch && dowMatch; + if (dayMatches) { + for (const hour of hours) { + for (const minute of minutes) { + let candidate = zonedTimeToUtc(year, month, day, hour, minute, zone); + if (candidate < startMs) continue; + // A wall time in the fall-back hour happens twice; the earlier + // occurrence is the one that comes next. + const earlier = candidate - 3_600_000; + if (earlier >= startMs) { + const earlierParts = zonedParts(new Date(earlier), zone); + if (earlierParts.hour === hour && earlierParts.minute === minute && cronMatchesAt(parsed, new Date(earlier), zone)) { + candidate = earlier; + } + } + if (cronMatchesAt(parsed, new Date(candidate), zone)) { + return new Date(candidate); + } + } + } + } + } + cursor += 86_400_000; + } + return null; +} + +/** The next run in plain words: "Tuesday at 9:00 AM", in the schedule's timezone. */ +export function nextRunLabel(cron: string, timezone: string | null, from: Date = new Date()): string | null { + const at = nextRunAt(cron, timezone, from); + if (!at) return null; + const wc = zonedParts(at, resolveZone(timezone)); + return `${WEEKDAY_NAMES[wc.weekday]} at ${clockLabel(wc.hour, wc.minute)}`; } 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..5f190b014 100644 --- a/frontend/components/automations/automation-data.test.ts +++ b/frontend/components/automations/automation-data.test.ts @@ -4,6 +4,9 @@ import { automationEngineOptions, cadenceLabel, engineLabel, + isValidCron, + nextRunAt, + nextRunLabel, reconcileAutomationEngine, scheduleZone, } from "@/app/agent/schedules/schedules-data"; @@ -56,3 +59,60 @@ 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("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("finds the next run for custom schedules", () => { + // Tuesdays and Thursdays at 8:30; from Monday midnight, Tuesday is next. + expect(nextRunAt("30 8 * * 2,4", "UTC", new Date("2026-10-12T00:00:00Z"))?.toISOString()).toBe("2026-10-13T08:30:00.000Z"); + // Every 30 minutes. + expect(nextRunAt("*/30 * * * *", "UTC", new Date("2026-10-13T10:07:00Z"))?.toISOString()).toBe("2026-10-13T10:30:00.000Z"); + // Weekdays at 9:00; Friday's run has already gone, so Monday is next. + expect(nextRunAt("0 9 * * 1-5", "UTC", new Date("2026-10-16T10:00:00Z"))?.toISOString()).toBe("2026-10-19T09:00:00.000Z"); + // Day-of-month OR day-of-week, like the scheduler: the 15th beats Monday. + expect(nextRunAt("0 0 15 * 1", "UTC", new Date("2026-10-14T00:00:00Z"))?.toISOString()).toBe("2026-10-15T00:00:00.000Z"); + }); + + test("honours the routine's timezone, including which day it is there", () => { + // 9:00 in New York is 13:00 UTC in October; from 12:00 UTC it is today. + expect(nextRunAt("0 9 * * *", "America/New_York", new Date("2026-10-13T12:00:00Z"))?.toISOString()).toBe("2026-10-13T13:00:00.000Z"); + expect(nextRunLabel("0 9 * * *", "America/New_York", new Date("2026-10-13T12:00:00Z"))).toBe("Tuesday at 9:00 AM"); + // Tuesdays 9:00 in Auckland: from Monday 00:00 UTC it is already Monday + // lunchtime there, and the run lands on Monday in UTC terms. + expect(nextRunAt("0 9 * * 2", "Pacific/Auckland", new Date("2026-10-12T00:00:00Z"))?.toISOString()).toBe("2026-10-12T20:00:00.000Z"); + expect(nextRunLabel("0 9 * * 2", "Pacific/Auckland", new Date("2026-10-12T00:00:00Z"))).toBe("Tuesday at 9:00 AM"); + }); + + test("labels the next run with a day and a time", () => { + expect(nextRunLabel("0 9 * * *", "UTC", new Date("2026-10-13T00:00:00Z"))).toBe("Tuesday at 9:00 AM"); + expect(nextRunLabel("30 8 * * 2,4", "UTC", new Date("2026-10-12T00:00:00Z"))).toBe("Tuesday at 8:30 AM"); + }); + + test("has no next run to show for invalid or impossible expressions", () => { + expect(nextRunAt("0 9 * *", "UTC", new Date("2026-10-13T00:00:00Z"))).toBeNull(); + expect(nextRunLabel("0 9 * *", "UTC", new Date("2026-10-13T00:00:00Z"))).toBeNull(); + // February 30 never fires. + expect(nextRunAt("0 0 30 2 *", "UTC", new Date("2026-10-13T00:00:00Z"))).toBeNull(); + // But February 29 does, given a long enough look ahead. + expect(nextRunAt("0 12 29 2 *", "UTC", new Date("2027-03-01T00:00:00Z"))?.toISOString()).toBe("2028-02-29T12:00:00.000Z"); + }); +}); diff --git a/frontend/components/bots/routine-form.tsx b/frontend/components/bots/routine-form.tsx index 0f8afff25..4b7226e68 100644 --- a/frontend/components/bots/routine-form.tsx +++ b/frontend/components/bots/routine-form.tsx @@ -1,6 +1,7 @@ "use client"; import { useId, useRef, useState } from "react"; +import { cadenceLabel, isValidCron, nextRunLabel } 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"; @@ -39,6 +40,18 @@ export function RoutineForm({ bot, routine, onSaved, onCancel }: { bot: ApiBot; const [error, setError] = useState(null); const timezone = routine ? routine.timezone : browserTimezone(); + /** 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; + const nextRun = cronValid ? nextRunLabel(cronText, timezone) : null; + 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 })); const submit = async () => { @@ -138,6 +151,9 @@ export function RoutineForm({ bot, routine, onSaved, onCancel }: { bot: ApiBot; hint={invalid.cron ?? (timezone ? `Times in ${timezone}.` : "Times in the server's timezone.")} fieldClassName="font-mono" /> + {preview && ( +

{preview}

+ )} {error && (

diff --git a/frontend/components/bots/routines-section.tsx b/frontend/components/bots/routines-section.tsx index 2fdb40300..46a8c3ff0 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, nextRunLabel } 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, by the scheduler's own rules; paused routines do not fire. */ + const nextRun = routine.enabled && now ? nextRunLabel(routine.cron, routine.timezone, new Date(now)) : null; + const guard = async (kind: "toggle" | "run" | "delete", work: () => Promise) => { if (busy) return; setBusy(kind); @@ -81,6 +84,7 @@ function RoutineRow({ {cadenceLabel(routine.cron)} {routine.timezone ? ` (${routine.timezone})` : ""} {routine.lastFiredAt ? ` · last run ${relativeTime(routine.lastFiredAt, now) || "just now"}` : " · never run"} + {nextRun ? ` · next run ${nextRun}` : ""}

Date: Sat, 17 Oct 2026 18:40:06 -0400 Subject: [PATCH 2/3] Fix fall-back next-run preview, stop guessing the zone for server-timezone routines --- .../app/agent/schedules/schedules-data.ts | 133 +++++++++++++----- .../automations/automation-data.test.ts | 35 +++++ frontend/components/bots/routine-form.tsx | 10 +- 3 files changed, 145 insertions(+), 33 deletions(-) diff --git a/frontend/app/agent/schedules/schedules-data.ts b/frontend/app/agent/schedules/schedules-data.ts index d2b21872b..5d77a04d4 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; @@ -306,18 +309,22 @@ function zonedParts(date: Date, timeZone: string): ZonedParts { } /** - * The zone a schedule's wall clock lives in: the routine's own zone, or the - * viewer's zone when the routine carries none (the scheduler falls back to - * its local zone the same way). An unknown zone also falls back. + * The zone a schedule's wall clock lives in, when the browser can know it: + * the routine's own zone. A routine with no zone runs in the scheduler's + * server-local time (the backend's `cronMatches` does the same), and the + * server's zone is not exposed to the frontend, so there is no honest zone + * to resolve to; substituting the viewer's zone would quietly disagree + * with the server. An unknown zone name lands in the same place, because + * the backend falls back to server local time for it. Null therefore means + * no next run can be computed here. */ -function resolveZone(timezone: string | null): string { - const fallback = Intl.DateTimeFormat().resolvedOptions().timeZone; - if (!timezone) return fallback; +function resolveZone(timezone: string | null): string | null { + if (!timezone) return null; try { new Intl.DateTimeFormat("en-US", { timeZone: timezone }); return timezone; } catch { - return fallback; + return null; } } @@ -345,26 +352,69 @@ function zoneOffsetMs(instant: number, timeZone: string): number { return asUtc - Math.floor(instant / 1000) * 1000; } -/** The UTC instant of a wall-clock time in `timeZone`. */ -function zonedTimeToUtc(year: number, month: number, day: number, hour: number, minute: number, timeZone: string): number { +/** + * Every UTC instant whose wall clock in `timeZone` reads the given time: + * exactly one normally, two when the time repeats at a fall-back switch, + * none when it is skipped at a spring-forward switch. A single fixed-point + * conversion can only ever land on one occurrence, and which one depends + * on the offset beside the switch (and on the shift being one hour, which + * Lord Howe's 30-minute shift is not). So sample the offsets in effect + * around the time - real zones run from -12h to +14h, and a switch beside + * this wall time sits inside that span - and keep the instants that read + * back as the requested time. + */ +function zonedTimeOccurrences( + year: number, + month: number, + day: number, + hour: number, + minute: number, + timeZone: string, +): number[] { const guess = Date.UTC(year, month - 1, day, hour, minute); - let utc = guess - zoneOffsetMs(guess, timeZone); - // The offset can change between the guess and the answer (DST switch); - // one correction lands on the right instant for real zones. - const corrected = guess - zoneOffsetMs(utc, timeZone); - if (corrected !== utc) utc = corrected; - return utc; + const offsets = new Set(); + for (let probe = guess - 54_000_000; probe <= guess + 54_000_000; probe += 10_800_000) { + offsets.add(zoneOffsetMs(probe, timeZone)); + } + const occurrences: number[] = []; + for (const offset of offsets) { + const utc = guess - offset; + const wc = zonedParts(new Date(utc), timeZone); + if ( + wc.year === year && + wc.month === month && + wc.day === day && + wc.hour === hour && + wc.minute === minute && + !occurrences.includes(utc) + ) { + occurrences.push(utc); + } + } + return occurrences.sort((a, b) => a - b); } +/** + * Next-run results by (cron, zone, start minute). Rows re-render on a + * timer and the form re-renders per keystroke; without this, an + * every-minute schedule rescans on every render. An entry is keyed on the + * truncated start minute, so it is only ever reused inside the minute it + * was computed for. + */ +const nextRunCache = new Map(); + /** * The next instant `cron` fires at or after the current minute, following * the scheduler's own rules in the schedule's timezone; null when the - * expression is invalid or never fires within four years (February 30). + * expression is invalid, when the schedule runs in the server's timezone + * (which the browser cannot know), or when it never fires within four + * years (February 30). */ export function nextRunAt(cron: string, timezone: string | null, from: Date = new Date()): Date | null { const parsed = parseCron(cron); if (!parsed) return null; const zone = resolveZone(timezone); + if (!zone) return null; // The scheduler ticks on minute marks; the next run is the first matching // mark, counting the current minute when `from` sits exactly on it. @@ -373,13 +423,27 @@ export function nextRunAt(cron: string, timezone: string | null, from: Date = ne if (start.getTime() < from.getTime()) start = new Date(start.getTime() + 60_000); const startMs = start.getTime(); + const key = `${cron}\n${zone}\n${startMs}`; + const cached = nextRunCache.get(key); + if (cached !== undefined) return cached ? new Date(cached.getTime()) : null; + + const result = scanNextRun(parsed, zone, startMs); + if (nextRunCache.size >= 256) nextRunCache.clear(); + nextRunCache.set(key, result ? new Date(result.getTime()) : null); + return result; +} + +/** The day-by-day scan behind `nextRunAt`, uncached. */ +function scanNextRun(parsed: ParsedCron, zone: string, startMs: number): Date | null { const hours = [...parsed.hour].sort((a, b) => a - b); const minutes = [...parsed.minute].sort((a, b) => a - b); - const first = zonedParts(start, zone); - let cursor = Date.UTC(first.year, first.month - 1, first.day); + const first = zonedParts(new Date(startMs), zone); + // Start one wall day early: a fall-back switch at midnight reads as the + // previous day again, so the next run can carry yesterday's date there. + let cursor = Date.UTC(first.year, first.month - 1, first.day) - 86_400_000; // Four years of days covers the longest real gap (February 29). - for (let dayOffset = 0; dayOffset <= 1466; dayOffset++) { + for (let dayOffset = 0; dayOffset <= 1467; dayOffset++) { const date = new Date(cursor); const year = date.getUTCFullYear(); const month = date.getUTCMonth() + 1; @@ -391,24 +455,27 @@ export function nextRunAt(cron: string, timezone: string | null, from: Date = ne const dowMatch = parsed.dow.has(weekday); const dayMatches = parsed.domRestricted && parsed.dowRestricted ? domMatch || dowMatch : domMatch && dowMatch; if (dayMatches) { + // The day's earliest instant wins, not the first in wall order: + // beside a fall-back switch a later wall time can hold the earlier + // instant (Berlin 2:45 CEST precedes the repeated 2:00 CET). + let earliest: number | null = null; for (const hour of hours) { for (const minute of minutes) { - let candidate = zonedTimeToUtc(year, month, day, hour, minute, zone); - if (candidate < startMs) continue; - // A wall time in the fall-back hour happens twice; the earlier - // occurrence is the one that comes next. - const earlier = candidate - 3_600_000; - if (earlier >= startMs) { - const earlierParts = zonedParts(new Date(earlier), zone); - if (earlierParts.hour === hour && earlierParts.minute === minute && cronMatchesAt(parsed, new Date(earlier), zone)) { - candidate = earlier; + const guess = Date.UTC(year, month - 1, day, hour, minute); + // A wall time whose latest possible occurrence (zones run from + // -12h to +14h) is already past cannot be next; skip it without + // resolving its occurrences. + if (guess + 43_200_000 < startMs) continue; + for (const candidate of zonedTimeOccurrences(year, month, day, hour, minute, zone)) { + if (candidate < startMs) continue; + if (earliest !== null && candidate >= earliest) continue; + if (cronMatchesAt(parsed, new Date(candidate), zone)) { + earliest = candidate; } } - if (cronMatchesAt(parsed, new Date(candidate), zone)) { - return new Date(candidate); - } } } + if (earliest !== null) return new Date(earliest); } } cursor += 86_400_000; @@ -420,7 +487,9 @@ export function nextRunAt(cron: string, timezone: string | null, from: Date = ne export function nextRunLabel(cron: string, timezone: string | null, from: Date = new Date()): string | null { const at = nextRunAt(cron, timezone, from); if (!at) return null; - const wc = zonedParts(at, resolveZone(timezone)); + const zone = resolveZone(timezone); + if (!zone) return null; + const wc = zonedParts(at, zone); return `${WEEKDAY_NAMES[wc.weekday]} at ${clockLabel(wc.hour, wc.minute)}`; } diff --git a/frontend/components/automations/automation-data.test.ts b/frontend/components/automations/automation-data.test.ts index 5f190b014..898df0818 100644 --- a/frontend/components/automations/automation-data.test.ts +++ b/frontend/components/automations/automation-data.test.ts @@ -70,6 +70,12 @@ describe("plain-words schedules and next run", () => { 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); @@ -102,6 +108,35 @@ describe("plain-words schedules and next run", () => { expect(nextRunLabel("0 9 * * 2", "Pacific/Auckland", new Date("2026-10-12T00:00:00Z"))).toBe("Tuesday at 9:00 AM"); }); + test("agrees with the scheduler across the fall-back switch", () => { + // New York falls back on Nov 1, 2026: 1:30 happens at 05:30 UTC (EDT) + // and again at 06:30 UTC (EST). From 05:45 UTC the second one is next; + // from before both, the first one is. + expect(nextRunAt("30 1 * * *", "America/New_York", new Date("2026-11-01T05:45:00Z"))?.toISOString()).toBe("2026-11-01T06:30:00.000Z"); + expect(nextRunAt("30 1 * * *", "America/New_York", new Date("2026-11-01T04:00:00Z"))?.toISOString()).toBe("2026-11-01T05:30:00.000Z"); + // Berlin falls back on Oct 25, 2026: 2:45 CEST (00:45 UTC) precedes the + // repeated 2:00 CET (01:00 UTC), even though 2:00 reads earlier. + expect(nextRunAt("0,45 2 * * *", "Europe/Berlin", new Date("2026-10-25T00:30:00Z"))?.toISOString()).toBe("2026-10-25T00:45:00.000Z"); + // Lord Howe shifts by only 30 minutes: 1:45 on Apr 5, 2026 happens at + // 14:45 UTC (+11:00) and again at 15:15 UTC (+10:30) on Apr 4. + expect(nextRunAt("45 1 * * *", "Australia/Lord_Howe", new Date("2026-04-04T14:00:00Z"))?.toISOString()).toBe("2026-04-04T14:45:00.000Z"); + }); + + test("skips wall times that never happen at the spring-forward switch", () => { + // 2:30 does not exist in New York on Mar 8, 2026; the run is Mar 9. + expect(nextRunAt("30 2 * * *", "America/New_York", new Date("2026-03-08T00:00:00Z"))?.toISOString()).toBe("2026-03-09T06:30:00.000Z"); + }); + + test("shows no next run for server-timezone schedules", () => { + // A schedule with no timezone runs in the server's local time, which + // the browser cannot know; guessing the viewer's zone would disagree + // with the scheduler, so there is no label to show. + expect(nextRunAt("0 9 * * *", null, new Date("2026-10-13T00:00:00Z"))).toBeNull(); + expect(nextRunLabel("0 9 * * *", null, new Date("2026-10-13T00:00:00Z"))).toBeNull(); + // An unknown zone name means server local time on the backend too. + expect(nextRunAt("0 9 * * *", "Not/AZone", new Date("2026-10-13T00:00:00Z"))).toBeNull(); + }); + test("labels the next run with a day and a time", () => { expect(nextRunLabel("0 9 * * *", "UTC", new Date("2026-10-13T00:00:00Z"))).toBe("Tuesday at 9:00 AM"); expect(nextRunLabel("30 8 * * 2,4", "UTC", new Date("2026-10-12T00:00:00Z"))).toBe("Tuesday at 8:30 AM"); diff --git a/frontend/components/bots/routine-form.tsx b/frontend/components/bots/routine-form.tsx index 4b7226e68..47c33719c 100644 --- a/frontend/components/bots/routine-form.tsx +++ b/frontend/components/bots/routine-form.tsx @@ -29,6 +29,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); @@ -150,9 +151,16 @@ export function RoutineForm({ bot, routine, onSaved, onCancel }: { bot: ApiBot; isInvalid={Boolean(invalid.cron)} hint={invalid.cron ?? (timezone ? `Times in ${timezone}.` : "Times in the server's timezone.")} fieldClassName="font-mono" + aria-describedby={preview ? previewId : undefined} /> {preview && ( -

{preview}

+

+ {preview} +

)} {error && ( From 6d4ce808ca48f3cbb17cef3126b6bcb3fcad32ad Mon Sep 17 00:00:00 2001 From: Ryan Cotten Date: Sun, 18 Oct 2026 13:24:21 -0400 Subject: [PATCH 3/3] Read the next run from the server, drop the client-side cron math --- frontend/app/agent/schedules/schedules-api.ts | 18 ++ .../app/agent/schedules/schedules-data.ts | 197 ++---------------- .../automations/automation-data.test.ts | 77 ++----- .../automations/schedules-api.test.ts | 23 ++ frontend/components/bots/routine-form.tsx | 34 ++- frontend/components/bots/routines-section.tsx | 8 +- frontend/components/bots/types.ts | 5 +- 7 files changed, 110 insertions(+), 252 deletions(-) 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 5d77a04d4..57ee5cc33 100644 --- a/frontend/app/agent/schedules/schedules-data.ts +++ b/frontend/app/agent/schedules/schedules-data.ts @@ -171,10 +171,10 @@ function weekdaySet(field: string): number[] | null { } /** - * Cron matching mirrored from the scheduler (`backend/src/schedules/cron.ts`) - * so the next run shown here is the one the scheduler will actually fire: - * same field syntax (lists, ranges, steps), same day-of-month/day-of-week - * rule, same timezone handling. Keep the two in step. + * 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; @@ -309,190 +309,25 @@ function zonedParts(date: Date, timeZone: string): ZonedParts { } /** - * The zone a schedule's wall clock lives in, when the browser can know it: - * the routine's own zone. A routine with no zone runs in the scheduler's - * server-local time (the backend's `cronMatches` does the same), and the - * server's zone is not exposed to the frontend, so there is no honest zone - * to resolve to; substituting the viewer's zone would quietly disagree - * with the server. An unknown zone name lands in the same place, because - * the backend falls back to server local time for it. Null therefore means - * no next run can be computed here. + * 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. */ -function resolveZone(timezone: string | null): string | null { - if (!timezone) return null; +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 { - new Intl.DateTimeFormat("en-US", { timeZone: timezone }); - return timezone; + const wc = zonedParts(at, timezone); + return `${WEEKDAY_NAMES[wc.weekday]} at ${clockLabel(wc.hour, wc.minute)}`; } catch { return null; } } -function cronMatchesAt(parsed: ParsedCron, date: Date, timeZone: string): boolean { - const wc = zonedParts(date, timeZone); - if (!parsed.minute.has(wc.minute)) return false; - if (!parsed.hour.has(wc.hour)) return false; - if (!parsed.month.has(wc.month)) return false; - - const domMatch = parsed.dom.has(wc.day); - const dowMatch = parsed.dow.has(wc.weekday); - - // Vixie cron: when both day fields are restricted the day matches if - // either does; otherwise a restricted field must match. - if (parsed.domRestricted && parsed.dowRestricted) { - return domMatch || dowMatch; - } - return domMatch && dowMatch; -} - -/** Milliseconds the given zone is ahead of UTC at `instant`. */ -function zoneOffsetMs(instant: number, timeZone: string): number { - const wc = zonedParts(new Date(instant), timeZone); - const asUtc = Date.UTC(wc.year, wc.month - 1, wc.day, wc.hour, wc.minute, wc.second); - return asUtc - Math.floor(instant / 1000) * 1000; -} - -/** - * Every UTC instant whose wall clock in `timeZone` reads the given time: - * exactly one normally, two when the time repeats at a fall-back switch, - * none when it is skipped at a spring-forward switch. A single fixed-point - * conversion can only ever land on one occurrence, and which one depends - * on the offset beside the switch (and on the shift being one hour, which - * Lord Howe's 30-minute shift is not). So sample the offsets in effect - * around the time - real zones run from -12h to +14h, and a switch beside - * this wall time sits inside that span - and keep the instants that read - * back as the requested time. - */ -function zonedTimeOccurrences( - year: number, - month: number, - day: number, - hour: number, - minute: number, - timeZone: string, -): number[] { - const guess = Date.UTC(year, month - 1, day, hour, minute); - const offsets = new Set(); - for (let probe = guess - 54_000_000; probe <= guess + 54_000_000; probe += 10_800_000) { - offsets.add(zoneOffsetMs(probe, timeZone)); - } - const occurrences: number[] = []; - for (const offset of offsets) { - const utc = guess - offset; - const wc = zonedParts(new Date(utc), timeZone); - if ( - wc.year === year && - wc.month === month && - wc.day === day && - wc.hour === hour && - wc.minute === minute && - !occurrences.includes(utc) - ) { - occurrences.push(utc); - } - } - return occurrences.sort((a, b) => a - b); -} - -/** - * Next-run results by (cron, zone, start minute). Rows re-render on a - * timer and the form re-renders per keystroke; without this, an - * every-minute schedule rescans on every render. An entry is keyed on the - * truncated start minute, so it is only ever reused inside the minute it - * was computed for. - */ -const nextRunCache = new Map(); - -/** - * The next instant `cron` fires at or after the current minute, following - * the scheduler's own rules in the schedule's timezone; null when the - * expression is invalid, when the schedule runs in the server's timezone - * (which the browser cannot know), or when it never fires within four - * years (February 30). - */ -export function nextRunAt(cron: string, timezone: string | null, from: Date = new Date()): Date | null { - const parsed = parseCron(cron); - if (!parsed) return null; - const zone = resolveZone(timezone); - if (!zone) return null; - - // The scheduler ticks on minute marks; the next run is the first matching - // mark, counting the current minute when `from` sits exactly on it. - let start = new Date(from); - start.setSeconds(0, 0); - if (start.getTime() < from.getTime()) start = new Date(start.getTime() + 60_000); - const startMs = start.getTime(); - - const key = `${cron}\n${zone}\n${startMs}`; - const cached = nextRunCache.get(key); - if (cached !== undefined) return cached ? new Date(cached.getTime()) : null; - - const result = scanNextRun(parsed, zone, startMs); - if (nextRunCache.size >= 256) nextRunCache.clear(); - nextRunCache.set(key, result ? new Date(result.getTime()) : null); - return result; -} - -/** The day-by-day scan behind `nextRunAt`, uncached. */ -function scanNextRun(parsed: ParsedCron, zone: string, startMs: number): Date | null { - const hours = [...parsed.hour].sort((a, b) => a - b); - const minutes = [...parsed.minute].sort((a, b) => a - b); - - const first = zonedParts(new Date(startMs), zone); - // Start one wall day early: a fall-back switch at midnight reads as the - // previous day again, so the next run can carry yesterday's date there. - let cursor = Date.UTC(first.year, first.month - 1, first.day) - 86_400_000; - // Four years of days covers the longest real gap (February 29). - for (let dayOffset = 0; dayOffset <= 1467; dayOffset++) { - const date = new Date(cursor); - const year = date.getUTCFullYear(); - const month = date.getUTCMonth() + 1; - const day = date.getUTCDate(); - const weekday = date.getUTCDay(); - - if (parsed.month.has(month)) { - const domMatch = parsed.dom.has(day); - const dowMatch = parsed.dow.has(weekday); - const dayMatches = parsed.domRestricted && parsed.dowRestricted ? domMatch || dowMatch : domMatch && dowMatch; - if (dayMatches) { - // The day's earliest instant wins, not the first in wall order: - // beside a fall-back switch a later wall time can hold the earlier - // instant (Berlin 2:45 CEST precedes the repeated 2:00 CET). - let earliest: number | null = null; - for (const hour of hours) { - for (const minute of minutes) { - const guess = Date.UTC(year, month - 1, day, hour, minute); - // A wall time whose latest possible occurrence (zones run from - // -12h to +14h) is already past cannot be next; skip it without - // resolving its occurrences. - if (guess + 43_200_000 < startMs) continue; - for (const candidate of zonedTimeOccurrences(year, month, day, hour, minute, zone)) { - if (candidate < startMs) continue; - if (earliest !== null && candidate >= earliest) continue; - if (cronMatchesAt(parsed, new Date(candidate), zone)) { - earliest = candidate; - } - } - } - } - if (earliest !== null) return new Date(earliest); - } - } - cursor += 86_400_000; - } - return null; -} - -/** The next run in plain words: "Tuesday at 9:00 AM", in the schedule's timezone. */ -export function nextRunLabel(cron: string, timezone: string | null, from: Date = new Date()): string | null { - const at = nextRunAt(cron, timezone, from); - if (!at) return null; - const zone = resolveZone(timezone); - if (!zone) return null; - const wc = zonedParts(at, zone); - return `${WEEKDAY_NAMES[wc.weekday]} at ${clockLabel(wc.hour, wc.minute)}`; -} - export function scheduleZone(schedule: Pick): string { return schedule.timezone ?? "Server timezone"; } diff --git a/frontend/components/automations/automation-data.test.ts b/frontend/components/automations/automation-data.test.ts index 898df0818..1f3cb38c5 100644 --- a/frontend/components/automations/automation-data.test.ts +++ b/frontend/components/automations/automation-data.test.ts @@ -5,8 +5,7 @@ import { cadenceLabel, engineLabel, isValidCron, - nextRunAt, - nextRunLabel, + nextRunLabelAt, reconcileAutomationEngine, scheduleZone, } from "@/app/agent/schedules/schedules-data"; @@ -87,67 +86,21 @@ describe("plain-words schedules and next run", () => { expect(isValidCron("")).toBe(false); }); - test("finds the next run for custom schedules", () => { - // Tuesdays and Thursdays at 8:30; from Monday midnight, Tuesday is next. - expect(nextRunAt("30 8 * * 2,4", "UTC", new Date("2026-10-12T00:00:00Z"))?.toISOString()).toBe("2026-10-13T08:30:00.000Z"); - // Every 30 minutes. - expect(nextRunAt("*/30 * * * *", "UTC", new Date("2026-10-13T10:07:00Z"))?.toISOString()).toBe("2026-10-13T10:30:00.000Z"); - // Weekdays at 9:00; Friday's run has already gone, so Monday is next. - expect(nextRunAt("0 9 * * 1-5", "UTC", new Date("2026-10-16T10:00:00Z"))?.toISOString()).toBe("2026-10-19T09:00:00.000Z"); - // Day-of-month OR day-of-week, like the scheduler: the 15th beats Monday. - expect(nextRunAt("0 0 15 * 1", "UTC", new Date("2026-10-14T00:00:00Z"))?.toISOString()).toBe("2026-10-15T00:00:00.000Z"); + 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("honours the routine's timezone, including which day it is there", () => { - // 9:00 in New York is 13:00 UTC in October; from 12:00 UTC it is today. - expect(nextRunAt("0 9 * * *", "America/New_York", new Date("2026-10-13T12:00:00Z"))?.toISOString()).toBe("2026-10-13T13:00:00.000Z"); - expect(nextRunLabel("0 9 * * *", "America/New_York", new Date("2026-10-13T12:00:00Z"))).toBe("Tuesday at 9:00 AM"); - // Tuesdays 9:00 in Auckland: from Monday 00:00 UTC it is already Monday - // lunchtime there, and the run lands on Monday in UTC terms. - expect(nextRunAt("0 9 * * 2", "Pacific/Auckland", new Date("2026-10-12T00:00:00Z"))?.toISOString()).toBe("2026-10-12T20:00:00.000Z"); - expect(nextRunLabel("0 9 * * 2", "Pacific/Auckland", new Date("2026-10-12T00:00:00Z"))).toBe("Tuesday at 9:00 AM"); - }); - - test("agrees with the scheduler across the fall-back switch", () => { - // New York falls back on Nov 1, 2026: 1:30 happens at 05:30 UTC (EDT) - // and again at 06:30 UTC (EST). From 05:45 UTC the second one is next; - // from before both, the first one is. - expect(nextRunAt("30 1 * * *", "America/New_York", new Date("2026-11-01T05:45:00Z"))?.toISOString()).toBe("2026-11-01T06:30:00.000Z"); - expect(nextRunAt("30 1 * * *", "America/New_York", new Date("2026-11-01T04:00:00Z"))?.toISOString()).toBe("2026-11-01T05:30:00.000Z"); - // Berlin falls back on Oct 25, 2026: 2:45 CEST (00:45 UTC) precedes the - // repeated 2:00 CET (01:00 UTC), even though 2:00 reads earlier. - expect(nextRunAt("0,45 2 * * *", "Europe/Berlin", new Date("2026-10-25T00:30:00Z"))?.toISOString()).toBe("2026-10-25T00:45:00.000Z"); - // Lord Howe shifts by only 30 minutes: 1:45 on Apr 5, 2026 happens at - // 14:45 UTC (+11:00) and again at 15:15 UTC (+10:30) on Apr 4. - expect(nextRunAt("45 1 * * *", "Australia/Lord_Howe", new Date("2026-04-04T14:00:00Z"))?.toISOString()).toBe("2026-04-04T14:45:00.000Z"); - }); - - test("skips wall times that never happen at the spring-forward switch", () => { - // 2:30 does not exist in New York on Mar 8, 2026; the run is Mar 9. - expect(nextRunAt("30 2 * * *", "America/New_York", new Date("2026-03-08T00:00:00Z"))?.toISOString()).toBe("2026-03-09T06:30:00.000Z"); - }); - - test("shows no next run for server-timezone schedules", () => { - // A schedule with no timezone runs in the server's local time, which - // the browser cannot know; guessing the viewer's zone would disagree - // with the scheduler, so there is no label to show. - expect(nextRunAt("0 9 * * *", null, new Date("2026-10-13T00:00:00Z"))).toBeNull(); - expect(nextRunLabel("0 9 * * *", null, new Date("2026-10-13T00:00:00Z"))).toBeNull(); - // An unknown zone name means server local time on the backend too. - expect(nextRunAt("0 9 * * *", "Not/AZone", new Date("2026-10-13T00:00:00Z"))).toBeNull(); - }); - - test("labels the next run with a day and a time", () => { - expect(nextRunLabel("0 9 * * *", "UTC", new Date("2026-10-13T00:00:00Z"))).toBe("Tuesday at 9:00 AM"); - expect(nextRunLabel("30 8 * * 2,4", "UTC", new Date("2026-10-12T00:00:00Z"))).toBe("Tuesday at 8:30 AM"); - }); - - test("has no next run to show for invalid or impossible expressions", () => { - expect(nextRunAt("0 9 * *", "UTC", new Date("2026-10-13T00:00:00Z"))).toBeNull(); - expect(nextRunLabel("0 9 * *", "UTC", new Date("2026-10-13T00:00:00Z"))).toBeNull(); - // February 30 never fires. - expect(nextRunAt("0 0 30 2 *", "UTC", new Date("2026-10-13T00:00:00Z"))).toBeNull(); - // But February 29 does, given a long enough look ahead. - expect(nextRunAt("0 12 29 2 *", "UTC", new Date("2027-03-01T00:00:00Z"))?.toISOString()).toBe("2028-02-29T12:00:00.000Z"); + 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 47c33719c..e168a67c3 100644 --- a/frontend/components/bots/routine-form.tsx +++ b/frontend/components/bots/routine-form.tsx @@ -1,7 +1,8 @@ "use client"; -import { useId, useRef, useState } from "react"; -import { cadenceLabel, isValidCron, nextRunLabel } from "@/app/agent/schedules/schedules-data"; +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"; @@ -40,12 +41,37 @@ 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; - const nextRun = cronValid ? nextRunLabel(cronText, timezone) : 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 @@ -149,7 +175,7 @@ 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} /> diff --git a/frontend/components/bots/routines-section.tsx b/frontend/components/bots/routines-section.tsx index 46a8c3ff0..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, nextRunLabel } 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,8 +40,8 @@ function RoutineRow({ const [started, setStarted] = useState(null); const [error, setError] = useState(null); - /** When this routine fires next, by the scheduler's own rules; paused routines do not fire. */ - const nextRun = routine.enabled && now ? nextRunLabel(routine.cron, routine.timezone, new Date(now)) : 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; @@ -82,7 +82,7 @@ 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}` : ""}

diff --git a/frontend/components/bots/types.ts b/frontend/components/bots/types.ts index c8f9c91df..bc96061d6 100644 --- a/frontend/components/bots/types.ts +++ b/frontend/components/bots/types.ts @@ -43,10 +43,13 @@ export interface ApiRoutine { id: string; name: string; cron: string; - timezone: string | null; + /** Always set: a routine created without one runs in UTC. */ + timezone: string; prompt: string; enabled: boolean; lastFiredAt: string | null; + /** When the scheduler fires it next, from the server; null while paused or when the cron names no date. */ + nextDueAt: string | null; createdAt: string; }