From b9e5d23b7c4b53ac11bd0746db4265d4b40ba88e Mon Sep 17 00:00:00 2001 From: Matt Pua Date: Wed, 22 Jul 2026 15:08:36 -0400 Subject: [PATCH 1/6] Use system timezone for loop schedules Generated-By: PostHog Code Task-Id: 907cb786-f6a5-4eb7-bab0-44ec58b70afa --- .../loops/components/LoopTriggerEditor.tsx | 36 +++++++++++++------ .../ui/src/features/loops/loopFormTypes.ts | 3 +- .../ui/src/features/loops/loopTemplates.ts | 3 +- .../src/features/loops/loopTimezone.test.ts | 22 ++++++++++++ .../ui/src/features/loops/loopTimezone.ts | 7 ++++ 5 files changed, 59 insertions(+), 12 deletions(-) create mode 100644 packages/ui/src/features/loops/loopTimezone.test.ts create mode 100644 packages/ui/src/features/loops/loopTimezone.ts diff --git a/packages/ui/src/features/loops/components/LoopTriggerEditor.tsx b/packages/ui/src/features/loops/components/LoopTriggerEditor.tsx index eb900993d2..a39968307f 100644 --- a/packages/ui/src/features/loops/components/LoopTriggerEditor.tsx +++ b/packages/ui/src/features/loops/components/LoopTriggerEditor.tsx @@ -25,6 +25,7 @@ import { isTriggerDraftValid, type LoopTriggerDraft, } from "../loopFormTypes"; +import { systemTimezone } from "../loopTimezone"; import { LoopRepositoryPicker } from "./LoopRepositoryPicker"; const TRIGGER_TYPE_OPTIONS: { @@ -235,14 +236,6 @@ const WEEKDAY_OPTIONS = [ { value: "6", label: "Saturday" }, ]; -function localTimezone(): string { - try { - return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; - } catch { - return "UTC"; - } -} - function ScheduleTriggerFields({ config, disabled, @@ -264,7 +257,7 @@ function ScheduleTriggerFields({ : (parsed?.frequency ?? "daily"); const time = parsed?.time ?? DEFAULT_SCHEDULE_TIME; const weekday = parsed?.weekday ?? "1"; - const timezone = config.timezone || localTimezone(); + const timezone = config.timezone || systemTimezone(); const frequencyOptions = isCustomCron ? [CUSTOM_FREQUENCY_OPTION, ...FREQUENCY_OPTIONS] : FREQUENCY_OPTIONS; @@ -369,7 +362,30 @@ function ScheduleTriggerFields({ {frequency !== "once" ? ( - Times in {timezone} + + + Timezone + + onChange({ ...config, timezone: event.target.value }) + } + /> + + + ) : null} ); diff --git a/packages/ui/src/features/loops/loopFormTypes.ts b/packages/ui/src/features/loops/loopFormTypes.ts index e7fbee6ca0..57ad96bf36 100644 --- a/packages/ui/src/features/loops/loopFormTypes.ts +++ b/packages/ui/src/features/loops/loopFormTypes.ts @@ -1,4 +1,5 @@ import type { LoopSchemas } from "@posthog/api-client/loops"; +import { systemTimezone } from "./loopTimezone"; /** * A trigger row in the create/edit form. `key` is a client-only stable @@ -45,7 +46,7 @@ export interface LoopFormValues { } export function emptyLoopScheduleTriggerConfig(): LoopSchemas.LoopScheduleTriggerConfig { - return { cron_expression: "0 9 * * 1", timezone: "UTC" }; + return { cron_expression: "0 9 * * 1", timezone: systemTimezone() }; } export function emptyLoopGithubTriggerConfig(): LoopSchemas.LoopGithubTriggerConfig { diff --git a/packages/ui/src/features/loops/loopTemplates.ts b/packages/ui/src/features/loops/loopTemplates.ts index bfe28694f1..f4cc027558 100644 --- a/packages/ui/src/features/loops/loopTemplates.ts +++ b/packages/ui/src/features/loops/loopTemplates.ts @@ -18,6 +18,7 @@ import { type LoopTriggerDraft, nextDraftTriggerKey, } from "./loopFormTypes"; +import { systemTimezone } from "./loopTimezone"; export type LoopTemplateCategory = "engineering" | "operations"; @@ -49,7 +50,7 @@ function scheduleDraft(cron: string): LoopTriggerDraft { key: nextDraftTriggerKey(), type: "schedule", enabled: true, - config: { cron_expression: cron, timezone: "UTC" }, + config: { cron_expression: cron, timezone: systemTimezone() }, }; } diff --git a/packages/ui/src/features/loops/loopTimezone.test.ts b/packages/ui/src/features/loops/loopTimezone.test.ts new file mode 100644 index 0000000000..78700e294b --- /dev/null +++ b/packages/ui/src/features/loops/loopTimezone.test.ts @@ -0,0 +1,22 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { systemTimezone } from "./loopTimezone"; + +describe("systemTimezone", () => { + afterEach(() => vi.restoreAllMocks()); + + it("returns the runtime IANA timezone", () => { + vi.spyOn(Intl, "DateTimeFormat").mockReturnValue({ + resolvedOptions: () => ({ timeZone: "America/New_York" }), + } as Intl.DateTimeFormat); + + expect(systemTimezone()).toBe("America/New_York"); + }); + + it("falls back to UTC when timezone detection fails", () => { + vi.spyOn(Intl, "DateTimeFormat").mockImplementation(() => { + throw new Error("unavailable"); + }); + + expect(systemTimezone()).toBe("UTC"); + }); +}); diff --git a/packages/ui/src/features/loops/loopTimezone.ts b/packages/ui/src/features/loops/loopTimezone.ts new file mode 100644 index 0000000000..ff22bfeb43 --- /dev/null +++ b/packages/ui/src/features/loops/loopTimezone.ts @@ -0,0 +1,7 @@ +export function systemTimezone(): string { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + } catch { + return "UTC"; + } +} From f03a14950cdab256b05bde733294eaadf9c5394a Mon Sep 17 00:00:00 2001 From: Matt Pua Date: Wed, 22 Jul 2026 17:23:35 -0400 Subject: [PATCH 2/6] refactor: improve loop schedule timezone display Generated-By: PostHog Code Task-Id: 231e46d4-90e5-4be2-af47-958398fcbf9c --- apps/code/.storybook/preview.tsx | 2 + .../loops/components/LoopDetailView.tsx | 31 ++- .../loops/components/LoopTriggerEditor.tsx | 62 +++--- .../ui/src/features/loops/loopCron.test.ts | 3 + packages/ui/src/features/loops/loopCron.ts | 18 +- .../ui/src/features/loops/loopDisplay.test.ts | 61 +++++- packages/ui/src/features/loops/loopDisplay.ts | 60 +++++- .../src/features/loops/loopFormTypes.test.ts | 6 +- .../ui/src/features/loops/loopFormTypes.ts | 2 +- .../ui/src/features/loops/loopTemplates.ts | 2 +- .../src/features/loops/loopTimezone.test.ts | 22 -- .../ui/src/features/loops/loopTimezone.ts | 7 - .../TimezoneConversionTooltip.stories.tsx | 30 +++ .../TimezoneConversionTooltip.test.tsx | 25 +++ .../primitives/TimezoneConversionTooltip.tsx | 193 ++++++++++++++++++ packages/ui/src/primitives/TimezonePicker.tsx | 91 +++++++++ .../primitives/TimezoneTimestamp.stories.tsx | 17 ++ .../ui/src/primitives/TimezoneTimestamp.tsx | 45 ++++ .../ui/src/primitives/nextRecurringRun.ts | 70 +++++++ packages/ui/src/primitives/timezone.test.ts | 64 ++++++ packages/ui/src/primitives/timezone.ts | 93 +++++++++ 21 files changed, 828 insertions(+), 76 deletions(-) delete mode 100644 packages/ui/src/features/loops/loopTimezone.test.ts delete mode 100644 packages/ui/src/features/loops/loopTimezone.ts create mode 100644 packages/ui/src/primitives/TimezoneConversionTooltip.stories.tsx create mode 100644 packages/ui/src/primitives/TimezoneConversionTooltip.test.tsx create mode 100644 packages/ui/src/primitives/TimezoneConversionTooltip.tsx create mode 100644 packages/ui/src/primitives/TimezonePicker.tsx create mode 100644 packages/ui/src/primitives/TimezoneTimestamp.stories.tsx create mode 100644 packages/ui/src/primitives/TimezoneTimestamp.tsx create mode 100644 packages/ui/src/primitives/nextRecurringRun.ts create mode 100644 packages/ui/src/primitives/timezone.test.ts create mode 100644 packages/ui/src/primitives/timezone.ts diff --git a/apps/code/.storybook/preview.tsx b/apps/code/.storybook/preview.tsx index b6988bec8f..22780d4720 100644 --- a/apps/code/.storybook/preview.tsx +++ b/apps/code/.storybook/preview.tsx @@ -51,6 +51,8 @@ const preview: Preview = { scaling="105%" > + {/* Match ThemeWrapper so portaled Quill components inherit the story's active theme. */} +
); }, diff --git a/packages/ui/src/features/loops/components/LoopDetailView.tsx b/packages/ui/src/features/loops/components/LoopDetailView.tsx index 21a7fa3d74..c2f5ee7cfc 100644 --- a/packages/ui/src/features/loops/components/LoopDetailView.tsx +++ b/packages/ui/src/features/loops/components/LoopDetailView.tsx @@ -17,6 +17,8 @@ import { UserAvatar } from "@posthog/ui/features/auth/UserAvatar"; import { useOrgMembers } from "@posthog/ui/features/canvas/hooks/useOrgMembers"; import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; +import { TimezoneTimestamp } from "@posthog/ui/primitives/TimezoneTimestamp"; +import { systemTimezone } from "@posthog/ui/primitives/timezone"; import { toast } from "@posthog/ui/primitives/toast"; import { navigateToEditLoop, @@ -36,6 +38,7 @@ import { describeTrigger, loopStatusColor, loopStatusLabel, + nextScheduleRun, summarizeNotificationDestinations, } from "../loopDisplay"; import { LoopLoadError } from "./LoopFallbacks"; @@ -329,7 +332,7 @@ function ConfigSummarySection({ loop }: { loop: LoopSchemas.Loop }) { {loop.triggers.map((trigger) => ( - {describeTrigger(trigger)} + {!trigger.enabled ? " (disabled)" : ""} ))} @@ -414,6 +417,32 @@ function InstructionsSection({ loop }: { loop: LoopSchemas.Loop }) { ); } +function TriggerDescription({ trigger }: { trigger: LoopSchemas.LoopTrigger }) { + const description = describeTrigger(trigger); + if (trigger.type !== "schedule") return description; + + const config = trigger.config as LoopSchemas.LoopScheduleTriggerConfig; + const nextRun = nextScheduleRun(config); + if (!nextRun) return description; + const nextRunSeparator = " · Next run "; + const [scheduleDescription, nextRunDescription] = + description.split(nextRunSeparator); + const timezone = + config.timezone ?? (config.run_at ? systemTimezone() : "UTC"); + + return ( + <> + {scheduleDescription} + {nextRunSeparator} + + + ); +} + function SummaryRow({ label, children, diff --git a/packages/ui/src/features/loops/components/LoopTriggerEditor.tsx b/packages/ui/src/features/loops/components/LoopTriggerEditor.tsx index a39968307f..232e335714 100644 --- a/packages/ui/src/features/loops/components/LoopTriggerEditor.tsx +++ b/packages/ui/src/features/loops/components/LoopTriggerEditor.tsx @@ -10,6 +10,9 @@ import { Switch } from "@posthog/quill"; import { CopyButton } from "@posthog/ui/features/agent-applications/components/CopyButton"; import { SettingsOptionSelect } from "@posthog/ui/features/settings/SettingsOptionSelect"; import { Button } from "@posthog/ui/primitives/Button"; +import { TimezonePicker } from "@posthog/ui/primitives/TimezonePicker"; +import { TimezoneTimestamp } from "@posthog/ui/primitives/TimezoneTimestamp"; +import { systemTimezone } from "@posthog/ui/primitives/timezone"; import { Box, Checkbox, Flex, IconButton, Text } from "@radix-ui/themes"; import { compileCronSchedule, @@ -17,6 +20,7 @@ import { parseCronSchedule, type RecurringFrequency, } from "../loopCron"; +import { nextScheduleRun } from "../loopDisplay"; import { defaultLoopScheduleTrigger, emptyLoopApiTriggerConfig, @@ -25,7 +29,6 @@ import { isTriggerDraftValid, type LoopTriggerDraft, } from "../loopFormTypes"; -import { systemTimezone } from "../loopTimezone"; import { LoopRepositoryPicker } from "./LoopRepositoryPicker"; const TRIGGER_TYPE_OPTIONS: { @@ -118,6 +121,7 @@ export function LoopTriggerEditor({ variant="outline" color="gray" size="1" + className="self-start" disabled={disabled} onClick={addTrigger} > @@ -258,6 +262,19 @@ function ScheduleTriggerFields({ const time = parsed?.time ?? DEFAULT_SCHEDULE_TIME; const weekday = parsed?.weekday ?? "1"; const timezone = config.timezone || systemTimezone(); + const nextRun = nextScheduleRun(config); + const nextRunTimezone = frequency === "once" ? systemTimezone() : timezone; + const nextRunLabel = nextRun + ? new Intl.DateTimeFormat(undefined, { + timeZone: nextRunTimezone, + weekday: "short", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + timeZoneName: "short", + }).format(nextRun) + : null; const frequencyOptions = isCustomCron ? [CUSTOM_FREQUENCY_OPTION, ...FREQUENCY_OPTIONS] : FREQUENCY_OPTIONS; @@ -362,29 +379,26 @@ function ScheduleTriggerFields({ {frequency !== "once" ? ( - - - Timezone - - onChange({ ...config, timezone: event.target.value }) - } - /> - - + + Timezone + onChange({ ...config, timezone: value })} + /> + + ) : null} + + {nextRun && nextRunLabel ? ( + + Next run + ) : null} diff --git a/packages/ui/src/features/loops/loopCron.test.ts b/packages/ui/src/features/loops/loopCron.test.ts index 9ad159bb5f..378e3f1a6d 100644 --- a/packages/ui/src/features/loops/loopCron.test.ts +++ b/packages/ui/src/features/loops/loopCron.test.ts @@ -36,7 +36,10 @@ describe("parseCronSchedule", () => { { name: "specific month", cron: "0 9 * 6 *" }, { name: "weekday list", cron: "0 9 * * 1,3,5" }, { name: "hourly on a weekday", cron: "0 * * * 1" }, + { name: "non-zero hourly minute", cron: "30 * * * *" }, { name: "wildcard minute", cron: "* 9 * * *" }, + { name: "minute out of range", cron: "60 9 * * *" }, + { name: "hour out of range", cron: "0 24 * * *" }, { name: "six fields", cron: "0 0 9 * * *" }, { name: "named weekday", cron: "0 9 * * MON" }, ])("returns null for $name so it renders as custom", ({ cron }) => { diff --git a/packages/ui/src/features/loops/loopCron.ts b/packages/ui/src/features/loops/loopCron.ts index 1a600fa12b..5b4a63960a 100644 --- a/packages/ui/src/features/loops/loopCron.ts +++ b/packages/ui/src/features/loops/loopCron.ts @@ -1,12 +1,13 @@ +import type { + RecurringSchedule, + RecurringScheduleFrequency, +} from "@posthog/ui/primitives/nextRecurringRun"; + export const DEFAULT_SCHEDULE_TIME = "09:00"; -export type RecurringFrequency = "hourly" | "daily" | "weekdays" | "weekly"; +export type RecurringFrequency = RecurringScheduleFrequency; -export interface ParsedRecurringSchedule { - frequency: RecurringFrequency; - time: string; - weekday: string; -} +export type ParsedRecurringSchedule = RecurringSchedule; /** Reads the shapes the frequency picker writes. Anything else (step values, * day-of-month, day lists, ...) returns null and must be treated as a custom @@ -20,12 +21,15 @@ export function parseCronSchedule( const [minute, hour, dayOfMonth, month, dayOfWeek] = parts; if (dayOfMonth !== "*" || month !== "*") return null; if (!/^\d{1,2}$/.test(minute)) return null; + const minuteNumber = Number(minute); + if (minuteNumber > 59) return null; if (hour === "*") { - return dayOfWeek === "*" + return minute === "0" && dayOfWeek === "*" ? { frequency: "hourly", time: DEFAULT_SCHEDULE_TIME, weekday: "1" } : null; } if (!/^\d{1,2}$/.test(hour)) return null; + if (Number(hour) > 23) return null; const time = `${hour.padStart(2, "0")}:${minute.padStart(2, "0")}`; if (dayOfWeek === "*") return { frequency: "daily", time, weekday: "1" }; if (dayOfWeek === "1-5") return { frequency: "weekdays", time, weekday: "1" }; diff --git a/packages/ui/src/features/loops/loopDisplay.test.ts b/packages/ui/src/features/loops/loopDisplay.test.ts index bbb71ae7dc..1d7ced1e3a 100644 --- a/packages/ui/src/features/loops/loopDisplay.test.ts +++ b/packages/ui/src/features/loops/loopDisplay.test.ts @@ -1,10 +1,15 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { describeTrigger, + nextScheduleRun, summarizeNotificationDestinations, + summarizeTrigger, } from "./loopDisplay"; describe("describeTrigger", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + it.each([ ["0 * * * *", "Every hour (UTC)"], ["30 9 * * *", "Daily at 9:30 AM (UTC)"], @@ -16,7 +21,7 @@ describe("describeTrigger", () => { type: "schedule", config: { cron_expression: cronExpression, timezone: "UTC" }, }), - ).toBe(`Schedule · ${expected}`); + ).toContain(`Schedule · ${expected} · Next run `); }); it("keeps custom cron expressions visible", () => { @@ -54,3 +59,55 @@ describe("summarizeNotificationDestinations", () => { ).toEqual([]); }); }); + +describe("nextScheduleRun", () => { + it("returns null for an invalid timezone", () => { + expect( + nextScheduleRun( + { cron_expression: "0 9 * * *", timezone: "Not/A_Timezone" }, + new Date("2026-07-22T12:00:00.000Z"), + ), + ).toBeNull(); + }); + + it("skips a local time that does not exist during DST transition", () => { + expect( + nextScheduleRun( + { + cron_expression: "30 2 * * *", + timezone: "America/Toronto", + }, + new Date("2026-03-08T06:00:00.000Z"), + )?.toISOString(), + ).toBe("2026-03-09T06:30:00.000Z"); + }); + + it("finds the next weekday across a weekend", () => { + expect( + nextScheduleRun( + { cron_expression: "0 9 * * 1-5", timezone: "UTC" }, + new Date("2026-07-24T10:00:00.000Z"), + )?.toISOString(), + ).toBe("2026-07-27T09:00:00.000Z"); + }); +}); + +describe("summarizeTrigger", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-22T20:09:00.000Z")); + }); + afterEach(() => vi.useRealTimers()); + + it("shows a readable schedule instead of raw cron", () => { + expect( + summarizeTrigger({ + type: "schedule", + config: { + cron_expression: "8 16 * * *", + timezone: "America/Toronto", + }, + }), + ).toBe("Daily at 4:08 PM (EDT) · Next run Thu, Jul 23, 4:08 PM"); + }); +}); diff --git a/packages/ui/src/features/loops/loopDisplay.ts b/packages/ui/src/features/loops/loopDisplay.ts index b77621fda6..3795d701d9 100644 --- a/packages/ui/src/features/loops/loopDisplay.ts +++ b/packages/ui/src/features/loops/loopDisplay.ts @@ -1,5 +1,10 @@ import type { LoopSchemas } from "@posthog/api-client/loops"; import { formatClockTime } from "@posthog/shared"; +import { nextRecurringRun } from "@posthog/ui/primitives/nextRecurringRun"; +import { + formatTimezoneAbbreviation, + systemTimezone, +} from "@posthog/ui/primitives/timezone"; import { parseCronSchedule } from "./loopCron"; const WEEKDAY_NAMES: Record = { @@ -18,14 +23,48 @@ function describeSchedule( const cron = config.cron_expression; const parsed = parseCronSchedule(cron); const timezone = config.timezone ?? "UTC"; - if (!parsed) return `${cron ?? "?"} (${timezone})`; - if (parsed.frequency === "hourly") return `Every hour (${timezone})`; + const timezoneLabel = formatTimezoneAbbreviation(timezone); + if (!parsed) return `${cron ?? "?"} (${timezoneLabel})`; + if (parsed.frequency === "hourly") return `Every hour (${timezoneLabel})`; const time = formatClockTime(parsed.time); - if (parsed.frequency === "daily") return `Daily at ${time} (${timezone})`; + if (parsed.frequency === "daily") + return `Daily at ${time} (${timezoneLabel})`; if (parsed.frequency === "weekdays") - return `Weekdays at ${time} (${timezone})`; - return `${WEEKDAY_NAMES[parsed.weekday]}s at ${time} (${timezone})`; + return `Weekdays at ${time} (${timezoneLabel})`; + return `${WEEKDAY_NAMES[parsed.weekday]}s at ${time} (${timezoneLabel})`; +} + +export function nextScheduleRun( + config: LoopSchemas.LoopScheduleTriggerConfig, + now = new Date(), +): Date | null { + if (config.run_at) { + const runAt = new Date(config.run_at); + return runAt > now ? runAt : null; + } + + const schedule = parseCronSchedule(config.cron_expression); + if (!schedule) return null; + return nextRecurringRun(schedule, config.timezone ?? "UTC", now); +} + +function describeNextRun( + config: LoopSchemas.LoopScheduleTriggerConfig, +): string { + const nextRun = nextScheduleRun(config); + if (!nextRun) return ""; + const timezone = + config.timezone ?? (config.run_at ? systemTimezone() : "UTC"); + const formatted = new Intl.DateTimeFormat(undefined, { + timeZone: timezone, + weekday: "short", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }).format(nextRun); + return ` · Next run ${formatted}`; } export function loopStatusColor( @@ -66,12 +105,13 @@ export function summarizeNotificationDestinations( return destinations; } -/** Compact one-word-ish label for the form's review list. */ +/** Readable label for the form's review list. */ export function summarizeTrigger(trigger: TriggerLike): string { if (trigger.type === "schedule") { const config = trigger.config as LoopSchemas.LoopScheduleTriggerConfig; - if (config.run_at) return "Once"; - return `Schedule (${config.cron_expression ?? "cron"})`; + if (config.run_at) + return `Once · ${new Date(config.run_at).toLocaleString()}`; + return `${describeSchedule(config)}${describeNextRun(config)}`; } if (trigger.type === "github") { const config = trigger.config as LoopSchemas.LoopGithubTriggerConfig; @@ -85,8 +125,8 @@ export function describeTrigger(trigger: TriggerLike): string { if (trigger.type === "schedule") { const config = trigger.config as LoopSchemas.LoopScheduleTriggerConfig; if (config.run_at) - return `One-time · ${new Date(config.run_at).toLocaleString()}`; - return `Schedule · ${describeSchedule(config)}`; + return `One-time · ${new Date(config.run_at).toLocaleString()}${describeNextRun(config)}`; + return `Schedule · ${describeSchedule(config)}${describeNextRun(config)}`; } if (trigger.type === "github") { const config = trigger.config as LoopSchemas.LoopGithubTriggerConfig; diff --git a/packages/ui/src/features/loops/loopFormTypes.test.ts b/packages/ui/src/features/loops/loopFormTypes.test.ts index 8df75b6dd5..320493c533 100644 --- a/packages/ui/src/features/loops/loopFormTypes.test.ts +++ b/packages/ui/src/features/loops/loopFormTypes.test.ts @@ -1,4 +1,5 @@ import type { LoopSchemas } from "@posthog/api-client/loops"; +import { systemTimezone } from "@posthog/ui/primitives/timezone"; import { describe, expect, it } from "vitest"; import { defaultLoopBehaviors, @@ -135,7 +136,10 @@ describe("emptyLoopFormValues", () => { key: expect.any(String), type: "schedule", enabled: true, - config: { cron_expression: "0 9 * * 1", timezone: "UTC" }, + config: { + cron_expression: "0 9 * * 1", + timezone: systemTimezone(), + }, }, ]); }); diff --git a/packages/ui/src/features/loops/loopFormTypes.ts b/packages/ui/src/features/loops/loopFormTypes.ts index 57ad96bf36..4825680804 100644 --- a/packages/ui/src/features/loops/loopFormTypes.ts +++ b/packages/ui/src/features/loops/loopFormTypes.ts @@ -1,5 +1,5 @@ import type { LoopSchemas } from "@posthog/api-client/loops"; -import { systemTimezone } from "./loopTimezone"; +import { systemTimezone } from "@posthog/ui/primitives/timezone"; /** * A trigger row in the create/edit form. `key` is a client-only stable diff --git a/packages/ui/src/features/loops/loopTemplates.ts b/packages/ui/src/features/loops/loopTemplates.ts index f4cc027558..ab361e7212 100644 --- a/packages/ui/src/features/loops/loopTemplates.ts +++ b/packages/ui/src/features/loops/loopTemplates.ts @@ -13,12 +13,12 @@ import { Warning, } from "@phosphor-icons/react"; import type { LoopSchemas } from "@posthog/api-client/loops"; +import { systemTimezone } from "@posthog/ui/primitives/timezone"; import { type LoopFormValues, type LoopTriggerDraft, nextDraftTriggerKey, } from "./loopFormTypes"; -import { systemTimezone } from "./loopTimezone"; export type LoopTemplateCategory = "engineering" | "operations"; diff --git a/packages/ui/src/features/loops/loopTimezone.test.ts b/packages/ui/src/features/loops/loopTimezone.test.ts deleted file mode 100644 index 78700e294b..0000000000 --- a/packages/ui/src/features/loops/loopTimezone.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { systemTimezone } from "./loopTimezone"; - -describe("systemTimezone", () => { - afterEach(() => vi.restoreAllMocks()); - - it("returns the runtime IANA timezone", () => { - vi.spyOn(Intl, "DateTimeFormat").mockReturnValue({ - resolvedOptions: () => ({ timeZone: "America/New_York" }), - } as Intl.DateTimeFormat); - - expect(systemTimezone()).toBe("America/New_York"); - }); - - it("falls back to UTC when timezone detection fails", () => { - vi.spyOn(Intl, "DateTimeFormat").mockImplementation(() => { - throw new Error("unavailable"); - }); - - expect(systemTimezone()).toBe("UTC"); - }); -}); diff --git a/packages/ui/src/features/loops/loopTimezone.ts b/packages/ui/src/features/loops/loopTimezone.ts deleted file mode 100644 index ff22bfeb43..0000000000 --- a/packages/ui/src/features/loops/loopTimezone.ts +++ /dev/null @@ -1,7 +0,0 @@ -export function systemTimezone(): string { - try { - return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; - } catch { - return "UTC"; - } -} diff --git a/packages/ui/src/primitives/TimezoneConversionTooltip.stories.tsx b/packages/ui/src/primitives/TimezoneConversionTooltip.stories.tsx new file mode 100644 index 0000000000..f45702335d --- /dev/null +++ b/packages/ui/src/primitives/TimezoneConversionTooltip.stories.tsx @@ -0,0 +1,30 @@ +import { TimezoneConversionTooltip } from "@posthog/ui/primitives/TimezoneConversionTooltip"; +import type { Meta, StoryObj } from "@storybook/react-vite"; + +const meta: Meta = { + title: "Components/UI/TimezoneConversionTooltip", + component: TimezoneConversionTooltip, + parameters: { layout: "centered" }, + args: { + timestamp: new Date("2026-07-23T01:00:00.000Z"), + timezone: "UTC", + timezoneLabel: "Schedule", + open: true, + children: ( + + Wed, Jul 22, 9:00 PM + + ), + }, +}; + +export default meta; +type Story = StoryObj; + +export const Light: Story = { + globals: { theme: "light" }, +}; + +export const Dark: Story = { + globals: { theme: "dark" }, +}; diff --git a/packages/ui/src/primitives/TimezoneConversionTooltip.test.tsx b/packages/ui/src/primitives/TimezoneConversionTooltip.test.tsx new file mode 100644 index 0000000000..e6756a1339 --- /dev/null +++ b/packages/ui/src/primitives/TimezoneConversionTooltip.test.tsx @@ -0,0 +1,25 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { TimezoneConversionTooltip } from "./TimezoneConversionTooltip"; + +describe("TimezoneConversionTooltip", () => { + it("falls back safely when given an invalid timezone", async () => { + const portal = document.createElement("div"); + portal.id = "portal-container"; + document.body.append(portal); + + render( + + Next run + , + ); + + expect(screen.getByText("Next run")).toBeInTheDocument(); + expect(await screen.findByText("Schedule")).toBeInTheDocument(); + portal.remove(); + }); +}); diff --git a/packages/ui/src/primitives/TimezoneConversionTooltip.tsx b/packages/ui/src/primitives/TimezoneConversionTooltip.tsx new file mode 100644 index 0000000000..9d8b617225 --- /dev/null +++ b/packages/ui/src/primitives/TimezoneConversionTooltip.tsx @@ -0,0 +1,193 @@ +import { + Check, + Copy, + Globe, + House, + Laptop, + Terminal, +} from "@phosphor-icons/react"; +import { + Button, + Popover, + PopoverContent, + PopoverTrigger, +} from "@posthog/quill"; +import { type ReactNode, useEffect, useRef, useState } from "react"; +import { + formatTimestampInTimezone, + formatTimezoneAbbreviation, + systemTimezone, +} from "./timezone"; +import { useCopy } from "./useCopy"; + +interface TimezoneConversionTooltipProps { + timestamp: string | number | Date; + timezone: string; + timezoneLabel?: string; + children: ReactNode; + defaultOpen?: boolean; + open?: boolean; +} + +interface ConversionRowProps { + abbreviation: string; + icon: typeof Laptop; + label: string; + value: string; +} + +function ConversionRow({ + abbreviation, + icon: Icon, + label, + value, +}: ConversionRowProps) { + const { copied, copy } = useCopy(1500); + + return ( +
+ + + {label} + {abbreviation ? ( + + {abbreviation} + + ) : null} + + + {value} + + +
+ ); +} + +export function TimezoneConversionTooltip({ + timestamp, + timezone, + timezoneLabel = "Schedule", + children, + defaultOpen = false, + open: controlledOpen, +}: TimezoneConversionTooltipProps) { + const [internalOpen, setInternalOpen] = useState(defaultOpen); + const open = controlledOpen ?? internalOpen; + const [portalContainer, setPortalContainer] = useState( + null, + ); + const closeTimer = useRef(null); + useEffect(() => { + setPortalContainer(document.getElementById("portal-container")); + return () => { + if (closeTimer.current !== null) window.clearTimeout(closeTimer.current); + }; + }, []); + const date = timestamp instanceof Date ? timestamp : new Date(timestamp); + if (Number.isNaN(date.getTime())) return children; + + const keepOpen = () => { + if (closeTimer.current !== null) window.clearTimeout(closeTimer.current); + if (controlledOpen === undefined) setInternalOpen(true); + }; + + const scheduleClose = () => { + if (closeTimer.current !== null) window.clearTimeout(closeTimer.current); + closeTimer.current = window.setTimeout(() => { + if (controlledOpen === undefined) setInternalOpen(false); + }, 150); + }; + + const deviceTimezone = systemTimezone(); + const rows = [ + { + label: "Your device", + timezone: deviceTimezone, + value: formatTimestampInTimezone(date, deviceTimezone), + icon: Laptop, + }, + { + label: timezoneLabel, + timezone, + value: formatTimestampInTimezone(date, timezone), + icon: House, + }, + { + label: "UTC", + timezone: "UTC", + value: formatTimestampInTimezone(date, "UTC"), + icon: Globe, + }, + { + label: "UNIX", + timezone: "UTC", + value: String(Math.floor(date.getTime() / 1000)), + icon: Terminal, + }, + ]; + + return ( + { + if (controlledOpen === undefined) setInternalOpen(nextOpen); + }} + > + + {children} + + } + /> + +
+ {rows.map((row) => { + const abbreviation = + row.label === "UNIX" + ? "" + : formatTimezoneAbbreviation(row.timezone, date); + return ( + + ); + })} +
+
+
+ ); +} diff --git a/packages/ui/src/primitives/TimezonePicker.tsx b/packages/ui/src/primitives/TimezonePicker.tsx new file mode 100644 index 0000000000..a92ff268ed --- /dev/null +++ b/packages/ui/src/primitives/TimezonePicker.tsx @@ -0,0 +1,91 @@ +import { CaretDown } from "@phosphor-icons/react"; +import { + Button, + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, + ComboboxTrigger, +} from "@posthog/quill"; +import { useRef } from "react"; +import { type TimezoneOption, timezoneOptions } from "./timezone"; + +interface TimezonePickerProps { + value: string; + onValueChange: (value: string) => void; + disabled?: boolean; + className?: string; +} + +export function TimezonePicker({ + value, + onValueChange, + disabled, + className, +}: TimezonePickerProps) { + const options = timezoneOptions(); + const selectedOption = + options.find((option) => option.value === value) ?? null; + const anchorRef = useRef(null); + + return ( +
+ + items={options} + value={selectedOption} + disabled={disabled} + onValueChange={(option) => { + if (option) onValueChange(option.value); + }} + > + + + {selectedOption?.label ?? value} + + + + } + /> + + + No timezones found. + + {(option: TimezoneOption) => ( + + {option.label} + + )} + + + +
+ ); +} diff --git a/packages/ui/src/primitives/TimezoneTimestamp.stories.tsx b/packages/ui/src/primitives/TimezoneTimestamp.stories.tsx new file mode 100644 index 0000000000..a662b638a9 --- /dev/null +++ b/packages/ui/src/primitives/TimezoneTimestamp.stories.tsx @@ -0,0 +1,17 @@ +import { TimezoneTimestamp } from "@posthog/ui/primitives/TimezoneTimestamp"; +import type { Meta, StoryObj } from "@storybook/react-vite"; + +const meta: Meta = { + title: "Components/UI/TimezoneTimestamp", + component: TimezoneTimestamp, + parameters: { layout: "centered" }, + args: { + timestamp: new Date("2026-07-23T01:00:00.000Z"), + timezone: "America/Toronto", + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/packages/ui/src/primitives/TimezoneTimestamp.tsx b/packages/ui/src/primitives/TimezoneTimestamp.tsx new file mode 100644 index 0000000000..b691ebeebf --- /dev/null +++ b/packages/ui/src/primitives/TimezoneTimestamp.tsx @@ -0,0 +1,45 @@ +import { TimezoneConversionTooltip } from "./TimezoneConversionTooltip"; + +interface TimezoneTimestampProps { + timestamp: string | number | Date; + timezone: string; + label?: string; + className?: string; +} + +export function TimezoneTimestamp({ + timestamp, + timezone, + label, + className, +}: TimezoneTimestampProps) { + const date = timestamp instanceof Date ? timestamp : new Date(timestamp); + if (Number.isNaN(date.getTime())) return null; + + let displayLabel = label; + if (!displayLabel) { + try { + displayLabel = new Intl.DateTimeFormat(undefined, { + timeZone: timezone, + weekday: "short", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + timeZoneName: "short", + }).format(date); + } catch { + displayLabel = date.toLocaleString(); + } + } + + return ( + + + {displayLabel} + + + ); +} diff --git a/packages/ui/src/primitives/nextRecurringRun.ts b/packages/ui/src/primitives/nextRecurringRun.ts new file mode 100644 index 0000000000..a9277c6a90 --- /dev/null +++ b/packages/ui/src/primitives/nextRecurringRun.ts @@ -0,0 +1,70 @@ +export type RecurringScheduleFrequency = + | "hourly" + | "daily" + | "weekdays" + | "weekly"; + +export interface RecurringSchedule { + frequency: RecurringScheduleFrequency; + time: string; + weekday: string; +} + +export function nextRecurringRun( + schedule: RecurringSchedule, + timezone: string, + now = new Date(), +): Date | null { + let formatter: Intl.DateTimeFormat; + try { + formatter = new Intl.DateTimeFormat("en-US", { + timeZone: timezone, + hourCycle: "h23", + hour: "2-digit", + minute: "2-digit", + weekday: "short", + }); + } catch { + return null; + } + + const weekdayNumbers: Record = { + Sun: "0", + Mon: "1", + Tue: "2", + Wed: "3", + Thu: "4", + Fri: "5", + Sat: "6", + }; + const [targetHour, targetMinute] = schedule.time.split(":").map(Number); + let candidateMs = Math.floor(now.getTime() / 60_000) * 60_000 + 60_000; + + for (let iteration = 0; iteration < 8 * 24 + 2; iteration += 1) { + const parts = Object.fromEntries( + formatter + .formatToParts(new Date(candidateMs)) + .filter(({ type }) => type !== "literal") + .map(({ type, value }) => [type, value]), + ); + const minuteAdjustment = (targetMinute - Number(parts.minute) + 60) % 60; + if (minuteAdjustment > 0) { + candidateMs += minuteAdjustment * 60_000; + continue; + } + + const weekday = weekdayNumbers[parts.weekday]; + const matchesHour = + schedule.frequency === "hourly" || Number(parts.hour) === targetHour; + const matchesWeekday = + schedule.frequency !== "weekdays" || (weekday !== "0" && weekday !== "6"); + const matchesWeekly = + schedule.frequency !== "weekly" || weekday === schedule.weekday; + if (matchesHour && matchesWeekday && matchesWeekly) { + return new Date(candidateMs); + } + candidateMs += 60 * 60_000; + } + + return null; +} diff --git a/packages/ui/src/primitives/timezone.test.ts b/packages/ui/src/primitives/timezone.test.ts new file mode 100644 index 0000000000..f594fd8aa7 --- /dev/null +++ b/packages/ui/src/primitives/timezone.test.ts @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + formatTimestampInTimezone, + formatTimezoneAbbreviation, + formatTimezoneLabel, + systemTimezone, + timezoneOptions, +} from "./timezone"; + +describe("systemTimezone", () => { + afterEach(() => vi.restoreAllMocks()); + + it("returns the runtime IANA timezone", () => { + vi.spyOn(Intl, "DateTimeFormat").mockReturnValue({ + resolvedOptions: () => ({ timeZone: "America/New_York" }), + } as Intl.DateTimeFormat); + + expect(systemTimezone()).toBe("America/New_York"); + }); + + it("falls back to UTC when timezone detection fails", () => { + vi.spyOn(Intl, "DateTimeFormat").mockImplementation(() => { + throw new Error("unavailable"); + }); + + expect(systemTimezone()).toBe("UTC"); + }); +}); + +describe("timezone options", () => { + it("formats a timestamp in the requested timezone", () => { + const formatted = formatTimestampInTimezone( + new Date("2026-07-22T16:00:00.000Z"), + "America/Toronto", + ); + + expect(formatted).toContain("12:00 PM"); + }); + + it("formats IANA names with their current UTC offset", () => { + expect(formatTimezoneLabel("America/Toronto")).toMatch( + /^America \/ Toronto \(UTC-0[45]:00\)$/, + ); + }); + + it.each([ + ["2026-07-22T12:00:00.000Z", "EDT"], + ["2026-01-22T12:00:00.000Z", "EST"], + ])("formats Toronto's abbreviation on %s", (date, expected) => { + expect(formatTimezoneAbbreviation("America/Toronto", new Date(date))).toBe( + expected, + ); + }); + + it("includes the detected timezone and UTC", () => { + vi.spyOn(Intl, "DateTimeFormat").mockReturnValueOnce({ + resolvedOptions: () => ({ timeZone: "America/New_York" }), + } as Intl.DateTimeFormat); + + expect(timezoneOptions().map(({ value }) => value)).toEqual( + expect.arrayContaining(["America/New_York", "UTC"]), + ); + }); +}); diff --git a/packages/ui/src/primitives/timezone.ts b/packages/ui/src/primitives/timezone.ts new file mode 100644 index 0000000000..13c3aa7a5c --- /dev/null +++ b/packages/ui/src/primitives/timezone.ts @@ -0,0 +1,93 @@ +export interface TimezoneOption { + value: string; + label: string; +} + +let cachedTimezoneOptions: TimezoneOption[] | null = null; +let timezoneOptionsExpireAt = 0; + +export function systemTimezone(): string { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + } catch { + return "UTC"; + } +} + +export function formatTimezoneLabel(timezone: string): string { + const name = timezone.replaceAll("_", " ").replaceAll("/", " / "); + + try { + const offset = new Intl.DateTimeFormat("en-US", { + timeZone: timezone, + timeZoneName: "longOffset", + }) + .formatToParts() + .find((part) => part.type === "timeZoneName") + ?.value.replace("GMT", "UTC"); + return offset ? `${name} (${offset})` : name; + } catch { + return name; + } +} + +export function formatTimezoneAbbreviation( + timezone: string, + date = new Date(), +): string { + try { + return ( + new Intl.DateTimeFormat("en-US", { + timeZone: timezone, + timeZoneName: "short", + }) + .formatToParts(date) + .find((part) => part.type === "timeZoneName")?.value ?? timezone + ); + } catch { + return timezone; + } +} + +export function formatTimestampInTimezone( + date: Date, + timezone: string, +): string { + try { + return new Intl.DateTimeFormat(undefined, { + timeZone: timezone, + weekday: "short", + month: "short", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "2-digit", + }).format(date); + } catch { + return date.toLocaleString(); + } +} + +export function timezoneOptions(): TimezoneOption[] { + if (cachedTimezoneOptions && Date.now() < timezoneOptionsExpireAt) { + return cachedTimezoneOptions; + } + + const localTimezone = systemTimezone(); + let timezones: string[]; + + try { + timezones = Intl.supportedValuesOf("timeZone"); + } catch { + timezones = []; + } + + cachedTimezoneOptions = [...new Set([localTimezone, "UTC", ...timezones])] + .sort((left, right) => left.localeCompare(right)) + .map((timezone) => ({ + value: timezone, + label: formatTimezoneLabel(timezone), + })); + timezoneOptionsExpireAt = Date.now() + 60 * 60 * 1000; + return cachedTimezoneOptions; +} From 35ef0959f862eb934e6b39fbd1a27238e1488cf7 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 21:29:31 +0000 Subject: [PATCH 3/6] chore(visual): update storybook baselines 6 updated Run: 0f67cae8-b6b4-4e64-8ae8-381a25901372 Co-authored-by: MattPua <3376526+MattPua@users.noreply.github.com> --- apps/code/snapshots.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/apps/code/snapshots.yml b/apps/code/snapshots.yml index 47392c6380..8de4fb1553 100644 --- a/apps/code/snapshots.yml +++ b/apps/code/snapshots.yml @@ -264,6 +264,18 @@ snapshots: hash: v1.k4693efd2.bbae772bcd59e545c1816dd0b32d4996b993a7a981691f0710aaaec48d504893.QeBzMnOZIT7FGv6KuyKrDmo2CaEvyBzKwj9DJluf2UI components-ui-combobox--with-search--light: hash: v1.k4693efd2.fed2cd543661a6ade6f836465a0ddacada5a2073f537d4b2b824918b57bb30a4.fpalvRPE3cetQ4_FQarE8PEfhkdCtcViPMsAMriDQDI + components-ui-timezoneconversiontooltip--dark--dark: + hash: v1.k4693efd2.50ab05ce0db7f840c42597f038e6acf835a47c9b4fdd2e2f84280ab91beea316.Chf3h-HHquoclZ0lak_lJgsIDzvjVoiUw6GfmG5CCU8 + components-ui-timezoneconversiontooltip--dark--light: + hash: v1.k4693efd2.1136dc2e5909de65274ec6ccbfdcecaea5ed47779ad2db4ba2d9307b26f1fb8e._NtdrEGgR7vCnwK6gsn1ZQPtqfNSIHpg0ZYXfGrAN1o + components-ui-timezoneconversiontooltip--light--dark: + hash: v1.k4693efd2.efab37759c71f5acbd23a327474aac8c9b4951dd19fa58cd7e408a5f87b09d7d.swN7uXSk62GDfFfwQFsZtZwlECoSefcb4W2ET9dIqRw + components-ui-timezoneconversiontooltip--light--light: + hash: v1.k4693efd2.e456c1afa34d753d08d83ce002e7dcafebba0049572f9da39e67d71fdddb8e05.J_kCP7Xt-OuBJQcqiQJZb1nTrKdazwHNxtATDTfoYrk + components-ui-timezonetimestamp--default--dark: + hash: v1.k4693efd2.d372047025c26e546561d5be18428ba2a34bdd00d7479156a48f39edd4fb21b6.IRUaKeF7HWOQrC_CueX5NgPy1L8wmXGJ8Eq_-_Ka07Y + components-ui-timezonetimestamp--default--light: + hash: v1.k4693efd2.cbe7ed21d83b8973fb37886771bddc38bf6e5cf7724ffa4102e5dce6c85f9686.j3F-0bfpM8IEuWOUhlUBhrLHSOk96YRtxrhUsx4SJd4 features-messageeditor-promptinput--all-chip-types--dark: hash: v1.k4693efd2.ee4386e086ae73a26460272d6673f2a4a2332b3fffee685474dac3794d4c42b6.-CCWbzKF6yZe9dsI3UrM406v7Ks20advTdOybi17OXk features-messageeditor-promptinput--all-chip-types--light: From ae1c5a94fd4a18f76ba3f0fad7d365cda8ee9468 Mon Sep 17 00:00:00 2001 From: Matt Pua Date: Wed, 22 Jul 2026 17:28:55 -0400 Subject: [PATCH 4/6] fix: prevent timezone popover reopening Generated-By: PostHog Code Task-Id: 231e46d4-90e5-4be2-af47-958398fcbf9c --- packages/ui/src/primitives/TimezoneConversionTooltip.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/ui/src/primitives/TimezoneConversionTooltip.tsx b/packages/ui/src/primitives/TimezoneConversionTooltip.tsx index 9d8b617225..c9b331eda0 100644 --- a/packages/ui/src/primitives/TimezoneConversionTooltip.tsx +++ b/packages/ui/src/primitives/TimezoneConversionTooltip.tsx @@ -152,8 +152,6 @@ export function TimezoneConversionTooltip({ className="inline-flex appearance-none border-0 bg-transparent p-0 text-inherit" onMouseEnter={keepOpen} onMouseLeave={scheduleClose} - onFocus={keepOpen} - onBlur={scheduleClose} > {children} From 621746bebf9fc3b23eb49fe3b043df4c741a2241 Mon Sep 17 00:00:00 2001 From: Matt Pua Date: Wed, 22 Jul 2026 17:48:28 -0400 Subject: [PATCH 5/6] fix: preserve loop schedule timezones Generated-By: PostHog Code Task-Id: a88a845a-e243-436a-a41d-edc0a65170fa --- .../loops/components/LoopTriggerEditor.tsx | 22 +++++----- .../primitives/TimezoneConversionTooltip.tsx | 15 +++---- packages/ui/src/primitives/TimezonePicker.tsx | 40 +++++++++++++++++-- .../ui/src/primitives/TimezoneTimestamp.tsx | 15 +------ packages/ui/src/primitives/timezone.test.ts | 13 +++++- packages/ui/src/primitives/timezone.ts | 31 ++++++++++++++ 6 files changed, 95 insertions(+), 41 deletions(-) diff --git a/packages/ui/src/features/loops/components/LoopTriggerEditor.tsx b/packages/ui/src/features/loops/components/LoopTriggerEditor.tsx index 232e335714..bac76e2c38 100644 --- a/packages/ui/src/features/loops/components/LoopTriggerEditor.tsx +++ b/packages/ui/src/features/loops/components/LoopTriggerEditor.tsx @@ -12,7 +12,10 @@ import { SettingsOptionSelect } from "@posthog/ui/features/settings/SettingsOpti import { Button } from "@posthog/ui/primitives/Button"; import { TimezonePicker } from "@posthog/ui/primitives/TimezonePicker"; import { TimezoneTimestamp } from "@posthog/ui/primitives/TimezoneTimestamp"; -import { systemTimezone } from "@posthog/ui/primitives/timezone"; +import { + formatScheduleTimestamp, + systemTimezone, +} from "@posthog/ui/primitives/timezone"; import { Box, Checkbox, Flex, IconButton, Text } from "@radix-ui/themes"; import { compileCronSchedule, @@ -261,19 +264,11 @@ function ScheduleTriggerFields({ : (parsed?.frequency ?? "daily"); const time = parsed?.time ?? DEFAULT_SCHEDULE_TIME; const weekday = parsed?.weekday ?? "1"; - const timezone = config.timezone || systemTimezone(); + const timezone = config.timezone ?? "UTC"; const nextRun = nextScheduleRun(config); const nextRunTimezone = frequency === "once" ? systemTimezone() : timezone; const nextRunLabel = nextRun - ? new Intl.DateTimeFormat(undefined, { - timeZone: nextRunTimezone, - weekday: "short", - month: "short", - day: "numeric", - hour: "numeric", - minute: "2-digit", - timeZoneName: "short", - }).format(nextRun) + ? formatScheduleTimestamp(nextRun, nextRunTimezone) : null; const frequencyOptions = isCustomCron ? [CUSTOM_FREQUENCY_OPTION, ...FREQUENCY_OPTIONS] @@ -299,7 +294,10 @@ function ScheduleTriggerFields({ if (next === "custom") return; if (next === "once") { // The backend rejects run_at values in the past; default an hour out. - onChange({ run_at: new Date(Date.now() + 60 * 60 * 1000).toISOString() }); + onChange({ + run_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + timezone, + }); return; } setRecurring(next, time, weekday); diff --git a/packages/ui/src/primitives/TimezoneConversionTooltip.tsx b/packages/ui/src/primitives/TimezoneConversionTooltip.tsx index c9b331eda0..04857c8e20 100644 --- a/packages/ui/src/primitives/TimezoneConversionTooltip.tsx +++ b/packages/ui/src/primitives/TimezoneConversionTooltip.tsx @@ -12,7 +12,7 @@ import { PopoverContent, PopoverTrigger, } from "@posthog/quill"; -import { type ReactNode, useEffect, useRef, useState } from "react"; +import { type ReactNode, useRef, useState } from "react"; import { formatTimestampInTimezone, formatTimezoneAbbreviation, @@ -85,16 +85,11 @@ export function TimezoneConversionTooltip({ }: TimezoneConversionTooltipProps) { const [internalOpen, setInternalOpen] = useState(defaultOpen); const open = controlledOpen ?? internalOpen; - const [portalContainer, setPortalContainer] = useState( - null, - ); + const portalContainer = + typeof document === "undefined" + ? null + : document.getElementById("portal-container"); const closeTimer = useRef(null); - useEffect(() => { - setPortalContainer(document.getElementById("portal-container")); - return () => { - if (closeTimer.current !== null) window.clearTimeout(closeTimer.current); - }; - }, []); const date = timestamp instanceof Date ? timestamp : new Date(timestamp); if (Number.isNaN(date.getTime())) return children; diff --git a/packages/ui/src/primitives/TimezonePicker.tsx b/packages/ui/src/primitives/TimezonePicker.tsx index a92ff268ed..2c7d6c5baf 100644 --- a/packages/ui/src/primitives/TimezonePicker.tsx +++ b/packages/ui/src/primitives/TimezonePicker.tsx @@ -9,8 +9,13 @@ import { ComboboxList, ComboboxTrigger, } from "@posthog/quill"; -import { useRef } from "react"; -import { type TimezoneOption, timezoneOptions } from "./timezone"; +import { useRef, useState } from "react"; +import { + formatTimezoneLabel, + isValidTimezone, + type TimezoneOption, + timezoneOptions, +} from "./timezone"; interface TimezonePickerProps { value: string; @@ -29,6 +34,12 @@ export function TimezonePicker({ const selectedOption = options.find((option) => option.value === value) ?? null; const anchorRef = useRef(null); + const [search, setSearch] = useState(""); + const customTimezone = search.trim(); + const canUseCustomTimezone = + customTimezone !== "" && + !options.some((option) => option.value === customTimezone) && + isValidTimezone(customTimezone); return (
@@ -37,7 +48,10 @@ export function TimezonePicker({ value={selectedOption} disabled={disabled} onValueChange={(option) => { - if (option) onValueChange(option.value); + if (option) { + onValueChange(option.value); + setSearch(""); + } }} > - No timezones found. + + {canUseCustomTimezone ? ( + + ) : ( + "No timezones found." + )} + {(option: TimezoneOption) => ( { - afterEach(() => vi.restoreAllMocks()); +afterEach(() => vi.restoreAllMocks()); +describe("systemTimezone", () => { it("returns the runtime IANA timezone", () => { vi.spyOn(Intl, "DateTimeFormat").mockReturnValue({ resolvedOptions: () => ({ timeZone: "America/New_York" }), @@ -61,4 +62,12 @@ describe("timezone options", () => { expect.arrayContaining(["America/New_York", "UTC"]), ); }); + + it.each([ + ["Europe/London", true], + ["America/Toronto", true], + ["Not/A_Timezone", false], + ])("validates %s", (timezone, expected) => { + expect(isValidTimezone(timezone)).toBe(expected); + }); }); diff --git a/packages/ui/src/primitives/timezone.ts b/packages/ui/src/primitives/timezone.ts index 13c3aa7a5c..dcbd1f2fae 100644 --- a/packages/ui/src/primitives/timezone.ts +++ b/packages/ui/src/primitives/timezone.ts @@ -5,6 +5,7 @@ export interface TimezoneOption { let cachedTimezoneOptions: TimezoneOption[] | null = null; let timezoneOptionsExpireAt = 0; +const scheduleTimestampFormatters = new Map(); export function systemTimezone(): string { try { @@ -68,6 +69,36 @@ export function formatTimestampInTimezone( } } +export function formatScheduleTimestamp(date: Date, timezone: string): string { + try { + let formatter = scheduleTimestampFormatters.get(timezone); + if (!formatter) { + formatter = new Intl.DateTimeFormat(undefined, { + timeZone: timezone, + weekday: "short", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + timeZoneName: "short", + }); + scheduleTimestampFormatters.set(timezone, formatter); + } + return formatter.format(date); + } catch { + return date.toLocaleString(); + } +} + +export function isValidTimezone(timezone: string): boolean { + try { + new Intl.DateTimeFormat("en-US", { timeZone: timezone }).format(); + return true; + } catch { + return false; + } +} + export function timezoneOptions(): TimezoneOption[] { if (cachedTimezoneOptions && Date.now() < timezoneOptionsExpireAt) { return cachedTimezoneOptions; From 9436f41ff30078f8ddb84138b919007161ff4023 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Wed, 22 Jul 2026 21:39:26 -0700 Subject: [PATCH 6/6] adapt timezone picker to quill combobox api --- packages/ui/src/primitives/TimezonePicker.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/primitives/TimezonePicker.tsx b/packages/ui/src/primitives/TimezonePicker.tsx index 2c7d6c5baf..7da4b11a89 100644 --- a/packages/ui/src/primitives/TimezonePicker.tsx +++ b/packages/ui/src/primitives/TimezonePicker.tsx @@ -53,6 +53,8 @@ export function TimezonePicker({ setSearch(""); } }} + inputValue={search} + onInputValueChange={(next) => setSearch(next ?? "")} > {canUseCustomTimezone ? (