import { z } from 'zod' import { router, protectedProcedure, adminProcedure } from '../trpc' export const analyticsRouter = router({ /** * Get score distribution for a round (histogram data) */ getScoreDistribution: adminProcedure .input(z.object({ roundId: z.string() })) .query(async ({ ctx, input }) => { const evaluations = await ctx.prisma.evaluation.findMany({ where: { assignment: { roundId: input.roundId }, status: 'SUBMITTED', }, select: { criterionScoresJson: true, }, }) // Extract all scores and calculate distribution const allScores: number[] = [] evaluations.forEach((evaluation) => { const scores = evaluation.criterionScoresJson as Record | null if (scores) { Object.values(scores).forEach((score) => { if (typeof score === 'number') { allScores.push(score) } }) } }) // Count scores by bucket (1-10) const distribution = Array.from({ length: 10 }, (_, i) => ({ score: i + 1, count: allScores.filter((s) => Math.round(s) === i + 1).length, })) return { distribution, totalScores: allScores.length, averageScore: allScores.length > 0 ? allScores.reduce((a, b) => a + b, 0) / allScores.length : 0, } }), /** * Get evaluation completion over time (timeline data) */ getEvaluationTimeline: adminProcedure .input(z.object({ roundId: z.string() })) .query(async ({ ctx, input }) => { const evaluations = await ctx.prisma.evaluation.findMany({ where: { assignment: { roundId: input.roundId }, status: 'SUBMITTED', }, select: { submittedAt: true, }, orderBy: { submittedAt: 'asc' }, }) // Group by date const byDate: Record = {} let cumulative = 0 evaluations.forEach((evaluation) => { if (evaluation.submittedAt) { const date = evaluation.submittedAt.toISOString().split('T')[0] if (!byDate[date]) { byDate[date] = 0 } byDate[date]++ } }) // Convert to cumulative timeline const timeline = Object.entries(byDate) .sort(([a], [b]) => a.localeCompare(b)) .map(([date, count]) => { cumulative += count return { date, daily: count, cumulative, } }) return timeline }), /** * Get juror workload distribution */ getJurorWorkload: adminProcedure .input(z.object({ roundId: z.string() })) .query(async ({ ctx, input }) => { const assignments = await ctx.prisma.assignment.findMany({ where: { roundId: input.roundId }, include: { user: { select: { name: true, email: true } }, evaluation: { select: { id: true, status: true }, }, }, }) // Group by user const byUser: Record< string, { name: string; assigned: number; completed: number } > = {} assignments.forEach((assignment) => { const userId = assignment.userId if (!byUser[userId]) { byUser[userId] = { name: assignment.user.name || assignment.user.email || 'Unknown', assigned: 0, completed: 0, } } byUser[userId].assigned++ if (assignment.evaluation?.status === 'SUBMITTED') { byUser[userId].completed++ } }) return Object.entries(byUser) .map(([id, data]) => ({ id, ...data, completionRate: data.assigned > 0 ? Math.round((data.completed / data.assigned) * 100) : 0, })) .sort((a, b) => b.assigned - a.assigned) }), /** * Get project rankings with average scores */ getProjectRankings: adminProcedure .input(z.object({ roundId: z.string(), limit: z.number().optional() })) .query(async ({ ctx, input }) => { const projects = await ctx.prisma.project.findMany({ where: { roundId: input.roundId }, include: { assignments: { include: { evaluation: { select: { criterionScoresJson: true, status: true }, }, }, }, }, }) // Calculate average scores const rankings = projects .map((project) => { const allScores: number[] = [] project.assignments.forEach((assignment) => { const evaluation = assignment.evaluation if (evaluation?.status === 'SUBMITTED') { const scores = evaluation.criterionScoresJson as Record< string, number > | null if (scores) { const scoreValues = Object.values(scores).filter( (s): s is number => typeof s === 'number' ) if (scoreValues.length > 0) { const average = scoreValues.reduce((a, b) => a + b, 0) / scoreValues.length allScores.push(average) } } } }) const averageScore = allScores.length > 0 ? allScores.reduce((a, b) => a + b, 0) / allScores.length : null return { id: project.id, title: project.title, teamName: project.teamName, status: project.status, averageScore, evaluationCount: allScores.length, } }) .filter((p) => p.averageScore !== null) .sort((a, b) => (b.averageScore || 0) - (a.averageScore || 0)) return input.limit ? rankings.slice(0, input.limit) : rankings }), /** * Get status breakdown (pie chart data) */ getStatusBreakdown: adminProcedure .input(z.object({ roundId: z.string() })) .query(async ({ ctx, input }) => { const projects = await ctx.prisma.project.groupBy({ by: ['status'], where: { roundId: input.roundId }, _count: true, }) return projects.map((p) => ({ status: p.status, count: p._count, })) }), /** * Get overview stats for dashboard */ getOverviewStats: adminProcedure .input(z.object({ roundId: z.string() })) .query(async ({ ctx, input }) => { const [ projectCount, assignmentCount, evaluationCount, jurorCount, statusCounts, ] = await Promise.all([ ctx.prisma.project.count({ where: { roundId: input.roundId } }), ctx.prisma.assignment.count({ where: { roundId: input.roundId } }), ctx.prisma.evaluation.count({ where: { assignment: { roundId: input.roundId }, status: 'SUBMITTED', }, }), ctx.prisma.assignment.groupBy({ by: ['userId'], where: { roundId: input.roundId }, }), ctx.prisma.project.groupBy({ by: ['status'], where: { roundId: input.roundId }, _count: true, }), ]) const completionRate = assignmentCount > 0 ? Math.round((evaluationCount / assignmentCount) * 100) : 0 return { projectCount, assignmentCount, evaluationCount, jurorCount: jurorCount.length, completionRate, statusBreakdown: statusCounts.map((s) => ({ status: s.status, count: s._count, })), } }), /** * Get criteria-level score distribution */ getCriteriaScores: adminProcedure .input(z.object({ roundId: z.string() })) .query(async ({ ctx, input }) => { // Get active evaluation form for this round const evaluationForm = await ctx.prisma.evaluationForm.findFirst({ where: { roundId: input.roundId, isActive: true }, }) if (!evaluationForm?.criteriaJson) { return [] } // Parse criteria from JSON const criteria = evaluationForm.criteriaJson as Array<{ id: string label: string }> if (!criteria || criteria.length === 0) { return [] } // Get all evaluations const evaluations = await ctx.prisma.evaluation.findMany({ where: { assignment: { roundId: input.roundId }, status: 'SUBMITTED', }, select: { criterionScoresJson: true }, }) // Calculate average score per criterion const criteriaScores = criteria.map((criterion) => { const scores: number[] = [] evaluations.forEach((evaluation) => { const criterionScoresJson = evaluation.criterionScoresJson as Record< string, number > | null if (criterionScoresJson && typeof criterionScoresJson[criterion.id] === 'number') { scores.push(criterionScoresJson[criterion.id]) } }) return { id: criterion.id, name: criterion.label, averageScore: scores.length > 0 ? scores.reduce((a, b) => a + b, 0) / scores.length : 0, count: scores.length, } }) return criteriaScores }), /** * Get geographic distribution of projects by country */ getGeographicDistribution: adminProcedure .input( z.object({ programId: z.string(), roundId: z.string().optional(), }) ) .query(async ({ ctx, input }) => { const where = input.roundId ? { roundId: input.roundId } : { round: { programId: input.programId } } const distribution = await ctx.prisma.project.groupBy({ by: ['country'], where, _count: { id: true }, }) return distribution.map((d) => ({ countryCode: d.country || 'UNKNOWN', count: d._count.id, })) }), })