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; +}