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.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-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-stats.test.ts b/apps/web/src/insights-stats.test.ts new file mode 100644 index 000000000..8af683269 --- /dev/null +++ b/apps/web/src/insights-stats.test.ts @@ -0,0 +1,90 @@ +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); + }); +}); diff --git a/apps/web/src/insights-stats.ts b/apps/web/src/insights-stats.ts new file mode 100644 index 000000000..d25826dff --- /dev/null +++ b/apps/web/src/insights-stats.ts @@ -0,0 +1,73 @@ +// 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/insights-timeline.test.ts b/apps/web/src/insights-timeline.test.ts new file mode 100644 index 000000000..a9cb41638 --- /dev/null +++ b/apps/web/src/insights-timeline.test.ts @@ -0,0 +1,65 @@ +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.map((b) => b.key)).toEqual([ + "2026-01-13", + "2026-01-14", + "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); + }); +}); 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 8096e778c..570e59eef 100644 --- a/apps/web/src/pages/insights-page.tsx +++ b/apps/web/src/pages/insights-page.tsx @@ -1,23 +1,279 @@ -import { PageShell, RichEmptyState } from "@corbits/react-ui"; +// 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, + 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 { runDeepLinkTarget } from "../insights-deeplinks"; +import { + computeInsightsStats, + purposeRunsForInsights, +} 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"; +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", + }); +} + +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, +}: { + 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; + + const timelineBuckets = + runs.kind === "ready" + ? bucketRunsByDay( + purposeRunsForInsights(runs.data.data), + INSIGHTS_TIMELINE_DAYS, + ) + : null; -/** - * 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." - /> +
+ + + + + + +
+ + {timelineBuckets !== null && !empty ? ( +
+ +
+ ) : null} + +
+ {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 ; }