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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion apps/hub/test/cron-due.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,13 +89,21 @@ describe("isValidCronExpression (matcher and validator share one parser)", () =>
"0 0 32 * *",
"0 0 * 13 *",
"60 * * * *",
"* * * * 7",
"* * * * 8",
"10-5 * * * *",
]) {
expect(isValidCronExpression(expression)).toBe(false);
}
});

test("accepts 7 as Sunday on day-of-week", () => {
expect(isValidCronExpression("* * * * 7")).toBe(true);
// 2026-01-04 is a Sunday.
expect(
cronMatchesMinute("0 0 * * 7", new Date("2026-01-04T00:00:00Z")),
).toBe(true);
});

test("accepts the range-then-step idiom the matcher already understands", () => {
expect(isValidCronExpression("5-10/2 * * * *")).toBe(true);
expect(
Expand Down
72 changes: 43 additions & 29 deletions apps/web/src/routine-trigger.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,20 @@
// Renders a `RoutineTrigger` (the wire shape `@corbits/routines` defines
// in packages/routines/src/trigger.ts) into what the Routines page shows:
// a plain-language cadence and a best-effort next-run estimate. Kept
// pure and UTC-only — a `RoutineTrigger` carries no timezone, so neither
// does this. The raw-cron escape hatch has no closed-form "next
// occurrence" here (the scheduler itself resolves that minute by minute;
// see apps/hub/src/cron-due.ts) — it renders a plain description instead
// of a guessed timestamp, never a wrong one dressed up as exact.
// a plain-language cadence and a best-effort next-run estimate.
//
// The estimate for interval/daily/weekly presets is computed by
// Daily / weekly / cron may carry an optional IANA `timezone`; hour and
// minute are wall-clock in that zone (DST-correct). The estimate uses
// `nextCronFireAfter` from `@corbits/routines/cron` — the exact same
// minute-by-minute search the hub's own scheduler runs against the
// exact same rendered cron expression — never a second, hand-rolled
// matcher that could drift from what actually fires. That subpath (not
// the package's default export) is deliberate: the default export
// pulls in `drizzle-orm` and `postgres` through `store.ts`, which have
// no business in a browser bundle; `cron.ts` has zero imports and
// bundles cleanly on its own. An interval preset in particular fires
// on a wall-clock-aligned cadence (`*/N * * * *`), not N minutes after
// whatever moment a viewer happens to load the page — "every 10
// minutes" viewed at :07 fires at :10, four minutes away, not ten.
// minute-by-minute search the hub's scheduler runs against the exact
// same rendered cron expression — never a second, hand-rolled matcher
// that could drift from what actually fires. That subpath (not the
// package's default export) is deliberate: the default export pulls in
// `drizzle-orm` and `postgres` through `store.ts`, which have no business
// in a browser bundle; `cron.ts` has zero imports and bundles cleanly.
//
// An interval preset fires on a wall-clock-aligned cadence
// (`*/N * * * *`), not N minutes after whatever moment a viewer happens
// to load the page — "every 10 minutes" viewed at :07 fires at :10.
import { nextCronFireAfter } from "@corbits/routines/cron";
import type { RoutineTrigger } from "./routines-api";

Expand All @@ -36,6 +32,10 @@ function pad(value: number): string {
return value.toString().padStart(2, "0");
}

function zoneLabel(timezone: string | undefined): string {
return timezone === undefined || timezone === "UTC" ? "UTC" : timezone;
}

export function cadenceLabel(trigger: RoutineTrigger): string {
if (trigger === null) return "Manual";
switch (trigger.kind) {
Expand All @@ -44,11 +44,16 @@ export function cadenceLabel(trigger: RoutineTrigger): string {
? `Every ${trigger.unit === "minutes" ? "minute" : "hour"}`
: `Every ${String(trigger.every)} ${trigger.unit}`;
case "daily":
return `Daily at ${pad(trigger.hour)}:${pad(trigger.minute)} UTC`;
return `Daily at ${pad(trigger.hour)}:${pad(trigger.minute)} ${zoneLabel(trigger.timezone)}`;
case "weekly":
return `Weekly on ${WEEKDAY_NAMES[trigger.dayOfWeek]} at ${pad(trigger.hour)}:${pad(trigger.minute)} UTC`;
case "cron":
return `Cron: ${trigger.expression}`;
return `Weekly on ${WEEKDAY_NAMES[trigger.dayOfWeek]} at ${pad(trigger.hour)}:${pad(trigger.minute)} ${zoneLabel(trigger.timezone)}`;
case "cron": {
const zone =
trigger.timezone !== undefined && trigger.timezone !== "UTC"
? ` (${trigger.timezone})`
: "";
return `Cron: ${trigger.expression}${zone}`;
}
}
}

Expand All @@ -68,22 +73,31 @@ function cronExpressionForPreset(
}
}

function timezoneFor(trigger: Exclude<RoutineTrigger, null>): string {
if (trigger.kind === "interval") return "UTC";
return trigger.timezone ?? "UTC";
}

/**
* A best-effort next-fire estimate for display only — never fed back
* into a launch decision, which is the scheduler's job
* (apps/hub/src/routine-scheduler.ts) against the real clock. Returns
* `null` for a manual routine, a raw-cron trigger (no closed form
* without rendering it through the same package the hub already does),
* or the vanishingly unlikely case of no match inside
* `nextCronFireAfter`'s multi-year lookahead.
* into a launch decision, which is the scheduler's job against the real
* clock. Returns `null` for a manual routine, or when the expression
* has no fire inside the lookahead window.
*
* Raw cron is estimated the same way presets are: same package, same
* timezone semantics as the hub.
*/
export function approximateNextRun(
trigger: RoutineTrigger,
now: Date,
): Date | null {
if (trigger === null || trigger.kind === "cron") return null;
if (trigger === null) return null;
try {
return nextCronFireAfter(cronExpressionForPreset(trigger), now);
const expression =
trigger.kind === "cron"
? trigger.expression
: cronExpressionForPreset(trigger);
return nextCronFireAfter(expression, now, timezoneFor(trigger));
} catch {
return null;
}
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/routines-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,16 @@ export const RoutineTrigger = type({
kind: "'daily'",
hour: "0 <= number.integer <= 23",
minute: "0 <= number.integer <= 59",
"timezone?": "string",
})
.or({
kind: "'weekly'",
dayOfWeek: "0 <= number.integer <= 6",
hour: "0 <= number.integer <= 23",
minute: "0 <= number.integer <= 59",
"timezone?": "string",
})
.or({ kind: "'cron'", expression: "string" })
.or({ kind: "'cron'", expression: "string", "timezone?": "string" })
.or("null");
export type RoutineTrigger = typeof RoutineTrigger.infer;

Expand Down
57 changes: 45 additions & 12 deletions apps/web/test/routine-trigger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,23 @@ describe("cadenceLabel", () => {
);
});

test("daily trigger renders a UTC time", () => {
test("daily trigger renders a UTC time by default", () => {
expect(cadenceLabel({ kind: "daily", hour: 9, minute: 5 })).toBe(
"Daily at 09:05 UTC",
);
});

test("daily trigger names a non-UTC timezone", () => {
expect(
cadenceLabel({
kind: "daily",
hour: 9,
minute: 0,
timezone: "America/Los_Angeles",
}),
).toBe("Daily at 09:00 America/Los_Angeles");
});

test("weekly trigger names the weekday", () => {
expect(
cadenceLabel({ kind: "weekly", dayOfWeek: 1, hour: 7, minute: 30 }),
Expand All @@ -35,14 +46,29 @@ describe("cadenceLabel", () => {
"Cron: */5 * * * *",
);
});

test("cron trigger appends a non-UTC timezone", () => {
expect(
cadenceLabel({
kind: "cron",
expression: "0 9 * * *",
timezone: "Europe/London",
}),
).toBe("Cron: 0 9 * * * (Europe/London)");
});
});

describe("approximateNextRun", () => {
test("manual and cron triggers have no closed-form estimate", () => {
test("manual triggers have no estimate", () => {
expect(approximateNextRun(null, new Date())).toBeNull();
expect(
approximateNextRun({ kind: "cron", expression: "* * * * *" }, new Date()),
).toBeNull();
});

test("raw cron is estimated through the same package the hub uses", () => {
const next = approximateNextRun(
{ kind: "cron", expression: "0 9 * * *" },
new Date("2026-01-01T08:00:00Z"),
);
expect(next?.toISOString()).toBe("2026-01-01T09:00:00.000Z");
});

test("interval adds its step to now when now sits on a boundary", () => {
Expand All @@ -55,9 +81,6 @@ describe("approximateNextRun", () => {
});

test("interval is wall-clock aligned, not an offset from the viewing moment", () => {
// The routine fires on `*/10 * * * *` — minutes 0, 10, 20, ... Viewed
// at :07, the real next fire is :10 (three minutes away), never
// "ten minutes from now."
const now = new Date("2026-01-01T00:07:00Z");
const next = approximateNextRun(
{ kind: "interval", unit: "minutes", every: 10 },
Expand All @@ -67,8 +90,6 @@ describe("approximateNextRun", () => {
});

test("hourly interval is wall-clock aligned to the hour", () => {
// `0 */2 * * *` fires at hour 0, 2, 4, ... Viewed at 01:00, the next
// fire is 02:00, not 03:00 ("2 hours from now").
const now = new Date("2026-01-01T01:00:00Z");
const next = approximateNextRun(
{ kind: "interval", unit: "hours", every: 2 },
Expand All @@ -89,14 +110,26 @@ describe("approximateNextRun", () => {
expect(next?.toISOString()).toBe("2026-01-01T09:00:00.000Z");
});

test("daily with timezone uses local wall-clock (UTC storage)", () => {
const now = new Date("2026-01-15T12:00:00Z");
const next = approximateNextRun(
{
kind: "daily",
hour: 9,
minute: 0,
timezone: "America/Los_Angeles",
},
now,
);
expect(next?.toISOString()).toBe("2026-01-15T17:00:00.000Z");
});

test("weekly finds the next matching weekday", () => {
// 2026-01-01 is a Thursday (day 4).
const now = new Date("2026-01-01T00:00:00Z");
const next = approximateNextRun(
{ kind: "weekly", dayOfWeek: 1, hour: 9, minute: 0 },
now,
);
// Next Monday is 2026-01-05.
expect(next?.toISOString()).toBe("2026-01-05T09:00:00.000Z");
});
});
Loading
Loading