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/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:
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 eb900993d2..bac76e2c38 100644
--- a/packages/ui/src/features/loops/components/LoopTriggerEditor.tsx
+++ b/packages/ui/src/features/loops/components/LoopTriggerEditor.tsx
@@ -10,6 +10,12 @@ 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 {
+ formatScheduleTimestamp,
+ systemTimezone,
+} from "@posthog/ui/primitives/timezone";
import { Box, Checkbox, Flex, IconButton, Text } from "@radix-ui/themes";
import {
compileCronSchedule,
@@ -17,6 +23,7 @@ import {
parseCronSchedule,
type RecurringFrequency,
} from "../loopCron";
+import { nextScheduleRun } from "../loopDisplay";
import {
defaultLoopScheduleTrigger,
emptyLoopApiTriggerConfig,
@@ -117,6 +124,7 @@ export function LoopTriggerEditor({
variant="outline"
color="gray"
size="1"
+ className="self-start"
disabled={disabled}
onClick={addTrigger}
>
@@ -235,14 +243,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 +264,12 @@ 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 ?? "UTC";
+ const nextRun = nextScheduleRun(config);
+ const nextRunTimezone = frequency === "once" ? systemTimezone() : timezone;
+ const nextRunLabel = nextRun
+ ? formatScheduleTimestamp(nextRun, nextRunTimezone)
+ : null;
const frequencyOptions = isCustomCron
? [CUSTOM_FREQUENCY_OPTION, ...FREQUENCY_OPTIONS]
: FREQUENCY_OPTIONS;
@@ -289,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);
@@ -369,7 +377,27 @@ function ScheduleTriggerFields({
{frequency !== "once" ? (
- Times in {timezone}
+
+ 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 e7fbee6ca0..4825680804 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 "@posthog/ui/primitives/timezone";
/**
* 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..ab361e7212 100644
--- a/packages/ui/src/features/loops/loopTemplates.ts
+++ b/packages/ui/src/features/loops/loopTemplates.ts
@@ -13,6 +13,7 @@ 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,
@@ -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/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..04857c8e20
--- /dev/null
+++ b/packages/ui/src/primitives/TimezoneConversionTooltip.tsx
@@ -0,0 +1,186 @@
+import {
+ Check,
+ Copy,
+ Globe,
+ House,
+ Laptop,
+ Terminal,
+} from "@phosphor-icons/react";
+import {
+ Button,
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@posthog/quill";
+import { type ReactNode, 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 =
+ typeof document === "undefined"
+ ? null
+ : document.getElementById("portal-container");
+ const closeTimer = useRef(null);
+ 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..7da4b11a89
--- /dev/null
+++ b/packages/ui/src/primitives/TimezonePicker.tsx
@@ -0,0 +1,123 @@
+import { CaretDown } from "@phosphor-icons/react";
+import {
+ Button,
+ Combobox,
+ ComboboxContent,
+ ComboboxEmpty,
+ ComboboxInput,
+ ComboboxItem,
+ ComboboxList,
+ ComboboxTrigger,
+} from "@posthog/quill";
+import { useRef, useState } from "react";
+import {
+ formatTimezoneLabel,
+ isValidTimezone,
+ 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);
+ const [search, setSearch] = useState("");
+ const customTimezone = search.trim();
+ const canUseCustomTimezone =
+ customTimezone !== "" &&
+ !options.some((option) => option.value === customTimezone) &&
+ isValidTimezone(customTimezone);
+
+ return (
+
+
+ items={options}
+ value={selectedOption}
+ disabled={disabled}
+ onValueChange={(option) => {
+ if (option) {
+ onValueChange(option.value);
+ setSearch("");
+ }
+ }}
+ inputValue={search}
+ onInputValueChange={(next) => setSearch(next ?? "")}
+ >
+
+
+ {selectedOption?.label ?? value}
+
+
+
+ }
+ />
+
+
+
+ {canUseCustomTimezone ? (
+
+ ) : (
+ "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..3b50713027
--- /dev/null
+++ b/packages/ui/src/primitives/TimezoneTimestamp.tsx
@@ -0,0 +1,34 @@
+import { TimezoneConversionTooltip } from "./TimezoneConversionTooltip";
+import { formatScheduleTimestamp } from "./timezone";
+
+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) {
+ displayLabel = formatScheduleTimestamp(date, timezone);
+ }
+
+ 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..f4d6cfe91a
--- /dev/null
+++ b/packages/ui/src/primitives/timezone.test.ts
@@ -0,0 +1,73 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import {
+ formatTimestampInTimezone,
+ formatTimezoneAbbreviation,
+ formatTimezoneLabel,
+ isValidTimezone,
+ systemTimezone,
+ timezoneOptions,
+} from "./timezone";
+
+afterEach(() => vi.restoreAllMocks());
+
+describe("systemTimezone", () => {
+ 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"]),
+ );
+ });
+
+ 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
new file mode 100644
index 0000000000..dcbd1f2fae
--- /dev/null
+++ b/packages/ui/src/primitives/timezone.ts
@@ -0,0 +1,124 @@
+export interface TimezoneOption {
+ value: string;
+ label: string;
+}
+
+let cachedTimezoneOptions: TimezoneOption[] | null = null;
+let timezoneOptionsExpireAt = 0;
+const scheduleTimestampFormatters = new Map();
+
+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 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;
+ }
+
+ 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;
+}