Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit bf2f53c

Browse files
committed
fix: improve loop run and schedule display
Generated-By: PostHog Code Task-Id: 7fc28633-4525-4dd9-b8b4-217dab72b7d7
1 parent 0305ec2 commit bf2f53c

9 files changed

Lines changed: 235 additions & 3 deletions

File tree

‎packages/shared/src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,7 @@ export type {
290290
TaskCreationOutput,
291291
} from "./task-creation-domain";
292292
export {
293+
formatClockTime,
293294
formatRelativeTimeLong,
294295
formatRelativeTimeShort,
295296
getLocalDayDiff,

‎packages/shared/src/time.test.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
22
import {
3+
formatClockTime,
34
formatRelativeTimeLong,
45
formatRelativeTimeShort,
56
getLocalDayDiff,
@@ -11,6 +12,17 @@ const MINUTE = 60_000;
1112
const HOUR = 3_600_000;
1213
const DAY = 86_400_000;
1314

15+
describe("formatClockTime", () => {
16+
it.each([
17+
["00:00", "12:00 AM"],
18+
["08:15", "8:15 AM"],
19+
["11:00", "11:00 AM"],
20+
["17:30", "5:30 PM"],
21+
])("formats %s as %s", (time, expected) => {
22+
expect(formatClockTime(time)).toBe(expected);
23+
});
24+
});
25+
1426
beforeEach(() => {
1527
vi.useFakeTimers();
1628
vi.setSystemTime(NOW);

‎packages/shared/src/time.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
1+
export function formatClockTime(time: string): string {
2+
const [hour, minute] = time.split(":").map(Number);
3+
return new Intl.DateTimeFormat("en-US", {
4+
hour: "numeric",
5+
minute: "2-digit",
6+
timeZone: "UTC",
7+
}).format(new Date(Date.UTC(2000, 0, 1, hour, minute)));
8+
}
9+
110
/**
211
* Format a timestamp as a short relative string (e.g. "3m", "2h", "5d").
312
* Accepts either a Unix ms timestamp or an ISO date string.

‎packages/ui/src/features/loops/components/LoopDetailView.tsx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
import { AlertDialog, Flex, Text } from "@radix-ui/themes";
1313
import { useState } from "react";
1414
import { useLoop } from "../hooks/useLoop";
15+
import { useLoopDisplayModel } from "../hooks/useLoopDisplayModel";
1516
import {
1617
useDeleteLoop,
1718
useRunLoop,
@@ -237,6 +238,8 @@ export function LoopDetailView({ loopId }: { loopId: string }) {
237238
}
238239

239240
function ConfigSummarySection({ loop }: { loop: LoopSchemas.Loop }) {
241+
const displayModel = useLoopDisplayModel(loop.runtime_adapter, loop.model);
242+
240243
return (
241244
<Flex direction="column" gap="3">
242245
<Text className="font-medium text-[13px] text-gray-12">
@@ -251,7 +254,7 @@ function ConfigSummarySection({ loop }: { loop: LoopSchemas.Loop }) {
251254
<SummaryRow label="Model">
252255
{[
253256
loop.runtime_adapter,
254-
loop.model,
257+
displayModel,
255258
loop.reasoning_effort ? `${loop.reasoning_effort} reasoning` : null,
256259
]
257260
.filter(Boolean)
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import type { LoopSchemas } from "@posthog/api-client/loops";
2+
import {
3+
REPORT_MODEL_RESOLVER,
4+
type ReportModelResolver,
5+
} from "@posthog/core/inbox/identifiers";
6+
import { useService } from "@posthog/di/react";
7+
import { getCloudUrlFromRegion } from "@posthog/shared";
8+
import { useAuthStateValue } from "@posthog/ui/features/auth/store";
9+
import { useQuery } from "@tanstack/react-query";
10+
11+
export function useLoopDisplayModel(
12+
adapter: LoopSchemas.LoopRuntimeAdapterEnum,
13+
configuredModel: string,
14+
): string {
15+
const cloudRegion = useAuthStateValue((state) => state.cloudRegion);
16+
const modelResolver = useService<ReportModelResolver>(REPORT_MODEL_RESOLVER);
17+
const { data } = useQuery({
18+
queryKey: ["loops", "default-model", cloudRegion, adapter],
19+
queryFn: () => {
20+
if (!cloudRegion) return undefined;
21+
return modelResolver.resolveDefaultModel(
22+
getCloudUrlFromRegion(cloudRegion),
23+
adapter,
24+
);
25+
},
26+
enabled: !configuredModel && !!cloudRegion,
27+
staleTime: 5 * 60_000,
28+
});
29+
30+
return configuredModel || data || "Default model";
31+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import type { LoopSchemas } from "@posthog/api-client/loops";
2+
import type { Task } from "@posthog/shared/domain-types";
3+
import { describe, expect, it } from "vitest";
4+
import { reconcileLoopRunStatus } from "./useLoopRuns";
5+
6+
const loopRun = {
7+
id: "loop-run-1",
8+
task_id: "task-1",
9+
loop_trigger_id: null,
10+
status: "in_progress",
11+
environment: "cloud",
12+
branch: null,
13+
error_message: null,
14+
output: null,
15+
created_at: "2026-07-22T12:00:00Z",
16+
completed_at: null,
17+
} satisfies LoopSchemas.LoopRun;
18+
19+
function taskWithRun(
20+
status: "in_progress" | "completed" | "failed" | "cancelled",
21+
): Task {
22+
return {
23+
id: "task-1",
24+
task_number: 1,
25+
slug: "loop-task",
26+
title: "Loop task",
27+
description: "",
28+
created_at: loopRun.created_at,
29+
updated_at: loopRun.created_at,
30+
origin_product: "user_created",
31+
latest_run: {
32+
id: "task-run-1",
33+
task: "task-1",
34+
team: 2,
35+
branch: null,
36+
environment: "cloud",
37+
status,
38+
log_url: "",
39+
error_message: status === "failed" ? "Run failed" : null,
40+
output: null,
41+
state: {},
42+
created_at: loopRun.created_at,
43+
updated_at: "2026-07-22T12:01:00Z",
44+
completed_at: "2026-07-22T12:01:00Z",
45+
},
46+
};
47+
}
48+
49+
describe("reconcileLoopRunStatus", () => {
50+
it.each(["cancelled", "failed"] as const)(
51+
"uses the terminal task status when loop history is stale: %s",
52+
(status) => {
53+
expect(reconcileLoopRunStatus(loopRun, taskWithRun(status))).toEqual(
54+
expect.objectContaining({
55+
status,
56+
completed_at: "2026-07-22T12:01:00Z",
57+
}),
58+
);
59+
},
60+
);
61+
62+
it.each(["in_progress", "completed"] as const)(
63+
"keeps loop history unchanged for task status: %s",
64+
(status) => {
65+
expect(reconcileLoopRunStatus(loopRun, taskWithRun(status))).toBe(
66+
loopRun,
67+
);
68+
},
69+
);
70+
});

‎packages/ui/src/features/loops/hooks/useLoopRuns.ts‎

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,31 @@
11
import { type LoopSchemas, listLoopRuns } from "@posthog/api-client/loops";
2+
import type { Task } from "@posthog/shared/domain-types";
3+
import { getAuthenticatedClient } from "@posthog/ui/features/auth/authClientImperative";
24
import { AUTH_SCOPED_QUERY_META } from "@posthog/ui/features/auth/useCurrentUser";
35
import { useQuery } from "@tanstack/react-query";
46
import { loopsKeys } from "./loopsKeys";
57
import { useLoopsClient } from "./useLoopsClient";
68

79
export const RECENT_RUNS_LIMIT = 10;
810

11+
export function reconcileLoopRunStatus(
12+
run: LoopSchemas.LoopRun,
13+
task: Task,
14+
): LoopSchemas.LoopRun {
15+
const latestRun = task.latest_run;
16+
if (run.status !== "in_progress" || !latestRun) return run;
17+
if (latestRun.status !== "cancelled" && latestRun.status !== "failed") {
18+
return run;
19+
}
20+
21+
return {
22+
...run,
23+
status: latestRun.status,
24+
completed_at: latestRun.completed_at,
25+
error_message: latestRun.error_message,
26+
};
27+
}
28+
929
/** The most recent runs for a loop, polled so the detail view stays live. */
1030
export function useLoopRuns(loopId: string | undefined) {
1131
const loopsClient = useLoopsClient();
@@ -14,12 +34,43 @@ export function useLoopRuns(loopId: string | undefined) {
1434
queryKey: loopsKeys.runs(loopsClient?.projectId ?? null, loopId ?? ""),
1535
queryFn: async () => {
1636
if (!loopsClient || !loopId) throw new Error("Not authenticated");
17-
return await listLoopRuns(
37+
const page = await listLoopRuns(
1838
loopsClient.client,
1939
loopsClient.projectId,
2040
loopId,
2141
{ limit: RECENT_RUNS_LIMIT },
2242
);
43+
const activeRuns = page.results.filter(
44+
(run) => run.status === "in_progress",
45+
);
46+
if (activeRuns.length === 0) return page;
47+
48+
const taskClient = await getAuthenticatedClient();
49+
if (!taskClient) return page;
50+
51+
const tasks = await Promise.allSettled(
52+
activeRuns.map(async (run) => ({
53+
run,
54+
task: (await taskClient.getTask(run.task_id)) as unknown as Task,
55+
})),
56+
);
57+
const reconciled = new Map(
58+
tasks.flatMap((result) =>
59+
result.status === "fulfilled"
60+
? [
61+
[
62+
result.value.run.id,
63+
reconcileLoopRunStatus(result.value.run, result.value.task),
64+
] as const,
65+
]
66+
: [],
67+
),
68+
);
69+
70+
return {
71+
...page,
72+
results: page.results.map((run) => reconciled.get(run.id) ?? run),
73+
};
2374
},
2475
select: (page) => page.results.slice(0, RECENT_RUNS_LIMIT),
2576
enabled: !!loopsClient && !!loopId,
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { describe, expect, it } from "vitest";
2+
import { describeTrigger } from "./loopDisplay";
3+
4+
describe("describeTrigger", () => {
5+
it.each([
6+
["0 * * * *", "Every hour (UTC)"],
7+
["30 9 * * *", "Daily at 9:30 AM (UTC)"],
8+
["0 11 * * 1-5", "Weekdays at 11:00 AM (UTC)"],
9+
["15 8 * * 3", "Wednesdays at 8:15 AM (UTC)"],
10+
])("formats %s as a readable schedule", (cronExpression, expected) => {
11+
expect(
12+
describeTrigger({
13+
type: "schedule",
14+
config: { cron_expression: cronExpression, timezone: "UTC" },
15+
}),
16+
).toBe(`Schedule · ${expected}`);
17+
});
18+
19+
it("keeps custom cron expressions visible", () => {
20+
expect(
21+
describeTrigger({
22+
type: "schedule",
23+
config: { cron_expression: "*/15 * * * *", timezone: "UTC" },
24+
}),
25+
).toBe("Schedule · */15 * * * * (UTC)");
26+
});
27+
});

‎packages/ui/src/features/loops/loopDisplay.ts‎

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,32 @@
11
import type { LoopSchemas } from "@posthog/api-client/loops";
2+
import { formatClockTime } from "@posthog/shared";
3+
import { parseCronSchedule } from "./loopCron";
4+
5+
const WEEKDAY_NAMES: Record<string, string> = {
6+
"0": "Sunday",
7+
"1": "Monday",
8+
"2": "Tuesday",
9+
"3": "Wednesday",
10+
"4": "Thursday",
11+
"5": "Friday",
12+
"6": "Saturday",
13+
};
14+
15+
function describeSchedule(
16+
config: LoopSchemas.LoopScheduleTriggerConfig,
17+
): string {
18+
const cron = config.cron_expression;
19+
const parsed = parseCronSchedule(cron);
20+
const timezone = config.timezone ?? "UTC";
21+
if (!parsed) return `${cron ?? "?"} (${timezone})`;
22+
if (parsed.frequency === "hourly") return `Every hour (${timezone})`;
23+
24+
const time = formatClockTime(parsed.time);
25+
if (parsed.frequency === "daily") return `Daily at ${time} (${timezone})`;
26+
if (parsed.frequency === "weekdays")
27+
return `Weekdays at ${time} (${timezone})`;
28+
return `${WEEKDAY_NAMES[parsed.weekday]}s at ${time} (${timezone})`;
29+
}
230

331
export function loopStatusColor(
432
loop: LoopSchemas.Loop,
@@ -39,7 +67,7 @@ export function describeTrigger(trigger: TriggerLike): string {
3967
const config = trigger.config as LoopSchemas.LoopScheduleTriggerConfig;
4068
if (config.run_at)
4169
return `One-time · ${new Date(config.run_at).toLocaleString()}`;
42-
return `Schedule · ${config.cron_expression ?? "?"} (${config.timezone ?? "UTC"})`;
70+
return `Schedule · ${describeSchedule(config)}`;
4371
}
4472
if (trigger.type === "github") {
4573
const config = trigger.config as LoopSchemas.LoopGithubTriggerConfig;

0 commit comments

Comments
 (0)