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 1 commit
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
1 change: 1 addition & 0 deletions packages/shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,7 @@ export type {
TaskCreationOutput,
} from "./task-creation-domain";
export {
formatClockTime,
formatRelativeTimeLong,
formatRelativeTimeShort,
getLocalDayDiff,
Expand Down
12 changes: 12 additions & 0 deletions packages/shared/src/time.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
formatClockTime,
formatRelativeTimeLong,
formatRelativeTimeShort,
getLocalDayDiff,
Expand All @@ -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);
Expand Down
9 changes: 9 additions & 0 deletions packages/shared/src/time.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
5 changes: 4 additions & 1 deletion packages/ui/src/features/loops/components/LoopDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 (
<Flex direction="column" gap="3">
<Text className="font-medium text-[13px] text-gray-12">
Expand All @@ -251,7 +254,7 @@ function ConfigSummarySection({ loop }: { loop: LoopSchemas.Loop }) {
<SummaryRow label="Model">
{[
loop.runtime_adapter,
loop.model,
displayModel,
loop.reasoning_effort ? `${loop.reasoning_effort} reasoning` : null,
]
.filter(Boolean)
Expand Down
31 changes: 31 additions & 0 deletions packages/ui/src/features/loops/hooks/useLoopDisplayModel.ts
Original file line number Diff line number Diff line change
@@ -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<ReportModelResolver>(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";
}
70 changes: 70 additions & 0 deletions packages/ui/src/features/loops/hooks/useLoopRuns.test.ts
Original file line number Diff line number Diff line change
@@ -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,
);
},
);
});
53 changes: 52 additions & 1 deletion packages/ui/src/features/loops/hooks/useLoopRuns.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,31 @@
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";
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();
Expand All @@ -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();
Comment thread
MattPua marked this conversation as resolved.
Outdated
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),
Comment thread
MattPua marked this conversation as resolved.
Outdated
] 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,
Expand Down
27 changes: 27 additions & 0 deletions packages/ui/src/features/loops/loopDisplay.test.ts
Original file line number Diff line number Diff line change
@@ -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)");
});
});
30 changes: 29 additions & 1 deletion packages/ui/src/features/loops/loopDisplay.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
"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,
Expand Down Expand Up @@ -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;
Expand Down
Loading