Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
)
: "—"
Comment on lines +84 to 88
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Unguarded timezone string passed to Intl.DateTimeFormat

formatDateTimeInTimezone calls new Intl.DateTimeFormat(locale, { timeZone: timezone }) directly. If automation.timezone on a legacy row contains a value the runtime rejects (e.g. a renamed IANA zone or corrupted data), Intl.DateTimeFormat throws a RangeError that propagates unhandled through the JSX tree and crashes the sidebar. The new schema .refine(isValidIanaTimezone) prevents future bad writes, but existing rows are not retroactively validated. Adding a try-catch inside formatDateTimeInTimezone or a React error boundary around the sidebar would prevent a crash for edge-case legacy data.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/AutomationDetailSidebar/AutomationDetailSidebar.tsx
Line: 84-88

Comment:
**Unguarded timezone string passed to `Intl.DateTimeFormat`**

`formatDateTimeInTimezone` calls `new Intl.DateTimeFormat(locale, { timeZone: timezone })` directly. If `automation.timezone` on a legacy row contains a value the runtime rejects (e.g. a renamed IANA zone or corrupted data), `Intl.DateTimeFormat` throws a `RangeError` that propagates unhandled through the JSX tree and crashes the sidebar. The new schema `.refine(isValidIanaTimezone)` prevents future bad writes, but existing rows are not retroactively validated. Adding a try-catch inside `formatDateTimeInTimezone` or a React error boundary around the sidebar would prevent a crash for edge-case legacy data.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 00ea424: formatDateTimeInTimezone now catches invalid timezone values and falls back to UTC formatting, so legacy/corrupt rows do not crash the sidebar.

}
/>
<Row
label="Last ran"
value={lastRunAt ? format(lastRunAt, "MMM d, h:mm a") : "—"}
value={
lastRunAt
? formatDateTimeInTimezone(lastRunAt, automation.timezone)
: "—"
}
/>
</Section>

Expand Down
6 changes: 2 additions & 4 deletions packages/cli/src/commands/automations/create/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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)}`,
};
},
});
12 changes: 12 additions & 0 deletions packages/cli/src/commands/automations/format.ts
Original file line number Diff line number Diff line change
@@ -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");
}
6 changes: 5 additions & 1 deletion packages/cli/src/commands/automations/list/command.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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"],
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/automations/resume/command.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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)}`,
};
},
});
2 changes: 1 addition & 1 deletion packages/cli/src/commands/automations/update/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
114 changes: 114 additions & 0 deletions packages/shared/src/rrule.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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",
});
});
});
46 changes: 40 additions & 6 deletions packages/shared/src/rrule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(),
Expand All @@ -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). */
Expand Down Expand Up @@ -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}`;
}

/**
Expand Down Expand Up @@ -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);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
15 changes: 14 additions & 1 deletion packages/trpc/src/router/automation/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,20 @@ const agentConfigSchema = z
})
.passthrough() as unknown as z.ZodType<ResolvedAgentConfig>;

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)
Expand Down
Loading