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 ( +
+ Geen agent-activiteit in de laatste 2 weken +
+ ) : ( +