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, }, } }