From e4a3524b0f9657c342c4903d9f6aa5720fbef4a5 Mon Sep 17 00:00:00 2001 From: janpeter visser Date: Sat, 2 May 2026 16:22:36 +0200 Subject: [PATCH] =?UTF-8?q?feat(insights):=20add=20getJobsPerDay=20helper?= =?UTF-8?q?=20=E2=80=94=20agent=20throughput=20per=20day=20+=20KPIs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../lib/insights/agent-throughput.test.ts | 82 ++++++++++++ lib/insights/agent-throughput.ts | 118 ++++++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 __tests__/lib/insights/agent-throughput.test.ts 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/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, + }, + } +}