From 739037a60b53c010acc18c950b3a8f344f4c91ac Mon Sep 17 00:00:00 2001 From: Janpeter Visser Date: Sat, 2 May 2026 16:30:06 +0200 Subject: [PATCH] Agent throughput: jobs per dag stacked bar + KPI-strip (#49) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(insights): add getJobsPerDay helper — agent throughput per day + KPIs Raw SQL aggregation of claude_jobs by day and status over 14 days with zero-fill for missing days. KPIs: todayCount, successRate7d, avgDurationSeconds7d. Optional productId filter. Co-Authored-By: Claude Sonnet 4.6 * feat(insights): add AgentThroughputCard — stacked BarChart + KPI-strip + product filter KPI strip (jobs today, 7d success rate, 7d avg duration), 14-day stacked BarChart with JOB_STATUS_COLORS, and URL-bookmarkable product dropdown via useTransition + router.replace. Empty-state when no activity. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- .../lib/insights/agent-throughput.test.ts | 82 +++++++++++ .../insights/components/agent-throughput.tsx | 133 ++++++++++++++++++ lib/insights/agent-throughput.ts | 118 ++++++++++++++++ 3 files changed, 333 insertions(+) create mode 100644 __tests__/lib/insights/agent-throughput.test.ts create mode 100644 app/(app)/insights/components/agent-throughput.tsx create mode 100644 lib/insights/agent-throughput.ts diff --git a/__tests__/lib/insights/agent-throughput.test.ts b/__tests__/lib/insights/agent-throughput.test.ts new file mode 100644 index 0000000..3465dd4 --- /dev/null +++ b/__tests__/lib/insights/agent-throughput.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const { mockQueryRaw } = vi.hoisted(() => ({ mockQueryRaw: vi.fn() })) + +vi.mock('@/lib/prisma', () => ({ + prisma: { $queryRaw: mockQueryRaw }, +})) + +import { getJobsPerDay } from '@/lib/insights/agent-throughput' + +// Build a date string for N days ago (UTC) +function daysAgo(n: number): Date { + const d = new Date() + d.setUTCDate(d.getUTCDate() - n) + return d +} + +function toUTCDate(d: Date): Date { + return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())) +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('getJobsPerDay', () => { + it('returns a 14-day array zero-filled for missing days', async () => { + // Only 3 days have data; the rest should be 0 + const day0 = toUTCDate(daysAgo(0)) + const day3 = toUTCDate(daysAgo(3)) + const day7 = toUTCDate(daysAgo(7)) + + const dayRows = [ + { day: day0, status: 'done', count: BigInt(2) }, + { day: day3, status: 'failed', count: BigInt(1) }, + { day: day7, status: 'done', count: BigInt(5) }, + ] + + const kpiRows = [ + { today_count: BigInt(2), done_7d: BigInt(7), terminal_7d: BigInt(10), avg_seconds: 120 }, + ] + + mockQueryRaw.mockResolvedValueOnce(dayRows).mockResolvedValueOnce(kpiRows) + + const result = await getJobsPerDay('user-1') + + expect(result.perDay).toHaveLength(14) + + // All days should have zero counts except the three we seeded + const nonZero = result.perDay.filter( + d => d.done + d.failed + d.queued + d.claimed + d.running + d.cancelled > 0, + ) + expect(nonZero).toHaveLength(3) + + // Today's done count should be 2 + const today = result.perDay[result.perDay.length - 1] + expect(today.done).toBe(2) + }) + + it('calculates KPIs correctly', async () => { + mockQueryRaw.mockResolvedValueOnce([]).mockResolvedValueOnce([ + { today_count: BigInt(3), done_7d: BigInt(7), terminal_7d: BigInt(10), avg_seconds: 90 }, + ]) + + const result = await getJobsPerDay('user-1') + + expect(result.kpi.todayCount).toBe(3) + expect(result.kpi.successRate7d).toBe(0.7) + expect(result.kpi.avgDurationSeconds7d).toBe(90) + }) + + it('returns zero successRate and null avgDuration when no terminal jobs', async () => { + mockQueryRaw.mockResolvedValueOnce([]).mockResolvedValueOnce([ + { today_count: BigInt(0), done_7d: BigInt(0), terminal_7d: BigInt(0), avg_seconds: null }, + ]) + + const result = await getJobsPerDay('user-1') + + expect(result.kpi.successRate7d).toBe(0) + expect(result.kpi.avgDurationSeconds7d).toBeNull() + }) +}) diff --git a/app/(app)/insights/components/agent-throughput.tsx b/app/(app)/insights/components/agent-throughput.tsx new file mode 100644 index 0000000..820e64f --- /dev/null +++ b/app/(app)/insights/components/agent-throughput.tsx @@ -0,0 +1,133 @@ +'use client' + +import { useRouter, usePathname, useSearchParams } from 'next/navigation' +import { useTransition } from 'react' +import { + BarChart, + Bar, + XAxis, + YAxis, + Tooltip, + ResponsiveContainer, +} from 'recharts' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import type { JobsPerDayResult } from '@/lib/insights/agent-throughput' +import { JOB_STATUS_COLORS } from '@/lib/chart-colors' + +interface Props { + data: JobsPerDayResult + productList: { id: string; name: string }[] + currentProductId?: string +} + +function formatDuration(seconds: number | null): string { + if (seconds === null) return '—' + const m = Math.floor(seconds / 60) + const s = seconds % 60 + return m > 0 ? `${m}m ${s}s` : `${s}s` +} + +const STACKED_STATUSES = ['queued', 'claimed', 'running', 'done', 'failed', 'cancelled'] as const + +export function AgentThroughputCard({ data, productList, currentProductId }: Props) { + const router = useRouter() + const pathname = usePathname() + const searchParams = useSearchParams() + const [isPending, startTransition] = useTransition() + + const { perDay, kpi } = data + + const isEmpty = perDay.every( + d => d.done + d.failed + d.queued + d.claimed + d.running + d.cancelled === 0, + ) + + function handleProductChange(value: string | null) { + if (value === null) return + startTransition(() => { + const params = new URLSearchParams(searchParams.toString()) + if (value === '__all__') { + params.delete('product') + } else { + params.set('product', value) + } + router.replace(`${pathname}?${params.toString()}`) + }) + } + + return ( +
+ {/* KPI strip + product filter */} +
+
+
+
{kpi.todayCount}
+
Jobs vandaag
+
+
+
+ {kpi.successRate7d === 0 ? '—' : `${Math.round(kpi.successRate7d * 100)}%`} +
+
Success-rate (7d)
+
+
+
+ {formatDuration(kpi.avgDurationSeconds7d)} +
+
Avg duration (7d)
+
+
+ + {productList.length > 0 && ( + + )} +
+ + {/* Chart */} + {isEmpty ? ( +

+ Geen agent-activiteit in de laatste 2 weken +

+ ) : ( + + + (v as string).slice(5)} + /> + + + {STACKED_STATUSES.map(status => ( + + ))} + + + )} +
+ ) +} diff --git a/lib/insights/agent-throughput.ts b/lib/insights/agent-throughput.ts new file mode 100644 index 0000000..ecc97ac --- /dev/null +++ b/lib/insights/agent-throughput.ts @@ -0,0 +1,118 @@ +import { prisma } from '@/lib/prisma' + +export interface DayCount { + day: string + queued: number + claimed: number + running: number + done: number + failed: number + cancelled: number +} + +export interface ThroughputKpi { + todayCount: number + successRate7d: number + avgDurationSeconds7d: number | null +} + +export interface JobsPerDayResult { + perDay: DayCount[] + kpi: ThroughputKpi +} + +const STATUSES = ['queued', 'claimed', 'running', 'done', 'failed', 'cancelled'] as const + +type RawDayRow = { day: Date; status: string; count: bigint } +type RawKpiRow = { today_count: bigint; done_7d: bigint; terminal_7d: bigint; avg_seconds: number | null } + +function toDateStr(d: Date): string { + return d.toISOString().slice(0, 10) +} + +export async function getJobsPerDay( + userId: string, + days = 14, + productId?: string, +): Promise { + const [dayRows, kpiRows] = await Promise.all([ + productId + ? prisma.$queryRaw` + SELECT DATE(created_at) AS day, LOWER(status::text) AS status, COUNT(*) AS count + FROM claude_jobs + WHERE user_id = ${userId} + AND product_id = ${productId} + AND created_at > NOW() - (${days} || ' days')::INTERVAL + GROUP BY DATE(created_at), status + ORDER BY day ASC + ` + : prisma.$queryRaw` + SELECT DATE(created_at) AS day, LOWER(status::text) AS status, COUNT(*) AS count + FROM claude_jobs + WHERE user_id = ${userId} + AND created_at > NOW() - (${days} || ' days')::INTERVAL + GROUP BY DATE(created_at), status + ORDER BY day ASC + `, + productId + ? prisma.$queryRaw` + SELECT + COUNT(*) FILTER (WHERE DATE(created_at) = CURRENT_DATE) AS today_count, + COUNT(*) FILTER (WHERE status = 'DONE' AND created_at > NOW() - INTERVAL '7 days') AS done_7d, + COUNT(*) FILTER (WHERE status IN ('DONE','FAILED','CANCELLED') AND created_at > NOW() - INTERVAL '7 days') AS terminal_7d, + AVG(EXTRACT(EPOCH FROM (finished_at - claimed_at))) FILTER (WHERE status = 'DONE' AND created_at > NOW() - INTERVAL '7 days') AS avg_seconds + FROM claude_jobs + WHERE user_id = ${userId} + AND product_id = ${productId} + ` + : prisma.$queryRaw` + SELECT + COUNT(*) FILTER (WHERE DATE(created_at) = CURRENT_DATE) AS today_count, + COUNT(*) FILTER (WHERE status = 'DONE' AND created_at > NOW() - INTERVAL '7 days') AS done_7d, + COUNT(*) FILTER (WHERE status IN ('DONE','FAILED','CANCELLED') AND created_at > NOW() - INTERVAL '7 days') AS terminal_7d, + AVG(EXTRACT(EPOCH FROM (finished_at - claimed_at))) FILTER (WHERE status = 'DONE' AND created_at > NOW() - INTERVAL '7 days') AS avg_seconds + FROM claude_jobs + WHERE user_id = ${userId} + `, + ]) + + // Build lookup: dayStr → status → count + const lookup = new Map>() + for (const row of dayRows) { + const d = toDateStr(row.day) + if (!lookup.has(d)) lookup.set(d, new Map()) + lookup.get(d)!.set(row.status, Number(row.count)) + } + + // Generate full date range with zero-fills + const now = new Date() + const perDay: DayCount[] = [] + for (let i = days - 1; i >= 0; i--) { + const d = new Date(now) + d.setUTCDate(d.getUTCDate() - i) + const key = toDateStr(d) + const statusMap = lookup.get(key) ?? new Map() + perDay.push({ + day: key, + queued: statusMap.get('queued') ?? 0, + claimed: statusMap.get('claimed') ?? 0, + running: statusMap.get('running') ?? 0, + done: statusMap.get('done') ?? 0, + failed: statusMap.get('failed') ?? 0, + cancelled: statusMap.get('cancelled') ?? 0, + }) + } + + const kpiRow = kpiRows[0] + const done7d = Number(kpiRow?.done_7d ?? 0) + const terminal7d = Number(kpiRow?.terminal_7d ?? 0) + + return { + perDay, + kpi: { + todayCount: Number(kpiRow?.today_count ?? 0), + successRate7d: terminal7d === 0 ? 0 : Math.round((done7d / terminal7d) * 100) / 100, + avgDurationSeconds7d: kpiRow?.avg_seconds != null ? Math.round(Number(kpiRow.avg_seconds)) : null, + }, + } +}