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)