Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/code/.storybook/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ const preview: Preview = {
scaling="105%"
>
<Story />
{/* Match ThemeWrapper so portaled Quill components inherit the story's active theme. */}
<div id="portal-container" />
</Theme>
);
},
Expand Down
12 changes: 12 additions & 0 deletions apps/code/snapshots.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
31 changes: 30 additions & 1 deletion packages/ui/src/features/loops/components/LoopDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -36,6 +38,7 @@ import {
describeTrigger,
loopStatusColor,
loopStatusLabel,
nextScheduleRun,
summarizeNotificationDestinations,
} from "../loopDisplay";
import { LoopLoadError } from "./LoopFallbacks";
Expand Down Expand Up @@ -329,7 +332,7 @@ function ConfigSummarySection({ loop }: { loop: LoopSchemas.Loop }) {
<Flex direction="column" gap="1">
{loop.triggers.map((trigger) => (
<Text key={trigger.id} className="text-[12.5px] text-gray-12">
{describeTrigger(trigger)}
<TriggerDescription trigger={trigger} />
{!trigger.enabled ? " (disabled)" : ""}
</Text>
))}
Expand Down Expand Up @@ -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}
<TimezoneTimestamp
timestamp={nextRun}
timezone={timezone}
label={nextRunDescription}
/>
</>
);
}

function SummaryRow({
label,
children,
Expand Down
50 changes: 39 additions & 11 deletions packages/ui/src/features/loops/components/LoopTriggerEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,20 @@ 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,
DEFAULT_SCHEDULE_TIME,
parseCronSchedule,
type RecurringFrequency,
} from "../loopCron";
import { nextScheduleRun } from "../loopDisplay";
import {
defaultLoopScheduleTrigger,
emptyLoopApiTriggerConfig,
Expand Down Expand Up @@ -117,6 +124,7 @@ export function LoopTriggerEditor({
variant="outline"
color="gray"
size="1"
className="self-start"
disabled={disabled}
onClick={addTrigger}
>
Expand Down Expand Up @@ -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,
Expand All @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -369,7 +377,27 @@ function ScheduleTriggerFields({
</Flex>

{frequency !== "once" ? (
<Text className="text-[11px] text-gray-9">Times in {timezone}</Text>
<Flex direction="column" gap="1">
<Text className="text-[11px] text-gray-9">Timezone</Text>
<TimezonePicker
value={timezone}
disabled={disabled}
className="w-[240px] max-w-full"
onValueChange={(value) => onChange({ ...config, timezone: value })}
/>
</Flex>
) : null}

{nextRun && nextRunLabel ? (
<Flex align="center" gap="2" className="text-[12px]">
<Text className="text-gray-10">Next run</Text>
<TimezoneTimestamp
timestamp={nextRun}
timezone={nextRunTimezone}
label={nextRunLabel}
className="text-gray-12"
/>
</Flex>
) : null}
</Flex>
);
Expand Down
3 changes: 3 additions & 0 deletions packages/ui/src/features/loops/loopCron.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
18 changes: 11 additions & 7 deletions packages/ui/src/features/loops/loopCron.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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" };
Expand Down
61 changes: 59 additions & 2 deletions packages/ui/src/features/loops/loopDisplay.test.ts
Original file line number Diff line number Diff line change
@@ -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)"],
Expand All @@ -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", () => {
Expand Down Expand Up @@ -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");
});
});
Loading
Loading