diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 8ce843ba2a..ff03602e7e 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -290,6 +290,7 @@ export type { TaskCreationOutput, } from "./task-creation-domain"; export { + formatClockTime, formatRelativeTimeLong, formatRelativeTimeShort, getLocalDayDiff, diff --git a/packages/shared/src/time.test.ts b/packages/shared/src/time.test.ts index 827afc5c77..d0a69f92ec 100644 --- a/packages/shared/src/time.test.ts +++ b/packages/shared/src/time.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { + formatClockTime, formatRelativeTimeLong, formatRelativeTimeShort, getLocalDayDiff, @@ -11,6 +12,17 @@ const MINUTE = 60_000; const HOUR = 3_600_000; const DAY = 86_400_000; +describe("formatClockTime", () => { + it.each([ + ["00:00", "12:00 AM"], + ["08:15", "8:15 AM"], + ["11:00", "11:00 AM"], + ["17:30", "5:30 PM"], + ])("formats %s as %s", (time, expected) => { + expect(formatClockTime(time)).toBe(expected); + }); +}); + beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(NOW); diff --git a/packages/shared/src/time.ts b/packages/shared/src/time.ts index dcbb4090da..3cfd21ba65 100644 --- a/packages/shared/src/time.ts +++ b/packages/shared/src/time.ts @@ -1,3 +1,12 @@ +export function formatClockTime(time: string): string { + const [hour, minute] = time.split(":").map(Number); + return new Intl.DateTimeFormat("en-US", { + hour: "numeric", + minute: "2-digit", + timeZone: "UTC", + }).format(new Date(Date.UTC(2000, 0, 1, hour, minute))); +} + /** * Format a timestamp as a short relative string (e.g. "3m", "2h", "5d"). * Accepts either a Unix ms timestamp or an ISO date string. diff --git a/packages/ui/src/features/loops/components/LoopDetailView.tsx b/packages/ui/src/features/loops/components/LoopDetailView.tsx index f16b8b85bf..97a6ab4cff 100644 --- a/packages/ui/src/features/loops/components/LoopDetailView.tsx +++ b/packages/ui/src/features/loops/components/LoopDetailView.tsx @@ -12,6 +12,7 @@ import { import { AlertDialog, Flex, Text } from "@radix-ui/themes"; import { useState } from "react"; import { useLoop } from "../hooks/useLoop"; +import { useLoopDisplayModel } from "../hooks/useLoopDisplayModel"; import { useDeleteLoop, useRunLoop, @@ -237,6 +238,8 @@ export function LoopDetailView({ loopId }: { loopId: string }) { } function ConfigSummarySection({ loop }: { loop: LoopSchemas.Loop }) { + const displayModel = useLoopDisplayModel(loop.runtime_adapter, loop.model); + return ( @@ -251,7 +254,7 @@ function ConfigSummarySection({ loop }: { loop: LoopSchemas.Loop }) { {[ loop.runtime_adapter, - loop.model, + displayModel, loop.reasoning_effort ? `${loop.reasoning_effort} reasoning` : null, ] .filter(Boolean) diff --git a/packages/ui/src/features/loops/hooks/useLoopDisplayModel.ts b/packages/ui/src/features/loops/hooks/useLoopDisplayModel.ts new file mode 100644 index 0000000000..a63d7660dc --- /dev/null +++ b/packages/ui/src/features/loops/hooks/useLoopDisplayModel.ts @@ -0,0 +1,31 @@ +import type { LoopSchemas } from "@posthog/api-client/loops"; +import { + REPORT_MODEL_RESOLVER, + type ReportModelResolver, +} from "@posthog/core/inbox/identifiers"; +import { useService } from "@posthog/di/react"; +import { getCloudUrlFromRegion } from "@posthog/shared"; +import { useAuthStateValue } from "@posthog/ui/features/auth/store"; +import { useQuery } from "@tanstack/react-query"; + +export function useLoopDisplayModel( + adapter: LoopSchemas.LoopRuntimeAdapterEnum, + configuredModel: string, +): string { + const cloudRegion = useAuthStateValue((state) => state.cloudRegion); + const modelResolver = useService(REPORT_MODEL_RESOLVER); + const { data } = useQuery({ + queryKey: ["loops", "default-model", cloudRegion, adapter], + queryFn: () => { + if (!cloudRegion) return undefined; + return modelResolver.resolveDefaultModel( + getCloudUrlFromRegion(cloudRegion), + adapter, + ); + }, + enabled: !configuredModel && !!cloudRegion, + staleTime: 5 * 60_000, + }); + + return configuredModel || data || "Default model"; +} diff --git a/packages/ui/src/features/loops/loopDisplay.test.ts b/packages/ui/src/features/loops/loopDisplay.test.ts new file mode 100644 index 0000000000..03e585b7a7 --- /dev/null +++ b/packages/ui/src/features/loops/loopDisplay.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { describeTrigger } from "./loopDisplay"; + +describe("describeTrigger", () => { + it.each([ + ["0 * * * *", "Every hour (UTC)"], + ["30 9 * * *", "Daily at 9:30 AM (UTC)"], + ["0 11 * * 1-5", "Weekdays at 11:00 AM (UTC)"], + ["15 8 * * 3", "Wednesdays at 8:15 AM (UTC)"], + ])("formats %s as a readable schedule", (cronExpression, expected) => { + expect( + describeTrigger({ + type: "schedule", + config: { cron_expression: cronExpression, timezone: "UTC" }, + }), + ).toBe(`Schedule · ${expected}`); + }); + + it("keeps custom cron expressions visible", () => { + expect( + describeTrigger({ + type: "schedule", + config: { cron_expression: "*/15 * * * *", timezone: "UTC" }, + }), + ).toBe("Schedule · */15 * * * * (UTC)"); + }); +}); diff --git a/packages/ui/src/features/loops/loopDisplay.ts b/packages/ui/src/features/loops/loopDisplay.ts index 382f5fbec5..beacd6958f 100644 --- a/packages/ui/src/features/loops/loopDisplay.ts +++ b/packages/ui/src/features/loops/loopDisplay.ts @@ -1,4 +1,32 @@ import type { LoopSchemas } from "@posthog/api-client/loops"; +import { formatClockTime } from "@posthog/shared"; +import { parseCronSchedule } from "./loopCron"; + +const WEEKDAY_NAMES: Record = { + "0": "Sunday", + "1": "Monday", + "2": "Tuesday", + "3": "Wednesday", + "4": "Thursday", + "5": "Friday", + "6": "Saturday", +}; + +function describeSchedule( + config: LoopSchemas.LoopScheduleTriggerConfig, +): string { + 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 time = formatClockTime(parsed.time); + if (parsed.frequency === "daily") return `Daily at ${time} (${timezone})`; + if (parsed.frequency === "weekdays") + return `Weekdays at ${time} (${timezone})`; + return `${WEEKDAY_NAMES[parsed.weekday]}s at ${time} (${timezone})`; +} export function loopStatusColor( loop: LoopSchemas.Loop, @@ -39,7 +67,7 @@ export function describeTrigger(trigger: TriggerLike): string { const config = trigger.config as LoopSchemas.LoopScheduleTriggerConfig; if (config.run_at) return `One-time · ${new Date(config.run_at).toLocaleString()}`; - return `Schedule · ${config.cron_expression ?? "?"} (${config.timezone ?? "UTC"})`; + return `Schedule · ${describeSchedule(config)}`; } if (trigger.type === "github") { const config = trigger.config as LoopSchemas.LoopGithubTriggerConfig;