Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
33 changes: 33 additions & 0 deletions apps/web/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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%;
}
43 changes: 43 additions & 0 deletions apps/web/src/insights-deeplinks.test.ts
Original file line number Diff line number Diff line change
@@ -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")));
});
});
16 changes: 16 additions & 0 deletions apps/web/src/insights-deeplinks.ts
Original file line number Diff line number Diff line change
@@ -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);
}
90 changes: 90 additions & 0 deletions apps/web/src/insights-stats.test.ts
Original file line number Diff line number Diff line change
@@ -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<WorkflowRun> & Pick<WorkflowRun, "id" | "status">,
): 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<Routine> & Pick<Routine, "id" | "enabled">,
): 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);
});
});
73 changes: 73 additions & 0 deletions apps/web/src/insights-stats.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
65 changes: 65 additions & 0 deletions apps/web/src/insights-timeline.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading