diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/AutomationDetailSidebar/AutomationDetailSidebar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/AutomationDetailSidebar/AutomationDetailSidebar.tsx index ae9ff26e9db..bcf42853e57 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/AutomationDetailSidebar/AutomationDetailSidebar.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/AutomationDetailSidebar/AutomationDetailSidebar.tsx @@ -2,9 +2,9 @@ import type { SelectAutomation, SelectAutomationRun, } from "@superset/db/schema"; +import { formatDateTimeInTimezone } from "@superset/shared/rrule"; import { cn } from "@superset/ui/utils"; import { useMutation } from "@tanstack/react-query"; -import { format } from "date-fns"; import { useEnabledAgents } from "renderer/hooks/useEnabledAgents"; import { apiTrpcClient } from "renderer/lib/api-trpc-client"; import { DevicePicker } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker"; @@ -81,13 +81,20 @@ export function AutomationDetailSidebar({ label="Next run" value={ automation.enabled && automation.nextRunAt - ? format(new Date(automation.nextRunAt), "MMM d, h:mm a") + ? formatDateTimeInTimezone( + new Date(automation.nextRunAt), + automation.timezone, + ) : "—" } /> diff --git a/packages/cli/src/commands/automations/create/command.ts b/packages/cli/src/commands/automations/create/command.ts index a63c5abe0bf..5dfd8a6faf0 100644 --- a/packages/cli/src/commands/automations/create/command.ts +++ b/packages/cli/src/commands/automations/create/command.ts @@ -7,6 +7,7 @@ import { resolveAgentConfigs, } from "@superset/shared/agent-settings"; import { command } from "../../../lib/command"; +import { formatAutomationDate } from "../format"; const DEFAULT_TIMEZONE = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; @@ -95,12 +96,9 @@ export default command({ mcpScope: [], }); - const nextRun = result.nextRunAt - ? new Date(result.nextRunAt).toISOString() - : "—"; return { data: result, - message: `Created automation "${result.name}" (${result.id})\nNext run: ${nextRun}`, + message: `Created automation "${result.name}" (${result.id})\nNext run: ${formatAutomationDate(result.nextRunAt, result.timezone)}`, }; }, }); diff --git a/packages/cli/src/commands/automations/format.ts b/packages/cli/src/commands/automations/format.ts new file mode 100644 index 00000000000..a4aab49374f --- /dev/null +++ b/packages/cli/src/commands/automations/format.ts @@ -0,0 +1,12 @@ +import { formatDateTimeInTimezone } from "@superset/shared/rrule"; + +export function formatAutomationDate( + value: Date | string | null | undefined, + timezone: string | null | undefined, +): string { + if (!value) return "—"; + const date = value instanceof Date ? value : new Date(value); + if (!Number.isFinite(date.getTime())) return "—"; + + return formatDateTimeInTimezone(date, timezone || "UTC"); +} diff --git a/packages/cli/src/commands/automations/list/command.ts b/packages/cli/src/commands/automations/list/command.ts index 77c891102e2..dc33e10eba1 100644 --- a/packages/cli/src/commands/automations/list/command.ts +++ b/packages/cli/src/commands/automations/list/command.ts @@ -1,5 +1,6 @@ import { table } from "@superset/cli-framework"; import { command } from "../../../lib/command"; +import { formatAutomationDate } from "../format"; export default command({ description: "List automations in the organization", @@ -14,7 +15,10 @@ export default command({ agent: (row.agentConfig as { id?: string } | null)?.id, schedule: row.scheduleText ?? row.rrule, enabled: row.enabled ? "yes" : "no", - nextRun: row.nextRunAt ?? "—", + nextRun: formatAutomationDate( + row.nextRunAt as Date | string | null | undefined, + row.timezone as string | null | undefined, + ), })), ["id", "name", "agent", "schedule", "enabled", "nextRun"], ["ID", "NAME", "AGENT", "SCHEDULE", "ENABLED", "NEXT RUN"], diff --git a/packages/cli/src/commands/automations/resume/command.ts b/packages/cli/src/commands/automations/resume/command.ts index 4cb36939924..408db550d8e 100644 --- a/packages/cli/src/commands/automations/resume/command.ts +++ b/packages/cli/src/commands/automations/resume/command.ts @@ -1,5 +1,6 @@ import { positional } from "@superset/cli-framework"; import { command } from "../../../lib/command"; +import { formatAutomationDate } from "../format"; export default command({ description: "Resume a paused automation", @@ -12,7 +13,7 @@ export default command({ }); return { data: result, - message: `Resumed automation ${id}. Next run: ${result.nextRunAt?.toISOString() ?? "—"}`, + message: `Resumed automation ${id}. Next run: ${formatAutomationDate(result.nextRunAt, result.timezone)}`, }; }, }); diff --git a/packages/cli/src/commands/automations/update/command.ts b/packages/cli/src/commands/automations/update/command.ts index 7dfe451d1c6..53cac30f26b 100644 --- a/packages/cli/src/commands/automations/update/command.ts +++ b/packages/cli/src/commands/automations/update/command.ts @@ -83,7 +83,7 @@ export default command({ timezone: options.timezone, dtstart: options.dtstart ? new Date(options.dtstart) : undefined, agentConfig, - targetHostId: options.device ?? null, + targetHostId: options.device, }); return { diff --git a/packages/shared/src/rrule.test.ts b/packages/shared/src/rrule.test.ts index 58089e5331b..a833c25660e 100644 --- a/packages/shared/src/rrule.test.ts +++ b/packages/shared/src/rrule.test.ts @@ -2,12 +2,32 @@ import { describe, expect, it } from "bun:test"; import { buildRrule, describeSchedule, + formatDateTimeInTimezone, matchPreset, + nextOccurrences, type PresetMatch, + parseRrule, } from "./rrule"; const US = { locale: "en-US" }; +function expectDateTimeParts( + formatted: string, + expected: { + month: string; + day: string; + year: string; + hour: string; + minute: string; + dayPeriod?: string; + timeZoneName: string; + }, +): void { + for (const value of Object.values(expected)) { + expect(formatted).toContain(value); + } +} + describe("describeSchedule / MINUTELY + HOURLY", () => { it("every minute", () => { expect(describeSchedule("FREQ=MINUTELY", US)).toBe("Every minute"); @@ -280,3 +300,97 @@ describe("matchPreset + buildRrule round-trip", () => { }); } }); + +describe("recurrence timezone math", () => { + it("computes daily wall-clock times as plain UTC Date instances", () => { + const next = parseRrule({ + rrule: "FREQ=DAILY;BYHOUR=6;BYMINUTE=0", + dtstart: new Date("2026-04-24T20:00:00.000Z"), + timezone: "America/Los_Angeles", + after: new Date("2026-04-25T00:00:00.000Z"), + }).nextRunAt; + + expect(next.constructor.name).toBe("Date"); + expect(next.toISOString()).toBe("2026-04-25T13:00:00.000Z"); + expectDateTimeParts( + formatDateTimeInTimezone(next, "America/Los_Angeles", { + locale: "en-US", + }), + { + month: "Apr", + day: "25", + year: "2026", + hour: "6", + minute: "00", + dayPeriod: "AM", + timeZoneName: "PDT", + }, + ); + }); + + it("keeps the same local time across daylight saving changes", () => { + const runs = nextOccurrences({ + rrule: "FREQ=DAILY;BYHOUR=6;BYMINUTE=0", + dtstart: new Date("2026-03-06T20:00:00.000Z"), + timezone: "America/Los_Angeles", + after: new Date("2026-03-07T00:00:00.000Z"), + count: 3, + }); + + expect(runs.map((run) => run.toISOString())).toEqual([ + "2026-03-07T14:00:00.000Z", + "2026-03-08T13:00:00.000Z", + "2026-03-09T13:00:00.000Z", + ]); + const formattedRuns = runs.map((run) => + formatDateTimeInTimezone(run, "America/Los_Angeles", { + locale: "en-US", + }), + ); + expectDateTimeParts(formattedRuns[0] ?? "", { + month: "Mar", + day: "7", + year: "2026", + hour: "6", + minute: "00", + dayPeriod: "AM", + timeZoneName: "PST", + }); + expectDateTimeParts(formattedRuns[1] ?? "", { + month: "Mar", + day: "8", + year: "2026", + hour: "6", + minute: "00", + dayPeriod: "AM", + timeZoneName: "PDT", + }); + expectDateTimeParts(formattedRuns[2] ?? "", { + month: "Mar", + day: "9", + year: "2026", + hour: "6", + minute: "00", + dayPeriod: "AM", + timeZoneName: "PDT", + }); + }); + + it("falls back to UTC formatting for invalid legacy timezone values", () => { + const formatted = formatDateTimeInTimezone( + new Date("2026-04-25T13:00:00.000Z"), + "Invalid/Timezone", + { locale: "en-US" }, + ); + + expectDateTimeParts(formatted, { + month: "Apr", + day: "25", + year: "2026", + hour: "1", + minute: "00", + dayPeriod: "PM", + timeZoneName: "UTC", + }); + }); +}); diff --git a/packages/shared/src/rrule.ts b/packages/shared/src/rrule.ts index 08246041dcb..5b809536aa1 100644 --- a/packages/shared/src/rrule.ts +++ b/packages/shared/src/rrule.ts @@ -6,10 +6,10 @@ * - compute real-UTC occurrences with correct DST behavior * (`parseRrule` / `nextOccurrenceAfter` / `nextOccurrences`) * - * rrule.js's `TZID` support returns Date objects whose UTC digits encode the - * *local wall-clock* in the rule's zone — not real UTC instants. Every call - * is wrapped by `utcToRruleDate` on the way in and `rruleDateToUtc` on the - * way out so callers outside this module never see the wall-clock-as-UTC. + * We intentionally run rrule.js on floating wall-clock dates without `TZID`. + * `TZID` output varies with the host process timezone; floating dates keep the + * recurrence calendar stable, then this module converts each occurrence to a + * real UTC instant in the automation's configured timezone. */ import { TZDate } from "@date-fns/tz"; @@ -296,7 +296,7 @@ export interface ParsedRecurrence { /** Wall-clock-as-UTC → real UTC in the given zone. */ export function rruleDateToUtc(rruleDate: Date, timezone: string): Date { - return new TZDate( + const zoned = new TZDate( rruleDate.getUTCFullYear(), rruleDate.getUTCMonth(), rruleDate.getUTCDate(), @@ -305,6 +305,7 @@ export function rruleDateToUtc(rruleDate: Date, timezone: string): Date { rruleDate.getUTCSeconds(), timezone, ); + return new Date(zoned.getTime()); } /** Real UTC → wall-clock-as-UTC in the given zone (rrule.js input space). */ @@ -348,7 +349,7 @@ function buildRuleString( dtstart: Date, timezone: string, ): string { - return `DTSTART;TZID=${timezone}:${formatRRuleLocalDtstart(dtstart, timezone)}\nRRULE:${rrule}`; + return `DTSTART:${formatRRuleLocalDtstart(dtstart, timezone)}\nRRULE:${rrule}`; } /** @@ -413,3 +414,36 @@ export function nextOccurrences(args: { } return results; } + +export interface FormatDateTimeInTimezoneOptions { + /** BCP-47 locale for date/time formatting. Defaults to runtime default. */ + locale?: string; +} + +const DATE_TIME_IN_TIMEZONE_FORMAT_OPTIONS: Intl.DateTimeFormatOptions = { + month: "short", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "2-digit", + timeZoneName: "short", +}; + +/** Format a real UTC instant in the automation's configured timezone. */ +export function formatDateTimeInTimezone( + date: Date, + timezone: string, + options: FormatDateTimeInTimezoneOptions = {}, +): string { + try { + return new Intl.DateTimeFormat(options.locale, { + ...DATE_TIME_IN_TIMEZONE_FORMAT_OPTIONS, + timeZone: timezone, + }).format(date); + } catch { + return new Intl.DateTimeFormat(options.locale, { + ...DATE_TIME_IN_TIMEZONE_FORMAT_OPTIONS, + timeZone: "UTC", + }).format(date); + } +} diff --git a/packages/trpc/src/router/automation/schema.ts b/packages/trpc/src/router/automation/schema.ts index 728983cf4c5..085e5cdeb16 100644 --- a/packages/trpc/src/router/automation/schema.ts +++ b/packages/trpc/src/router/automation/schema.ts @@ -14,7 +14,20 @@ const agentConfigSchema = z }) .passthrough() as unknown as z.ZodType; -const iana = z.string().min(1).describe("IANA timezone name"); +function isValidIanaTimezone(timezone: string): boolean { + try { + new Intl.DateTimeFormat(undefined, { timeZone: timezone }); + return true; + } catch { + return false; + } +} + +const iana = z + .string() + .min(1) + .refine(isValidIanaTimezone, "Invalid IANA timezone name") + .describe("IANA timezone name"); const rruleBody = z .string() .min(1)