Skip to content

Commit 636a8d6

Browse files
MattPuacharlesvien
andauthored
feat(loops): Customize timezone for loop schedules (#3725)
Co-authored-by: Charles Vien <charles.v@posthog.com>
1 parent f6e5e74 commit 636a8d6

20 files changed

Lines changed: 897 additions & 34 deletions

apps/code/.storybook/preview.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ const preview: Preview = {
5151
scaling="105%"
5252
>
5353
<Story />
54+
{/* Match ThemeWrapper so portaled Quill components inherit the story's active theme. */}
55+
<div id="portal-container" />
5456
</Theme>
5557
);
5658
},

apps/code/snapshots.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,18 @@ snapshots:
264264
hash: v1.k4693efd2.bbae772bcd59e545c1816dd0b32d4996b993a7a981691f0710aaaec48d504893.QeBzMnOZIT7FGv6KuyKrDmo2CaEvyBzKwj9DJluf2UI
265265
components-ui-combobox--with-search--light:
266266
hash: v1.k4693efd2.fed2cd543661a6ade6f836465a0ddacada5a2073f537d4b2b824918b57bb30a4.fpalvRPE3cetQ4_FQarE8PEfhkdCtcViPMsAMriDQDI
267+
components-ui-timezoneconversiontooltip--dark--dark:
268+
hash: v1.k4693efd2.50ab05ce0db7f840c42597f038e6acf835a47c9b4fdd2e2f84280ab91beea316.Chf3h-HHquoclZ0lak_lJgsIDzvjVoiUw6GfmG5CCU8
269+
components-ui-timezoneconversiontooltip--dark--light:
270+
hash: v1.k4693efd2.1136dc2e5909de65274ec6ccbfdcecaea5ed47779ad2db4ba2d9307b26f1fb8e._NtdrEGgR7vCnwK6gsn1ZQPtqfNSIHpg0ZYXfGrAN1o
271+
components-ui-timezoneconversiontooltip--light--dark:
272+
hash: v1.k4693efd2.efab37759c71f5acbd23a327474aac8c9b4951dd19fa58cd7e408a5f87b09d7d.swN7uXSk62GDfFfwQFsZtZwlECoSefcb4W2ET9dIqRw
273+
components-ui-timezoneconversiontooltip--light--light:
274+
hash: v1.k4693efd2.e456c1afa34d753d08d83ce002e7dcafebba0049572f9da39e67d71fdddb8e05.J_kCP7Xt-OuBJQcqiQJZb1nTrKdazwHNxtATDTfoYrk
275+
components-ui-timezonetimestamp--default--dark:
276+
hash: v1.k4693efd2.d372047025c26e546561d5be18428ba2a34bdd00d7479156a48f39edd4fb21b6.IRUaKeF7HWOQrC_CueX5NgPy1L8wmXGJ8Eq_-_Ka07Y
277+
components-ui-timezonetimestamp--default--light:
278+
hash: v1.k4693efd2.cbe7ed21d83b8973fb37886771bddc38bf6e5cf7724ffa4102e5dce6c85f9686.j3F-0bfpM8IEuWOUhlUBhrLHSOk96YRtxrhUsx4SJd4
267279
features-messageeditor-promptinput--all-chip-types--dark:
268280
hash: v1.k4693efd2.ee4386e086ae73a26460272d6673f2a4a2332b3fffee685474dac3794d4c42b6.-CCWbzKF6yZe9dsI3UrM406v7Ks20advTdOybi17OXk
269281
features-messageeditor-promptinput--all-chip-types--light:

packages/ui/src/features/loops/components/LoopDetailView.tsx

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import { UserAvatar } from "@posthog/ui/features/auth/UserAvatar";
1717
import { useOrgMembers } from "@posthog/ui/features/canvas/hooks/useOrgMembers";
1818
import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay";
1919
import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent";
20+
import { TimezoneTimestamp } from "@posthog/ui/primitives/TimezoneTimestamp";
21+
import { systemTimezone } from "@posthog/ui/primitives/timezone";
2022
import { toast } from "@posthog/ui/primitives/toast";
2123
import {
2224
navigateToEditLoop,
@@ -36,6 +38,7 @@ import {
3638
describeTrigger,
3739
loopStatusColor,
3840
loopStatusLabel,
41+
nextScheduleRun,
3942
summarizeNotificationDestinations,
4043
} from "../loopDisplay";
4144
import { LoopLoadError } from "./LoopFallbacks";
@@ -329,7 +332,7 @@ function ConfigSummarySection({ loop }: { loop: LoopSchemas.Loop }) {
329332
<Flex direction="column" gap="1">
330333
{loop.triggers.map((trigger) => (
331334
<Text key={trigger.id} className="text-[12.5px] text-gray-12">
332-
{describeTrigger(trigger)}
335+
<TriggerDescription trigger={trigger} />
333336
{!trigger.enabled ? " (disabled)" : ""}
334337
</Text>
335338
))}
@@ -414,6 +417,32 @@ function InstructionsSection({ loop }: { loop: LoopSchemas.Loop }) {
414417
);
415418
}
416419

420+
function TriggerDescription({ trigger }: { trigger: LoopSchemas.LoopTrigger }) {
421+
const description = describeTrigger(trigger);
422+
if (trigger.type !== "schedule") return description;
423+
424+
const config = trigger.config as LoopSchemas.LoopScheduleTriggerConfig;
425+
const nextRun = nextScheduleRun(config);
426+
if (!nextRun) return description;
427+
const nextRunSeparator = " · Next run ";
428+
const [scheduleDescription, nextRunDescription] =
429+
description.split(nextRunSeparator);
430+
const timezone =
431+
config.timezone ?? (config.run_at ? systemTimezone() : "UTC");
432+
433+
return (
434+
<>
435+
{scheduleDescription}
436+
{nextRunSeparator}
437+
<TimezoneTimestamp
438+
timestamp={nextRun}
439+
timezone={timezone}
440+
label={nextRunDescription}
441+
/>
442+
</>
443+
);
444+
}
445+
417446
function SummaryRow({
418447
label,
419448
children,

packages/ui/src/features/loops/components/LoopTriggerEditor.tsx

Lines changed: 39 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,20 @@ import { Switch } from "@posthog/quill";
1010
import { CopyButton } from "@posthog/ui/features/agent-applications/components/CopyButton";
1111
import { SettingsOptionSelect } from "@posthog/ui/features/settings/SettingsOptionSelect";
1212
import { Button } from "@posthog/ui/primitives/Button";
13+
import { TimezonePicker } from "@posthog/ui/primitives/TimezonePicker";
14+
import { TimezoneTimestamp } from "@posthog/ui/primitives/TimezoneTimestamp";
15+
import {
16+
formatScheduleTimestamp,
17+
systemTimezone,
18+
} from "@posthog/ui/primitives/timezone";
1319
import { Box, Checkbox, Flex, IconButton, Text } from "@radix-ui/themes";
1420
import {
1521
compileCronSchedule,
1622
DEFAULT_SCHEDULE_TIME,
1723
parseCronSchedule,
1824
type RecurringFrequency,
1925
} from "../loopCron";
26+
import { nextScheduleRun } from "../loopDisplay";
2027
import {
2128
defaultLoopScheduleTrigger,
2229
emptyLoopApiTriggerConfig,
@@ -117,6 +124,7 @@ export function LoopTriggerEditor({
117124
variant="outline"
118125
color="gray"
119126
size="1"
127+
className="self-start"
120128
disabled={disabled}
121129
onClick={addTrigger}
122130
>
@@ -235,14 +243,6 @@ const WEEKDAY_OPTIONS = [
235243
{ value: "6", label: "Saturday" },
236244
];
237245

238-
function localTimezone(): string {
239-
try {
240-
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
241-
} catch {
242-
return "UTC";
243-
}
244-
}
245-
246246
function ScheduleTriggerFields({
247247
config,
248248
disabled,
@@ -264,7 +264,12 @@ function ScheduleTriggerFields({
264264
: (parsed?.frequency ?? "daily");
265265
const time = parsed?.time ?? DEFAULT_SCHEDULE_TIME;
266266
const weekday = parsed?.weekday ?? "1";
267-
const timezone = config.timezone || localTimezone();
267+
const timezone = config.timezone ?? "UTC";
268+
const nextRun = nextScheduleRun(config);
269+
const nextRunTimezone = frequency === "once" ? systemTimezone() : timezone;
270+
const nextRunLabel = nextRun
271+
? formatScheduleTimestamp(nextRun, nextRunTimezone)
272+
: null;
268273
const frequencyOptions = isCustomCron
269274
? [CUSTOM_FREQUENCY_OPTION, ...FREQUENCY_OPTIONS]
270275
: FREQUENCY_OPTIONS;
@@ -289,7 +294,10 @@ function ScheduleTriggerFields({
289294
if (next === "custom") return;
290295
if (next === "once") {
291296
// The backend rejects run_at values in the past; default an hour out.
292-
onChange({ run_at: new Date(Date.now() + 60 * 60 * 1000).toISOString() });
297+
onChange({
298+
run_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
299+
timezone,
300+
});
293301
return;
294302
}
295303
setRecurring(next, time, weekday);
@@ -369,7 +377,27 @@ function ScheduleTriggerFields({
369377
</Flex>
370378

371379
{frequency !== "once" ? (
372-
<Text className="text-[11px] text-gray-9">Times in {timezone}</Text>
380+
<Flex direction="column" gap="1">
381+
<Text className="text-[11px] text-gray-9">Timezone</Text>
382+
<TimezonePicker
383+
value={timezone}
384+
disabled={disabled}
385+
className="w-[240px] max-w-full"
386+
onValueChange={(value) => onChange({ ...config, timezone: value })}
387+
/>
388+
</Flex>
389+
) : null}
390+
391+
{nextRun && nextRunLabel ? (
392+
<Flex align="center" gap="2" className="text-[12px]">
393+
<Text className="text-gray-10">Next run</Text>
394+
<TimezoneTimestamp
395+
timestamp={nextRun}
396+
timezone={nextRunTimezone}
397+
label={nextRunLabel}
398+
className="text-gray-12"
399+
/>
400+
</Flex>
373401
) : null}
374402
</Flex>
375403
);

packages/ui/src/features/loops/loopCron.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,10 @@ describe("parseCronSchedule", () => {
3636
{ name: "specific month", cron: "0 9 * 6 *" },
3737
{ name: "weekday list", cron: "0 9 * * 1,3,5" },
3838
{ name: "hourly on a weekday", cron: "0 * * * 1" },
39+
{ name: "non-zero hourly minute", cron: "30 * * * *" },
3940
{ name: "wildcard minute", cron: "* 9 * * *" },
41+
{ name: "minute out of range", cron: "60 9 * * *" },
42+
{ name: "hour out of range", cron: "0 24 * * *" },
4043
{ name: "six fields", cron: "0 0 9 * * *" },
4144
{ name: "named weekday", cron: "0 9 * * MON" },
4245
])("returns null for $name so it renders as custom", ({ cron }) => {

packages/ui/src/features/loops/loopCron.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
1+
import type {
2+
RecurringSchedule,
3+
RecurringScheduleFrequency,
4+
} from "@posthog/ui/primitives/nextRecurringRun";
5+
16
export const DEFAULT_SCHEDULE_TIME = "09:00";
27

3-
export type RecurringFrequency = "hourly" | "daily" | "weekdays" | "weekly";
8+
export type RecurringFrequency = RecurringScheduleFrequency;
49

5-
export interface ParsedRecurringSchedule {
6-
frequency: RecurringFrequency;
7-
time: string;
8-
weekday: string;
9-
}
10+
export type ParsedRecurringSchedule = RecurringSchedule;
1011

1112
/** Reads the shapes the frequency picker writes. Anything else (step values,
1213
* day-of-month, day lists, ...) returns null and must be treated as a custom
@@ -20,12 +21,15 @@ export function parseCronSchedule(
2021
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
2122
if (dayOfMonth !== "*" || month !== "*") return null;
2223
if (!/^\d{1,2}$/.test(minute)) return null;
24+
const minuteNumber = Number(minute);
25+
if (minuteNumber > 59) return null;
2326
if (hour === "*") {
24-
return dayOfWeek === "*"
27+
return minute === "0" && dayOfWeek === "*"
2528
? { frequency: "hourly", time: DEFAULT_SCHEDULE_TIME, weekday: "1" }
2629
: null;
2730
}
2831
if (!/^\d{1,2}$/.test(hour)) return null;
32+
if (Number(hour) > 23) return null;
2933
const time = `${hour.padStart(2, "0")}:${minute.padStart(2, "0")}`;
3034
if (dayOfWeek === "*") return { frequency: "daily", time, weekday: "1" };
3135
if (dayOfWeek === "1-5") return { frequency: "weekdays", time, weekday: "1" };

packages/ui/src/features/loops/loopDisplay.test.ts

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
1-
import { describe, expect, it } from "vitest";
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
22
import {
33
describeTrigger,
4+
nextScheduleRun,
45
summarizeNotificationDestinations,
6+
summarizeTrigger,
57
} from "./loopDisplay";
68

79
describe("describeTrigger", () => {
10+
beforeEach(() => vi.useFakeTimers());
11+
afterEach(() => vi.useRealTimers());
12+
813
it.each([
914
["0 * * * *", "Every hour (UTC)"],
1015
["30 9 * * *", "Daily at 9:30 AM (UTC)"],
@@ -16,7 +21,7 @@ describe("describeTrigger", () => {
1621
type: "schedule",
1722
config: { cron_expression: cronExpression, timezone: "UTC" },
1823
}),
19-
).toBe(`Schedule · ${expected}`);
24+
).toContain(`Schedule · ${expected} · Next run `);
2025
});
2126

2227
it("keeps custom cron expressions visible", () => {
@@ -54,3 +59,55 @@ describe("summarizeNotificationDestinations", () => {
5459
).toEqual([]);
5560
});
5661
});
62+
63+
describe("nextScheduleRun", () => {
64+
it("returns null for an invalid timezone", () => {
65+
expect(
66+
nextScheduleRun(
67+
{ cron_expression: "0 9 * * *", timezone: "Not/A_Timezone" },
68+
new Date("2026-07-22T12:00:00.000Z"),
69+
),
70+
).toBeNull();
71+
});
72+
73+
it("skips a local time that does not exist during DST transition", () => {
74+
expect(
75+
nextScheduleRun(
76+
{
77+
cron_expression: "30 2 * * *",
78+
timezone: "America/Toronto",
79+
},
80+
new Date("2026-03-08T06:00:00.000Z"),
81+
)?.toISOString(),
82+
).toBe("2026-03-09T06:30:00.000Z");
83+
});
84+
85+
it("finds the next weekday across a weekend", () => {
86+
expect(
87+
nextScheduleRun(
88+
{ cron_expression: "0 9 * * 1-5", timezone: "UTC" },
89+
new Date("2026-07-24T10:00:00.000Z"),
90+
)?.toISOString(),
91+
).toBe("2026-07-27T09:00:00.000Z");
92+
});
93+
});
94+
95+
describe("summarizeTrigger", () => {
96+
beforeEach(() => {
97+
vi.useFakeTimers();
98+
vi.setSystemTime(new Date("2026-07-22T20:09:00.000Z"));
99+
});
100+
afterEach(() => vi.useRealTimers());
101+
102+
it("shows a readable schedule instead of raw cron", () => {
103+
expect(
104+
summarizeTrigger({
105+
type: "schedule",
106+
config: {
107+
cron_expression: "8 16 * * *",
108+
timezone: "America/Toronto",
109+
},
110+
}),
111+
).toBe("Daily at 4:08 PM (EDT) · Next run Thu, Jul 23, 4:08 PM");
112+
});
113+
});

0 commit comments

Comments
 (0)