import { z } from 'zod' import { router, protectedProcedure, adminProcedure, observerProcedure } from '../trpc' export const analyticsRouter = router({ /** * Get score distribution for a round (histogram data) */ getScoreDistribution: observerProcedure .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: observerProcedure .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: observerProcedure .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: observerProcedure .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 }, select: { id: true, title: true, teamName: true, status: true, assignments: { select: { 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: observerProcedure .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: observerProcedure .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: observerProcedure .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: observerProcedure .input( z.object({ programId: z.string(), roundId: z.string().optional(), }) ) .query(async ({ ctx, input }) => { const where = input.roundId ? { roundId: input.roundId } : { programId: input.programId } const distribution = await ctx.prisma.project.groupBy({ by: ['country'], where: { ...where, country: { not: null } }, _count: { id: true }, }) return distribution.map((d) => ({ countryCode: d.country || 'UNKNOWN', count: d._count.id, })) }), // ========================================================================= // Advanced Analytics (F10) // ========================================================================= /** * Compare metrics across multiple rounds */ getCrossRoundComparison: observerProcedure .input(z.object({ roundIds: z.array(z.string()).min(2) })) .query(async ({ ctx, input }) => { const comparisons = await Promise.all( input.roundIds.map(async (roundId) => { const round = await ctx.prisma.round.findUniqueOrThrow({ where: { id: roundId }, select: { id: true, name: true }, }) const [projectCount, assignmentCount, evaluationCount] = await Promise.all([ ctx.prisma.project.count({ where: { roundId } }), ctx.prisma.assignment.count({ where: { roundId } }), ctx.prisma.evaluation.count({ where: { assignment: { roundId }, status: 'SUBMITTED', }, }), ]) const completionRate = assignmentCount > 0 ? Math.round((evaluationCount / assignmentCount) * 100) : 0 // Get average scores const evaluations = await ctx.prisma.evaluation.findMany({ where: { assignment: { roundId }, status: 'SUBMITTED', }, select: { globalScore: true }, }) const globalScores = evaluations .map((e) => e.globalScore) .filter((s): s is number => s !== null) const averageScore = globalScores.length > 0 ? globalScores.reduce((a, b) => a + b, 0) / globalScores.length : null // Score distribution const distribution = Array.from({ length: 10 }, (_, i) => ({ score: i + 1, count: globalScores.filter((s) => Math.round(s) === i + 1).length, })) return { roundId, roundName: round.name, projectCount, evaluationCount, completionRate, averageScore, scoreDistribution: distribution, } }) ) return comparisons }), /** * Get juror consistency metrics for a round */ getJurorConsistency: observerProcedure .input(z.object({ roundId: z.string() })) .query(async ({ ctx, input }) => { const evaluations = await ctx.prisma.evaluation.findMany({ where: { assignment: { roundId: input.roundId }, status: 'SUBMITTED', }, include: { assignment: { include: { user: { select: { id: true, name: true, email: true } }, }, }, }, }) // Group scores by juror const jurorScores: Record = {} evaluations.forEach((e) => { const userId = e.assignment.userId if (!jurorScores[userId]) { jurorScores[userId] = { name: e.assignment.user.name || e.assignment.user.email || 'Unknown', email: e.assignment.user.email || '', scores: [], } } if (e.globalScore !== null) { jurorScores[userId].scores.push(e.globalScore) } }) // Calculate overall average const allScores = Object.values(jurorScores).flatMap((j) => j.scores) const overallAverage = allScores.length > 0 ? allScores.reduce((a, b) => a + b, 0) / allScores.length : 0 // Calculate per-juror metrics const metrics = Object.entries(jurorScores).map(([userId, data]) => { const avg = data.scores.length > 0 ? data.scores.reduce((a, b) => a + b, 0) / data.scores.length : 0 const variance = data.scores.length > 1 ? data.scores.reduce((sum, s) => sum + Math.pow(s - avg, 2), 0) / data.scores.length : 0 const stddev = Math.sqrt(variance) const deviationFromOverall = Math.abs(avg - overallAverage) return { userId, name: data.name, email: data.email, evaluationCount: data.scores.length, averageScore: avg, stddev, deviationFromOverall, isOutlier: deviationFromOverall > 2, // Flag if 2+ points from mean } }) return { overallAverage, jurors: metrics.sort((a, b) => b.deviationFromOverall - a.deviationFromOverall), } }), /** * Get diversity metrics for projects in a round */ getDiversityMetrics: observerProcedure .input(z.object({ roundId: z.string() })) .query(async ({ ctx, input }) => { const projects = await ctx.prisma.project.findMany({ where: { roundId: input.roundId }, select: { country: true, competitionCategory: true, oceanIssue: true, tags: true, }, }) const total = projects.length if (total === 0) { return { total: 0, byCountry: [], byCategory: [], byOceanIssue: [], byTag: [] } } // By country const countryCounts: Record = {} projects.forEach((p) => { const key = p.country || 'Unknown' countryCounts[key] = (countryCounts[key] || 0) + 1 }) const byCountry = Object.entries(countryCounts) .map(([country, count]) => ({ country, count, percentage: (count / total) * 100 })) .sort((a, b) => b.count - a.count) // By competition category const categoryCounts: Record = {} projects.forEach((p) => { const key = p.competitionCategory || 'Uncategorized' categoryCounts[key] = (categoryCounts[key] || 0) + 1 }) const byCategory = Object.entries(categoryCounts) .map(([category, count]) => ({ category, count, percentage: (count / total) * 100 })) .sort((a, b) => b.count - a.count) // By ocean issue const issueCounts: Record = {} projects.forEach((p) => { const key = p.oceanIssue || 'Unspecified' issueCounts[key] = (issueCounts[key] || 0) + 1 }) const byOceanIssue = Object.entries(issueCounts) .map(([issue, count]) => ({ issue, count, percentage: (count / total) * 100 })) .sort((a, b) => b.count - a.count) // By tag const tagCounts: Record = {} projects.forEach((p) => { p.tags.forEach((tag) => { tagCounts[tag] = (tagCounts[tag] || 0) + 1 }) }) const byTag = Object.entries(tagCounts) .map(([tag, count]) => ({ tag, count, percentage: (count / total) * 100 })) .sort((a, b) => b.count - a.count) return { total, byCountry, byCategory, byOceanIssue, byTag } }), /** * Get year-over-year stats across all rounds in a program */ getYearOverYear: observerProcedure .input(z.object({ programId: z.string() })) .query(async ({ ctx, input }) => { const rounds = await ctx.prisma.round.findMany({ where: { programId: input.programId }, select: { id: true, name: true, createdAt: true }, orderBy: { createdAt: 'asc' }, }) const stats = await Promise.all( rounds.map(async (round) => { const [projectCount, evaluationCount, assignmentCount] = await Promise.all([ ctx.prisma.project.count({ where: { roundId: round.id } }), ctx.prisma.evaluation.count({ where: { assignment: { roundId: round.id }, status: 'SUBMITTED', }, }), ctx.prisma.assignment.count({ where: { roundId: round.id } }), ]) const completionRate = assignmentCount > 0 ? Math.round((evaluationCount / assignmentCount) * 100) : 0 // Average score const evaluations = await ctx.prisma.evaluation.findMany({ where: { assignment: { roundId: round.id }, status: 'SUBMITTED', }, select: { globalScore: true }, }) const scores = evaluations .map((e) => e.globalScore) .filter((s): s is number => s !== null) const averageScore = scores.length > 0 ? scores.reduce((a, b) => a + b, 0) / scores.length : null return { roundId: round.id, roundName: round.name, createdAt: round.createdAt, projectCount, evaluationCount, completionRate, averageScore, } }) ) return stats }), /** * Get dashboard stats (optionally scoped to a round) */ getDashboardStats: observerProcedure .input(z.object({ roundId: z.string().optional() }).optional()) .query(async ({ ctx, input }) => { const roundId = input?.roundId const roundWhere = roundId ? { roundId } : {} const assignmentWhere = roundId ? { roundId } : {} const evalWhere = roundId ? { assignment: { roundId }, status: 'SUBMITTED' as const } : { status: 'SUBMITTED' as const } const [ programCount, activeRoundCount, projectCount, jurorCount, submittedEvaluations, totalAssignments, evaluationScores, ] = await Promise.all([ ctx.prisma.program.count(), ctx.prisma.round.count({ where: { status: 'ACTIVE' } }), ctx.prisma.project.count({ where: roundWhere }), ctx.prisma.user.count({ where: { role: 'JURY_MEMBER', status: 'ACTIVE' } }), ctx.prisma.evaluation.count({ where: evalWhere }), ctx.prisma.assignment.count({ where: assignmentWhere }), ctx.prisma.evaluation.findMany({ where: { ...evalWhere, globalScore: { not: null } }, select: { globalScore: true }, }), ]) const completionRate = totalAssignments > 0 ? Math.round((submittedEvaluations / totalAssignments) * 100) : 0 const scores = evaluationScores.map((e) => e.globalScore!).filter((s) => s != null) const scoreDistribution = [ { label: '9-10', min: 9, max: 10 }, { label: '7-8', min: 7, max: 8.99 }, { label: '5-6', min: 5, max: 6.99 }, { label: '3-4', min: 3, max: 4.99 }, { label: '1-2', min: 1, max: 2.99 }, ].map((b) => ({ label: b.label, count: scores.filter((s) => s >= b.min && s <= b.max).length, })) return { programCount, activeRoundCount, projectCount, jurorCount, submittedEvaluations, totalEvaluations: totalAssignments, completionRate, scoreDistribution, } }), /** * Get all projects with pagination, filtering, and search (for observer dashboard) */ getAllProjects: observerProcedure .input( z.object({ roundId: z.string().optional(), search: z.string().optional(), status: z.string().optional(), page: z.number().min(1).default(1), perPage: z.number().min(1).max(100).default(20), }) ) .query(async ({ ctx, input }) => { const where: Record = {} if (input.roundId) { where.roundId = input.roundId } if (input.status) { where.status = input.status } if (input.search) { where.OR = [ { title: { contains: input.search, mode: 'insensitive' } }, { teamName: { contains: input.search, mode: 'insensitive' } }, ] } const [projects, total] = await Promise.all([ ctx.prisma.project.findMany({ where, select: { id: true, title: true, teamName: true, status: true, country: true, round: { select: { id: true, name: true } }, assignments: { select: { evaluation: { select: { globalScore: true, status: true }, }, }, }, }, orderBy: { title: 'asc' }, skip: (input.page - 1) * input.perPage, take: input.perPage, }), ctx.prisma.project.count({ where }), ]) const mapped = projects.map((p) => { const submitted = p.assignments .map((a) => a.evaluation) .filter((e) => e?.status === 'SUBMITTED') const scores = submitted .map((e) => e?.globalScore) .filter((s): s is number => s !== null) const averageScore = scores.length > 0 ? scores.reduce((a, b) => a + b, 0) / scores.length : null return { id: p.id, title: p.title, teamName: p.teamName, status: p.status, country: p.country, roundId: p.round?.id ?? '', roundName: p.round?.name ?? '', averageScore, evaluationCount: submitted.length, } }) return { projects: mapped, total, page: input.page, perPage: input.perPage, totalPages: Math.ceil(total / input.perPage), } }), })