From 9015ae0b202baba9e97fdbde0db3b6a788b14516 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 9 Aug 2026 15:46:31 -0700 Subject: [PATCH 1/8] Add tests for Insights stats rollups --- apps/web/src/insights-stats.test.ts | 75 +++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 apps/web/src/insights-stats.test.ts diff --git a/apps/web/src/insights-stats.test.ts b/apps/web/src/insights-stats.test.ts new file mode 100644 index 000000000..6c352ecb4 --- /dev/null +++ b/apps/web/src/insights-stats.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from "bun:test"; + +import type { WorkflowRun } from "./api"; +import { computeInsightsStats } from "./insights-stats"; +import type { Routine } from "./routines-api"; + +function run( + partial: Partial & Pick, +): WorkflowRun { + return { + tenantId: "t1", + tenantName: "Bench", + definitionId: "def", + definitionName: partial.definitionName ?? "research-brief", + address: "addr", + createdAt: partial.createdAt ?? "2026-01-02T00:00:00.000Z", + ...partial, + }; +} + +function routine( + partial: Partial & Pick, +): Routine { + return { + name: "Daily dig", + definitionId: "def", + trigger: { kind: "interval", unit: "hours", every: 24 }, + scope: "bench", + input: {}, + deliveryChannelId: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + ...partial, + }; +} + +describe("computeInsightsStats", () => { + test("counts purposeful runs by status and drops channel hosts", () => { + const stats = computeInsightsStats( + [ + run({ id: "1", status: "running", createdAt: "2026-01-03T00:00:00.000Z" }), + run({ id: "2", status: "error", createdAt: "2026-01-02T00:00:00.000Z" }), + run({ id: "3", status: "stopped", createdAt: "2026-01-01T00:00:00.000Z" }), + run({ + id: "host", + status: "running", + definitionName: "ins-cd03d8e3", + createdAt: "2026-01-04T00:00:00.000Z", + }), + ], + [routine({ id: "r1", enabled: true }), routine({ id: "r2", enabled: false })], + ); + + expect(stats.totalRuns).toBe(3); + expect(stats.running).toBe(1); + expect(stats.errored).toBe(1); + expect(stats.stopped).toBe(1); + expect(stats.routineCount).toBe(2); + expect(stats.enabledRoutines).toBe(1); + expect(stats.recentRuns.map((r) => r.id)).toEqual(["1", "2", "3"]); + }); + + test("limits recent runs", () => { + const runs = Array.from({ length: 5 }, (_, i) => + run({ + id: String(i), + status: "deployed", + createdAt: `2026-01-0${i + 1}T00:00:00.000Z`, + }), + ); + const stats = computeInsightsStats(runs, [], 2); + expect(stats.recentRuns).toHaveLength(2); + expect(stats.deployed).toBe(5); + }); +}); From 7a90c5d72aa92e2500f628149c95f5ea52ef1883 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 9 Aug 2026 15:46:31 -0700 Subject: [PATCH 2/8] CL-5791: Insights I1 live rollups from runs and routines --- apps/web/src/insights-stats.ts | 75 +++++++++ apps/web/src/pages/insights-page.tsx | 236 +++++++++++++++++++++++++-- 2 files changed, 298 insertions(+), 13 deletions(-) create mode 100644 apps/web/src/insights-stats.ts diff --git a/apps/web/src/insights-stats.ts b/apps/web/src/insights-stats.ts new file mode 100644 index 000000000..bc810b682 --- /dev/null +++ b/apps/web/src/insights-stats.ts @@ -0,0 +1,75 @@ +// Pure Insights rollups over data the web already has (workflow runs + +// routines). No new analytics backend — I1 is an honest live surface on +// existing endpoints. + +import { isChannelHostDefinitionName } from "@corbits/chat/channel-host-naming"; + +import type { WorkflowRun } from "./api"; +import type { Routine } from "./routines-api"; + +export type InsightsStats = { + readonly totalRuns: number; + readonly running: number; + readonly errored: number; + readonly stopped: number; + readonly deployed: number; + readonly routineCount: number; + readonly enabledRoutines: number; + readonly recentRuns: readonly WorkflowRun[]; +}; + +/** Cap recent-run table rows so the page stays scannable. */ +export const INSIGHTS_RECENT_LIMIT = 12; + +/** Purpose runs only — drop channel-host anchors the same way Home does. */ +export function purposeRunsForInsights( + runs: readonly WorkflowRun[], +): readonly WorkflowRun[] { + return runs.filter( + (run) => !isChannelHostDefinitionName(run.definitionName), + ); +} + +export function computeInsightsStats( + runs: readonly WorkflowRun[], + routines: readonly Routine[], + recentLimit: number = INSIGHTS_RECENT_LIMIT, +): InsightsStats { + const purposeful = purposeRunsForInsights(runs); + let running = 0; + let errored = 0; + let stopped = 0; + let deployed = 0; + for (const run of purposeful) { + switch (run.status) { + case "running": + case "updating": + running += 1; + break; + case "error": + errored += 1; + break; + case "stopped": + stopped += 1; + break; + case "deployed": + deployed += 1; + break; + } + } + + const recentRuns = [...purposeful] + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)) + .slice(0, recentLimit); + + return { + totalRuns: purposeful.length, + running, + errored, + stopped, + deployed, + routineCount: routines.length, + enabledRoutines: routines.filter((r) => r.enabled).length, + recentRuns, + }; +} diff --git a/apps/web/src/pages/insights-page.tsx b/apps/web/src/pages/insights-page.tsx index 8096e778c..4b0a290f7 100644 --- a/apps/web/src/pages/insights-page.tsx +++ b/apps/web/src/pages/insights-page.tsx @@ -1,23 +1,233 @@ -import { PageShell, RichEmptyState } from "@corbits/react-ui"; +// Insights I1: live rollups from workflow runs + routines already on the hub. +// No new analytics backend — honest numbers, recent runs, and a deep-link into +// Routines for schedule management. + +import { + Badge, + PageShell, + RichEmptyState, + Section, + Skeleton, + StatGrid, + StatTile, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@corbits/react-ui"; import { ChartColumn } from "lucide-react"; +import type { ReactNode } from "react"; + +import { RunsSchema, useAPIQuery } from "../api"; +import type { APIQuery, RunsPage, WorkflowRun } from "../api"; +import { useBench } from "../bench-context"; +import { computeInsightsStats } from "../insights-stats"; +import { Link } from "../navigation"; +import { tenantKeys } from "../query-client"; +import { SignedOutNotice } from "../query-view"; +import { + listRoutines, + useTenantQuery, + type Routine, +} from "../routines-api"; + +function tileValue(value: number | null, loading: boolean): ReactNode { + if (loading) return ; + if (value === null) return "—"; + return value; +} + +function statusTone( + status: WorkflowRun["status"], +): "success" | "danger" | "neutral" | "info" { + switch (status) { + case "running": + case "updating": + return "success"; + case "error": + return "danger"; + case "stopped": + return "neutral"; + case "deployed": + return "info"; + } +} + +function formatWhen(iso: string): string { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return iso; + return date.toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +export function InsightsPage({ + runs, + routines, +}: { + readonly runs: APIQuery; + readonly routines: APIQuery; +}) { + if (runs.kind === "unauthenticated" || routines.kind === "unauthenticated") { + return ( + + + + ); + } + + const loading = runs.kind === "loading" || routines.kind === "loading"; + const failed = + runs.kind === "error" + ? runs.message + : routines.kind === "error" + ? routines.message + : null; + + if (failed !== null && !loading) { + return ( + + } + title="Couldn't load insights" + description={failed} + /> + + ); + } + + const stats = + runs.kind === "ready" && routines.kind === "ready" + ? computeInsightsStats(runs.data.data, routines.data) + : null; + + const empty = + stats !== null && + stats.totalRuns === 0 && + stats.routineCount === 0; -/** - * Honest stub for the Insights nav target. The analytics surface is a later - * wave ticket; the rail already needs the path so product navigation matches - * the accepted nav set (Home, Routines, Library, Agents, Skills, Insights). - */ -export function InsightsPage() { return ( - } - title="Insights aren't built yet" - description="Insights will show usage, run history, and an audit trail for this bench. There is no analytics surface wired yet, so this page has nothing real to chart." - /> +
+ + + + + + +
+ +
+ {loading ? ( + + ) : empty ? ( + } + title="Nothing to chart yet" + description="Start a purpose workflow or create a routine — Insights will roll them up here." + actions={[ + { + label: "Open Routines", + href: "/routines", + variant: "primary", + }, + ]} + /> + ) : ( +

+ Manage schedules and fire history on{" "} + Routines + {stats !== null && stats.stopped + stats.deployed > 0 + ? ` · ${stats.deployed} deployed · ${stats.stopped} stopped` + : null} + . +

+ )} +
+ + {stats !== null && stats.recentRuns.length > 0 ? ( +
+ + + + Workflow + Status + Bench + Started + + + + {stats.recentRuns.map((row) => ( + + {row.definitionName} + + + {row.status} + + + {row.tenantName} + {formatWhen(row.createdAt)} + + ))} + +
+
+ ) : null}
); } export function InsightsRoute() { - return ; + const { selectedTenantId } = useBench(); + const runs = useAPIQuery("/api/me/workflows/runs", RunsSchema); + const routines = useTenantQuery( + selectedTenantId === null + ? ["tenant", "none", "routines"] + : tenantKeys.routines(selectedTenantId), + selectedTenantId !== null, + () => listRoutines(selectedTenantId as string), + ); + + // No bench selected: still show run rollups (me-scoped); routines empty. + const routinesForPage: APIQuery = + selectedTenantId === null + ? { kind: "ready", data: [] } + : routines; + + return ; } From cb27604e1b9e6e173a8ea7080eda63b157c01d53 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 9 Aug 2026 16:25:28 -0700 Subject: [PATCH 3/8] Format with Prettier for CI --- apps/web/src/insights-stats.test.ts | 23 +++++++++++++++++++---- apps/web/src/insights-stats.ts | 4 +--- apps/web/src/pages/insights-page.tsx | 22 +++++----------------- 3 files changed, 25 insertions(+), 24 deletions(-) diff --git a/apps/web/src/insights-stats.test.ts b/apps/web/src/insights-stats.test.ts index 6c352ecb4..8af683269 100644 --- a/apps/web/src/insights-stats.test.ts +++ b/apps/web/src/insights-stats.test.ts @@ -38,9 +38,21 @@ describe("computeInsightsStats", () => { test("counts purposeful runs by status and drops channel hosts", () => { const stats = computeInsightsStats( [ - run({ id: "1", status: "running", createdAt: "2026-01-03T00:00:00.000Z" }), - run({ id: "2", status: "error", createdAt: "2026-01-02T00:00:00.000Z" }), - run({ id: "3", status: "stopped", createdAt: "2026-01-01T00:00:00.000Z" }), + run({ + id: "1", + status: "running", + createdAt: "2026-01-03T00:00:00.000Z", + }), + run({ + id: "2", + status: "error", + createdAt: "2026-01-02T00:00:00.000Z", + }), + run({ + id: "3", + status: "stopped", + createdAt: "2026-01-01T00:00:00.000Z", + }), run({ id: "host", status: "running", @@ -48,7 +60,10 @@ describe("computeInsightsStats", () => { createdAt: "2026-01-04T00:00:00.000Z", }), ], - [routine({ id: "r1", enabled: true }), routine({ id: "r2", enabled: false })], + [ + routine({ id: "r1", enabled: true }), + routine({ id: "r2", enabled: false }), + ], ); expect(stats.totalRuns).toBe(3); diff --git a/apps/web/src/insights-stats.ts b/apps/web/src/insights-stats.ts index bc810b682..d25826dff 100644 --- a/apps/web/src/insights-stats.ts +++ b/apps/web/src/insights-stats.ts @@ -25,9 +25,7 @@ export const INSIGHTS_RECENT_LIMIT = 12; export function purposeRunsForInsights( runs: readonly WorkflowRun[], ): readonly WorkflowRun[] { - return runs.filter( - (run) => !isChannelHostDefinitionName(run.definitionName), - ); + return runs.filter((run) => !isChannelHostDefinitionName(run.definitionName)); } export function computeInsightsStats( diff --git a/apps/web/src/pages/insights-page.tsx b/apps/web/src/pages/insights-page.tsx index 4b0a290f7..1c858e2e8 100644 --- a/apps/web/src/pages/insights-page.tsx +++ b/apps/web/src/pages/insights-page.tsx @@ -27,11 +27,7 @@ import { computeInsightsStats } from "../insights-stats"; import { Link } from "../navigation"; import { tenantKeys } from "../query-client"; import { SignedOutNotice } from "../query-view"; -import { - listRoutines, - useTenantQuery, - type Routine, -} from "../routines-api"; +import { listRoutines, useTenantQuery, type Routine } from "../routines-api"; function tileValue(value: number | null, loading: boolean): ReactNode { if (loading) return ; @@ -107,9 +103,7 @@ export function InsightsPage({ : null; const empty = - stats !== null && - stats.totalRuns === 0 && - stats.routineCount === 0; + stats !== null && stats.totalRuns === 0 && stats.routineCount === 0; return ( @@ -133,9 +127,7 @@ export function InsightsPage({ @@ -196,9 +188,7 @@ export function InsightsPage({ {row.definitionName} - - {row.status} - + {row.status} {row.tenantName} {formatWhen(row.createdAt)} @@ -225,9 +215,7 @@ export function InsightsRoute() { // No bench selected: still show run rollups (me-scoped); routines empty. const routinesForPage: APIQuery = - selectedTenantId === null - ? { kind: "ready", data: [] } - : routines; + selectedTenantId === null ? { kind: "ready", data: [] } : routines; return ; } From 7ab3811ef22296891d08172d87ce63f8cb14cdd3 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 9 Aug 2026 16:40:07 -0700 Subject: [PATCH 4/8] Add tests for Insights timeline and run deep-links --- apps/web/src/insights-deeplinks.test.ts | 43 +++++++++++++++++ apps/web/src/insights-timeline.test.ts | 63 +++++++++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 apps/web/src/insights-deeplinks.test.ts create mode 100644 apps/web/src/insights-timeline.test.ts diff --git a/apps/web/src/insights-deeplinks.test.ts b/apps/web/src/insights-deeplinks.test.ts new file mode 100644 index 000000000..6e6b0bc70 --- /dev/null +++ b/apps/web/src/insights-deeplinks.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "bun:test"; + +import type { WorkflowRun } from "./api"; +import { + ROUTINES_PATH_PREFIX, + runDeepLinkTarget, + runDetailPath, +} from "./insights-deeplinks"; + +function run(id: string): WorkflowRun { + return { + id, + tenantId: "t1", + tenantName: "Bench", + definitionId: "def", + definitionName: "research-brief", + address: "addr", + status: "running", + createdAt: "2026-01-02T00:00:00.000Z", + }; +} + +describe("runDetailPath", () => { + test("builds the /routines/:id run-detail path the command palette also uses", () => { + expect(runDetailPath("run_123")).toBe(`${ROUTINES_PATH_PREFIX}/run_123`); + }); + + test("encodes ids so a slash or space cannot break out of the segment", () => { + expect(runDetailPath("a/b c")).toBe(`${ROUTINES_PATH_PREFIX}/a%2Fb%20c`); + }); +}); + +describe("runDeepLinkTarget", () => { + test("returns the run-detail path for a purpose run", () => { + expect(runDeepLinkTarget(run("run_42"))).toBe( + `${ROUTINES_PATH_PREFIX}/run_42`, + ); + }); + + test("is stable across two runs that differ only by id", () => { + expect(runDeepLinkTarget(run("a"))).not.toBe(runDeepLinkTarget(run("b"))); + }); +}); diff --git a/apps/web/src/insights-timeline.test.ts b/apps/web/src/insights-timeline.test.ts new file mode 100644 index 000000000..2a0425d29 --- /dev/null +++ b/apps/web/src/insights-timeline.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "bun:test"; + +import type { WorkflowRun } from "./api"; +import { bucketRunsByDay } from "./insights-timeline"; + +function run(createdAt: string, id?: string): WorkflowRun { + return { + id: id ?? createdAt, + tenantId: "t1", + tenantName: "Bench", + definitionId: "def", + definitionName: "research-brief", + address: "addr", + status: "running", + createdAt, + }; +} + +// Fixed "now" at 2026-01-15T18:00Z so day math is deterministic regardless of +// when the suite runs. +const NOW = new Date("2026-01-15T18:00:00.000Z"); + +describe("bucketRunsByDay", () => { + test("emits one bucket per day, oldest first, with zero counts", () => { + const buckets = bucketRunsByDay([], 3, NOW); + expect(buckets).toHaveLength(3); + // Oldest first: Jan 13, Jan 14, Jan 15 (UTC days, since NOW is UTC). + expect(buckets[0].key).toBe("2026-01-13"); + expect(buckets[1].key).toBe("2026-01-14"); + expect(buckets[2].key).toBe("2026-01-15"); + expect(buckets.every((b) => b.count === 0)).toBe(true); + }); + + test("counts runs into the correct UTC day bucket", () => { + const buckets = bucketRunsByDay( + [ + run("2026-01-15T01:00:00.000Z"), + run("2026-01-15T22:00:00.000Z"), + run("2026-01-14T12:00:00.000Z"), + run("2026-01-10T12:00:00.000Z"), // outside the window — dropped + ], + 3, + NOW, + ); + expect(buckets.map((b) => b.count)).toEqual([0, 1, 2]); + }); + + test("ignores runs older than the window", () => { + const buckets = bucketRunsByDay([run("2025-12-01T00:00:00.000Z")], 7, NOW); + expect(buckets.reduce((sum, b) => sum + b.count, 0)).toBe(0); + }); + + test("labels are unique within the window", () => { + const buckets = bucketRunsByDay([run("2026-01-15T01:00:00.000Z")], 5, NOW); + const labels = buckets.map((b) => b.label); + expect(new Set(labels).size).toBe(labels.length); + }); + + test("defaults `days` to a sane default when omitted", () => { + const buckets = bucketRunsByDay([], 7, NOW); + expect(buckets).toHaveLength(7); + }); +}); From aa558b3e2c7e01d82a133cb691ae9ea74cdcd5fd Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 9 Aug 2026 16:40:07 -0700 Subject: [PATCH 5/8] Insights I2: 14-day timeline and deep-links to Routines --- apps/web/src/app.css | 33 ++++++++++ apps/web/src/insights-deeplinks.ts | 16 +++++ apps/web/src/insights-timeline.ts | 95 ++++++++++++++++++++++++++++ apps/web/src/pages/insights-page.tsx | 62 ++++++++++++++++-- 4 files changed, 201 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/insights-deeplinks.ts create mode 100644 apps/web/src/insights-timeline.ts diff --git a/apps/web/src/app.css b/apps/web/src/app.css index 38feb7cfe..5854eac9b 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -595,3 +595,36 @@ /* Chat surface styling ships with @corbits/chat-ui (see packages/chat-ui/src/styles.css) — imported in main.tsx alongside this file, mirroring how @corbits/react-ui/styles.css is consumed. */ + +/* Insights I2 day-bucket strip */ +.insights-timeline { + display: flex; + align-items: flex-end; + gap: 0.35rem; + min-height: 6rem; + padding: 0.5rem 0; +} +.insights-timeline-bar { + flex: 1 1 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-end; + min-width: 0; + height: 5.5rem; +} +.insights-timeline-fill { + width: 70%; + min-height: 2px; + background: var(--color-accent, #5b8def); + border-radius: 2px 2px 0 0; +} +.insights-timeline-label { + margin-top: 0.25rem; + font-size: 0.65rem; + color: var(--color-muted, #888); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +} diff --git a/apps/web/src/insights-deeplinks.ts b/apps/web/src/insights-deeplinks.ts new file mode 100644 index 000000000..5427fec8e --- /dev/null +++ b/apps/web/src/insights-deeplinks.ts @@ -0,0 +1,16 @@ +// Deep-link targets for Insights rows. The only existing per-run surface is +// the Routines page, which owns the `/routines/:id` prefix and is the same +// target the command palette navigates to for a run — so Insights rows jump +// to the same place rather than inventing a route that does not exist yet. + +export const ROUTINES_PATH_PREFIX = "/routines"; + +/** Canonical `/routines/:id` path a run row deep-links into. */ +export function runDetailPath(runId: string): string { + return `${ROUTINES_PATH_PREFIX}/${encodeURIComponent(runId)}`; +} + +/** The deep-link target for a recent-run row — its own detail on Routines. */ +export function runDeepLinkTarget(run: { readonly id: string }): string { + return runDetailPath(run.id); +} diff --git a/apps/web/src/insights-timeline.ts b/apps/web/src/insights-timeline.ts new file mode 100644 index 000000000..c467eb620 --- /dev/null +++ b/apps/web/src/insights-timeline.ts @@ -0,0 +1,95 @@ +// Lightweight day-bucket timeline over existing run timestamps — no new +// analytics backend. Buckets the last N UTC days (oldest first) so a small +// bar/sparkline can render honest volume from data the page already holds. + +import type { WorkflowRun } from "./api"; + +export type DayBucket = { + /** Calendar day as `YYYY-MM-DD` (UTC). */ + readonly key: string; + /** Short, single-line axis label for the day. */ + readonly label: string; + /** Purpose runs that started on this day. */ + readonly count: number; +}; + +/** Default window for the timeline strip. */ +export const INSIGHTS_TIMELINE_DAYS = 14; + +const MONTHS = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", +]; + +/** + * Buckets `runs` into the last `days` UTC days ending on `now`'s day, oldest + * first. Runs outside the window are dropped; days with no runs still appear + * as zero-count buckets so the strip never collapses. + */ +export function bucketRunsByDay( + runs: readonly WorkflowRun[], + days: number = INSIGHTS_TIMELINE_DAYS, + now: Date = new Date(), +): readonly DayBucket[] { + // Start of the current UTC day, then walk back `days - 1` days so the + // window includes today. + const todayUTC = Date.UTC( + now.getUTCFullYear(), + now.getUTCMonth(), + now.getUTCDate(), + ); + const dayMs = 86_400_000; + + const counts = new Map(); + const keys: string[] = []; + for (let offset = days - 1; offset >= 0; offset -= 1) { + const ms = todayUTC - offset * dayMs; + const d = new Date(ms); + const key = utcDayKey(d); + keys.push(key); + counts.set(key, 0); + } + + for (const run of runs) { + const key = isoToUtcDayKey(run.createdAt); + if (key === null) continue; + if (counts.has(key)) { + counts.set(key, (counts.get(key) ?? 0) + 1); + } + } + + return keys.map((key) => ({ + key, + label: labelForKey(key), + count: counts.get(key) ?? 0, + })); +} + +function utcDayKey(d: Date): string { + const y = d.getUTCFullYear(); + const m = String(d.getUTCMonth() + 1).padStart(2, "0"); + const day = String(d.getUTCDate()).padStart(2, "0"); + return `${y}-${m}-${day}`; +} + +function isoToUtcDayKey(iso: string): string | null { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return null; + return utcDayKey(d); +} + +function labelForKey(key: string): string { + // key is `YYYY-MM-DD`; derive a locale-independent `Mon D` label. + const [, month, day] = key.split("-").map(Number) as [number, number, number]; + return `${MONTHS[month - 1]} ${day}`; +} diff --git a/apps/web/src/pages/insights-page.tsx b/apps/web/src/pages/insights-page.tsx index 1c858e2e8..4a3da5750 100644 --- a/apps/web/src/pages/insights-page.tsx +++ b/apps/web/src/pages/insights-page.tsx @@ -1,6 +1,6 @@ -// Insights I1: live rollups from workflow runs + routines already on the hub. -// No new analytics backend — honest numbers, recent runs, and a deep-link into -// Routines for schedule management. +// Insights I1 + I2: live rollups, day-bucket timeline, and deep-links into +// Routines for each recent run. No new analytics backend — honest numbers +// from data the page already loads. import { Badge, @@ -23,7 +23,13 @@ import type { ReactNode } from "react"; import { RunsSchema, useAPIQuery } from "../api"; import type { APIQuery, RunsPage, WorkflowRun } from "../api"; import { useBench } from "../bench-context"; +import { runDeepLinkTarget } from "../insights-deeplinks"; import { computeInsightsStats } from "../insights-stats"; +import { + bucketRunsByDay, + INSIGHTS_TIMELINE_DAYS, + type DayBucket, +} from "../insights-timeline"; import { Link } from "../navigation"; import { tenantKeys } from "../query-client"; import { SignedOutNotice } from "../query-view"; @@ -62,6 +68,34 @@ function formatWhen(iso: string): string { }); } +function TimelineBars({ buckets }: { readonly buckets: readonly DayBucket[] }) { + const max = Math.max(1, ...buckets.map((b) => b.count)); + return ( +
+ {buckets.map((bucket) => { + const heightPct = Math.round((bucket.count / max) * 100); + return ( +
+
+ {bucket.label} +
+ ); + })} +
+ ); +} + export function InsightsPage({ runs, routines, @@ -105,6 +139,11 @@ export function InsightsPage({ const empty = stats !== null && stats.totalRuns === 0 && stats.routineCount === 0; + const timelineBuckets = + runs.kind === "ready" + ? bucketRunsByDay(runs.data.data, INSIGHTS_TIMELINE_DAYS) + : null; + return (
+ {timelineBuckets !== null && !empty ? ( +
+ +
+ ) : null} +
0 ? (
@@ -186,7 +234,11 @@ export function InsightsPage({ {stats.recentRuns.map((row) => ( - {row.definitionName} + + + {row.definitionName} + + {row.status} From 8d5c4a74eee594f47d7f751583a73f086c565757 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 9 Aug 2026 16:55:18 -0700 Subject: [PATCH 6/8] fix Insights I2 timeline test nullability for typecheck --- apps/web/src/insights-timeline.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/insights-timeline.test.ts b/apps/web/src/insights-timeline.test.ts index 2a0425d29..d9b31e76f 100644 --- a/apps/web/src/insights-timeline.test.ts +++ b/apps/web/src/insights-timeline.test.ts @@ -25,9 +25,9 @@ describe("bucketRunsByDay", () => { const buckets = bucketRunsByDay([], 3, NOW); expect(buckets).toHaveLength(3); // Oldest first: Jan 13, Jan 14, Jan 15 (UTC days, since NOW is UTC). - expect(buckets[0].key).toBe("2026-01-13"); - expect(buckets[1].key).toBe("2026-01-14"); - expect(buckets[2].key).toBe("2026-01-15"); + expect(buckets[0]!.key).toBe("2026-01-13"); + expect(buckets[1]!.key).toBe("2026-01-14"); + expect(buckets[2]!.key).toBe("2026-01-15"); expect(buckets.every((b) => b.count === 0)).toBe(true); }); From e282f437e3d2fa34aaacc395581862d27745683a Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 9 Aug 2026 17:01:03 -0700 Subject: [PATCH 7/8] CL-5857: Avoid non-null assertions in timeline tests Assert day keys via map equality so eslint no-non-null-assertion is clean. --- apps/web/src/insights-timeline.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/web/src/insights-timeline.test.ts b/apps/web/src/insights-timeline.test.ts index d9b31e76f..a9cb41638 100644 --- a/apps/web/src/insights-timeline.test.ts +++ b/apps/web/src/insights-timeline.test.ts @@ -25,9 +25,11 @@ describe("bucketRunsByDay", () => { const buckets = bucketRunsByDay([], 3, NOW); expect(buckets).toHaveLength(3); // Oldest first: Jan 13, Jan 14, Jan 15 (UTC days, since NOW is UTC). - expect(buckets[0]!.key).toBe("2026-01-13"); - expect(buckets[1]!.key).toBe("2026-01-14"); - expect(buckets[2]!.key).toBe("2026-01-15"); + expect(buckets.map((b) => b.key)).toEqual([ + "2026-01-13", + "2026-01-14", + "2026-01-15", + ]); expect(buckets.every((b) => b.count === 0)).toBe(true); }); From 74e100332495f5faf2a3acddd620c9a5383d7ca9 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 9 Aug 2026 17:17:49 -0700 Subject: [PATCH 8/8] Filter channel-host runs out of the Insights timeline Timeline buckets now use the same purposeRunsForInsights filter as the stats tiles so chart heights match the page copy. --- apps/web/src/pages/insights-page.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/web/src/pages/insights-page.tsx b/apps/web/src/pages/insights-page.tsx index 4a3da5750..570e59eef 100644 --- a/apps/web/src/pages/insights-page.tsx +++ b/apps/web/src/pages/insights-page.tsx @@ -24,7 +24,10 @@ import { RunsSchema, useAPIQuery } from "../api"; import type { APIQuery, RunsPage, WorkflowRun } from "../api"; import { useBench } from "../bench-context"; import { runDeepLinkTarget } from "../insights-deeplinks"; -import { computeInsightsStats } from "../insights-stats"; +import { + computeInsightsStats, + purposeRunsForInsights, +} from "../insights-stats"; import { bucketRunsByDay, INSIGHTS_TIMELINE_DAYS, @@ -141,7 +144,10 @@ export function InsightsPage({ const timelineBuckets = runs.kind === "ready" - ? bucketRunsByDay(runs.data.data, INSIGHTS_TIMELINE_DAYS) + ? bucketRunsByDay( + purposeRunsForInsights(runs.data.data), + INSIGHTS_TIMELINE_DAYS, + ) : null; return (