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;
}