From bf2f53c09ca797fb3e2476ef03b9f9cafeb49237 Mon Sep 17 00:00:00 2001 From: Matt Pua Date: Wed, 22 Jul 2026 10:21:09 -0400 Subject: [PATCH 1/2] fix: improve loop run and schedule display Generated-By: PostHog Code Task-Id: 7fc28633-4525-4dd9-b8b4-217dab72b7d7 --- packages/shared/src/index.ts | 1 + packages/shared/src/time.test.ts | 12 ++++ packages/shared/src/time.ts | 9 +++ .../loops/components/LoopDetailView.tsx | 5 +- .../loops/hooks/useLoopDisplayModel.ts | 31 ++++++++ .../features/loops/hooks/useLoopRuns.test.ts | 70 +++++++++++++++++++ .../src/features/loops/hooks/useLoopRuns.ts | 53 +++++++++++++- .../ui/src/features/loops/loopDisplay.test.ts | 27 +++++++ packages/ui/src/features/loops/loopDisplay.ts | 30 +++++++- 9 files changed, 235 insertions(+), 3 deletions(-) create mode 100644 packages/ui/src/features/loops/hooks/useLoopDisplayModel.ts create mode 100644 packages/ui/src/features/loops/hooks/useLoopRuns.test.ts create mode 100644 packages/ui/src/features/loops/loopDisplay.test.ts 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/hooks/useLoopRuns.test.ts b/packages/ui/src/features/loops/hooks/useLoopRuns.test.ts new file mode 100644 index 0000000000..38f42bd7ca --- /dev/null +++ b/packages/ui/src/features/loops/hooks/useLoopRuns.test.ts @@ -0,0 +1,70 @@ +import type { LoopSchemas } from "@posthog/api-client/loops"; +import type { Task } from "@posthog/shared/domain-types"; +import { describe, expect, it } from "vitest"; +import { reconcileLoopRunStatus } from "./useLoopRuns"; + +const loopRun = { + id: "loop-run-1", + task_id: "task-1", + loop_trigger_id: null, + status: "in_progress", + environment: "cloud", + branch: null, + error_message: null, + output: null, + created_at: "2026-07-22T12:00:00Z", + completed_at: null, +} satisfies LoopSchemas.LoopRun; + +function taskWithRun( + status: "in_progress" | "completed" | "failed" | "cancelled", +): Task { + return { + id: "task-1", + task_number: 1, + slug: "loop-task", + title: "Loop task", + description: "", + created_at: loopRun.created_at, + updated_at: loopRun.created_at, + origin_product: "user_created", + latest_run: { + id: "task-run-1", + task: "task-1", + team: 2, + branch: null, + environment: "cloud", + status, + log_url: "", + error_message: status === "failed" ? "Run failed" : null, + output: null, + state: {}, + created_at: loopRun.created_at, + updated_at: "2026-07-22T12:01:00Z", + completed_at: "2026-07-22T12:01:00Z", + }, + }; +} + +describe("reconcileLoopRunStatus", () => { + it.each(["cancelled", "failed"] as const)( + "uses the terminal task status when loop history is stale: %s", + (status) => { + expect(reconcileLoopRunStatus(loopRun, taskWithRun(status))).toEqual( + expect.objectContaining({ + status, + completed_at: "2026-07-22T12:01:00Z", + }), + ); + }, + ); + + it.each(["in_progress", "completed"] as const)( + "keeps loop history unchanged for task status: %s", + (status) => { + expect(reconcileLoopRunStatus(loopRun, taskWithRun(status))).toBe( + loopRun, + ); + }, + ); +}); diff --git a/packages/ui/src/features/loops/hooks/useLoopRuns.ts b/packages/ui/src/features/loops/hooks/useLoopRuns.ts index d7f0a5a341..03ff616b62 100644 --- a/packages/ui/src/features/loops/hooks/useLoopRuns.ts +++ b/packages/ui/src/features/loops/hooks/useLoopRuns.ts @@ -1,4 +1,6 @@ import { type LoopSchemas, listLoopRuns } from "@posthog/api-client/loops"; +import type { Task } from "@posthog/shared/domain-types"; +import { getAuthenticatedClient } from "@posthog/ui/features/auth/authClientImperative"; import { AUTH_SCOPED_QUERY_META } from "@posthog/ui/features/auth/useCurrentUser"; import { useQuery } from "@tanstack/react-query"; import { loopsKeys } from "./loopsKeys"; @@ -6,6 +8,24 @@ import { useLoopsClient } from "./useLoopsClient"; export const RECENT_RUNS_LIMIT = 10; +export function reconcileLoopRunStatus( + run: LoopSchemas.LoopRun, + task: Task, +): LoopSchemas.LoopRun { + const latestRun = task.latest_run; + if (run.status !== "in_progress" || !latestRun) return run; + if (latestRun.status !== "cancelled" && latestRun.status !== "failed") { + return run; + } + + return { + ...run, + status: latestRun.status, + completed_at: latestRun.completed_at, + error_message: latestRun.error_message, + }; +} + /** The most recent runs for a loop, polled so the detail view stays live. */ export function useLoopRuns(loopId: string | undefined) { const loopsClient = useLoopsClient(); @@ -14,12 +34,43 @@ export function useLoopRuns(loopId: string | undefined) { queryKey: loopsKeys.runs(loopsClient?.projectId ?? null, loopId ?? ""), queryFn: async () => { if (!loopsClient || !loopId) throw new Error("Not authenticated"); - return await listLoopRuns( + const page = await listLoopRuns( loopsClient.client, loopsClient.projectId, loopId, { limit: RECENT_RUNS_LIMIT }, ); + const activeRuns = page.results.filter( + (run) => run.status === "in_progress", + ); + if (activeRuns.length === 0) return page; + + const taskClient = await getAuthenticatedClient(); + if (!taskClient) return page; + + const tasks = await Promise.allSettled( + activeRuns.map(async (run) => ({ + run, + task: (await taskClient.getTask(run.task_id)) as unknown as Task, + })), + ); + const reconciled = new Map( + tasks.flatMap((result) => + result.status === "fulfilled" + ? [ + [ + result.value.run.id, + reconcileLoopRunStatus(result.value.run, result.value.task), + ] as const, + ] + : [], + ), + ); + + return { + ...page, + results: page.results.map((run) => reconciled.get(run.id) ?? run), + }; }, select: (page) => page.results.slice(0, RECENT_RUNS_LIMIT), enabled: !!loopsClient && !!loopId, 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; From fa2c73660d589c282b4a1f9920a9a3c0063b5a10 Mon Sep 17 00:00:00 2001 From: Matt Pua Date: Wed, 22 Jul 2026 10:26:42 -0400 Subject: [PATCH 2/2] fix: keep loop display PR scoped Generated-By: PostHog Code Task-Id: 7fc28633-4525-4dd9-b8b4-217dab72b7d7 --- .../features/loops/hooks/useLoopRuns.test.ts | 70 ------------------- .../src/features/loops/hooks/useLoopRuns.ts | 53 +------------- 2 files changed, 1 insertion(+), 122 deletions(-) delete mode 100644 packages/ui/src/features/loops/hooks/useLoopRuns.test.ts diff --git a/packages/ui/src/features/loops/hooks/useLoopRuns.test.ts b/packages/ui/src/features/loops/hooks/useLoopRuns.test.ts deleted file mode 100644 index 38f42bd7ca..0000000000 --- a/packages/ui/src/features/loops/hooks/useLoopRuns.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { LoopSchemas } from "@posthog/api-client/loops"; -import type { Task } from "@posthog/shared/domain-types"; -import { describe, expect, it } from "vitest"; -import { reconcileLoopRunStatus } from "./useLoopRuns"; - -const loopRun = { - id: "loop-run-1", - task_id: "task-1", - loop_trigger_id: null, - status: "in_progress", - environment: "cloud", - branch: null, - error_message: null, - output: null, - created_at: "2026-07-22T12:00:00Z", - completed_at: null, -} satisfies LoopSchemas.LoopRun; - -function taskWithRun( - status: "in_progress" | "completed" | "failed" | "cancelled", -): Task { - return { - id: "task-1", - task_number: 1, - slug: "loop-task", - title: "Loop task", - description: "", - created_at: loopRun.created_at, - updated_at: loopRun.created_at, - origin_product: "user_created", - latest_run: { - id: "task-run-1", - task: "task-1", - team: 2, - branch: null, - environment: "cloud", - status, - log_url: "", - error_message: status === "failed" ? "Run failed" : null, - output: null, - state: {}, - created_at: loopRun.created_at, - updated_at: "2026-07-22T12:01:00Z", - completed_at: "2026-07-22T12:01:00Z", - }, - }; -} - -describe("reconcileLoopRunStatus", () => { - it.each(["cancelled", "failed"] as const)( - "uses the terminal task status when loop history is stale: %s", - (status) => { - expect(reconcileLoopRunStatus(loopRun, taskWithRun(status))).toEqual( - expect.objectContaining({ - status, - completed_at: "2026-07-22T12:01:00Z", - }), - ); - }, - ); - - it.each(["in_progress", "completed"] as const)( - "keeps loop history unchanged for task status: %s", - (status) => { - expect(reconcileLoopRunStatus(loopRun, taskWithRun(status))).toBe( - loopRun, - ); - }, - ); -}); diff --git a/packages/ui/src/features/loops/hooks/useLoopRuns.ts b/packages/ui/src/features/loops/hooks/useLoopRuns.ts index 03ff616b62..d7f0a5a341 100644 --- a/packages/ui/src/features/loops/hooks/useLoopRuns.ts +++ b/packages/ui/src/features/loops/hooks/useLoopRuns.ts @@ -1,6 +1,4 @@ import { type LoopSchemas, listLoopRuns } from "@posthog/api-client/loops"; -import type { Task } from "@posthog/shared/domain-types"; -import { getAuthenticatedClient } from "@posthog/ui/features/auth/authClientImperative"; import { AUTH_SCOPED_QUERY_META } from "@posthog/ui/features/auth/useCurrentUser"; import { useQuery } from "@tanstack/react-query"; import { loopsKeys } from "./loopsKeys"; @@ -8,24 +6,6 @@ import { useLoopsClient } from "./useLoopsClient"; export const RECENT_RUNS_LIMIT = 10; -export function reconcileLoopRunStatus( - run: LoopSchemas.LoopRun, - task: Task, -): LoopSchemas.LoopRun { - const latestRun = task.latest_run; - if (run.status !== "in_progress" || !latestRun) return run; - if (latestRun.status !== "cancelled" && latestRun.status !== "failed") { - return run; - } - - return { - ...run, - status: latestRun.status, - completed_at: latestRun.completed_at, - error_message: latestRun.error_message, - }; -} - /** The most recent runs for a loop, polled so the detail view stays live. */ export function useLoopRuns(loopId: string | undefined) { const loopsClient = useLoopsClient(); @@ -34,43 +14,12 @@ export function useLoopRuns(loopId: string | undefined) { queryKey: loopsKeys.runs(loopsClient?.projectId ?? null, loopId ?? ""), queryFn: async () => { if (!loopsClient || !loopId) throw new Error("Not authenticated"); - const page = await listLoopRuns( + return await listLoopRuns( loopsClient.client, loopsClient.projectId, loopId, { limit: RECENT_RUNS_LIMIT }, ); - const activeRuns = page.results.filter( - (run) => run.status === "in_progress", - ); - if (activeRuns.length === 0) return page; - - const taskClient = await getAuthenticatedClient(); - if (!taskClient) return page; - - const tasks = await Promise.allSettled( - activeRuns.map(async (run) => ({ - run, - task: (await taskClient.getTask(run.task_id)) as unknown as Task, - })), - ); - const reconciled = new Map( - tasks.flatMap((result) => - result.status === "fulfilled" - ? [ - [ - result.value.run.id, - reconcileLoopRunStatus(result.value.run, result.value.task), - ] as const, - ] - : [], - ), - ); - - return { - ...page, - results: page.results.map((run) => reconciled.get(run.id) ?? run), - }; }, select: (page) => page.results.slice(0, RECENT_RUNS_LIMIT), enabled: !!loopsClient && !!loopId,