import { z } from 'zod' import { router, observerProcedure } from '../trpc' import { normalizeCountryToCode } from '@/lib/countries' const editionOrStageInput = z.object({ stageId: z.string().optional(), programId: z.string().optional(), }).refine(data => data.stageId || data.programId, { message: 'Either stageId or programId is required', }) function projectWhere(input: { stageId?: string; programId?: string }) { if (input.stageId) return { assignments: { some: { stageId: input.stageId } } } return { programId: input.programId! } } function assignmentWhere(input: { stageId?: string; programId?: string }) { if (input.stageId) return { stageId: input.stageId } return { stage: { track: { pipeline: { programId: input.programId! } } } } } function evalWhere(input: { stageId?: string; programId?: string }, extra: Record = {}) { const base = input.stageId ? { assignment: { stageId: input.stageId } } : { assignment: { stage: { track: { pipeline: { programId: input.programId! } } } } } return { ...base, ...extra } } export const analyticsRouter = router({ /** * Get score distribution (histogram data) */ getScoreDistribution: observerProcedure .input(editionOrStageInput) .query(async ({ ctx, input }) => { const evaluations = await ctx.prisma.evaluation.findMany({ where: evalWhere(input, { 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(editionOrStageInput) .query(async ({ ctx, input }) => { const evaluations = await ctx.prisma.evaluation.findMany({ where: evalWhere(input, { 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(editionOrStageInput) .query(async ({ ctx, input }) => { const assignments = await ctx.prisma.assignment.findMany({ where: assignmentWhere(input), 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(editionOrStageInput.and(z.object({ limit: z.number().optional() }))) .query(async ({ ctx, input }) => { const projects = await ctx.prisma.project.findMany({ where: projectWhere(input), 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(editionOrStageInput) .query(async ({ ctx, input }) => { const projects = await ctx.prisma.project.groupBy({ by: ['status'], where: projectWhere(input), _count: true, }) return projects.map((p) => ({ status: p.status, count: p._count, })) }), /** * Get overview stats for dashboard */ getOverviewStats: observerProcedure .input(editionOrStageInput) .query(async ({ ctx, input }) => { const [ projectCount, assignmentCount, evaluationCount, jurorCount, statusCounts, ] = await Promise.all([ ctx.prisma.project.count({ where: projectWhere(input) }), ctx.prisma.assignment.count({ where: assignmentWhere(input) }), ctx.prisma.evaluation.count({ where: evalWhere(input, { status: 'SUBMITTED' }), }), ctx.prisma.assignment.groupBy({ by: ['userId'], where: assignmentWhere(input), }), ctx.prisma.project.groupBy({ by: ['status'], where: projectWhere(input), _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(editionOrStageInput) .query(async ({ ctx, input }) => { const formWhere = input.stageId ? { stageId: input.stageId, isActive: true } : { stage: { track: { pipeline: { programId: input.programId! } } }, isActive: true } const evaluationForms = await ctx.prisma.evaluationForm.findMany({ where: formWhere, }) if (!evaluationForms.length) { return [] } const criteriaMap = new Map() evaluationForms.forEach((form) => { const criteria = form.criteriaJson as Array<{ id: string; label: string }> | null if (criteria) { criteria.forEach((c) => { const key = input.stageId ? c.id : c.label if (!criteriaMap.has(key)) { criteriaMap.set(key, c) } }) } }) const criteria = Array.from(criteriaMap.values()) if (criteria.length === 0) { return [] } // Get all evaluations const evaluations = await ctx.prisma.evaluation.findMany({ where: evalWhere(input, { 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(), stageId: z.string().optional(), }) ) .query(async ({ ctx, input }) => { const where = input.stageId ? { assignments: { some: { stageId: input.stageId } } } : { programId: input.programId } const distribution = await ctx.prisma.project.groupBy({ by: ['country'], where: { ...where, country: { not: null } }, _count: { id: true }, }) // Resolve country names to ISO codes (DB may store "France" instead of "FR") const codeMap = new Map() for (const d of distribution) { const resolved = normalizeCountryToCode(d.country) ?? d.country ?? 'UNKNOWN' codeMap.set(resolved, (codeMap.get(resolved) ?? 0) + d._count.id) } return Array.from(codeMap.entries()).map(([countryCode, count]) => ({ countryCode, count, })) }), // ========================================================================= // Advanced Analytics (F10) // ========================================================================= /** * Compare metrics across multiple stages */ getCrossStageComparison: observerProcedure .input(z.object({ stageIds: z.array(z.string()).min(2) })) .query(async ({ ctx, input }) => { const comparisons = await Promise.all( input.stageIds.map(async (stageId) => { const stage = await ctx.prisma.stage.findUniqueOrThrow({ where: { id: stageId }, select: { id: true, name: true }, }) const [projectCount, assignmentCount, evaluationCount] = await Promise.all([ ctx.prisma.project.count({ where: { assignments: { some: { stageId } } }, }), ctx.prisma.assignment.count({ where: { stageId } }), ctx.prisma.evaluation.count({ where: { assignment: { stageId }, status: 'SUBMITTED', }, }), ]) const completionRate = assignmentCount > 0 ? Math.round((evaluationCount / assignmentCount) * 100) : 0 const evaluations = await ctx.prisma.evaluation.findMany({ where: { assignment: { stageId }, 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 const distribution = Array.from({ length: 10 }, (_, i) => ({ score: i + 1, count: globalScores.filter((s) => Math.round(s) === i + 1).length, })) return { stageId, stageName: stage.name, projectCount, evaluationCount, completionRate, averageScore, scoreDistribution: distribution, } }) ) return comparisons }), /** * Get juror consistency metrics for a stage */ getJurorConsistency: observerProcedure .input(editionOrStageInput) .query(async ({ ctx, input }) => { const evaluations = await ctx.prisma.evaluation.findMany({ where: evalWhere(input, { 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 stage */ getDiversityMetrics: observerProcedure .input(editionOrStageInput) .query(async ({ ctx, input }) => { const projects = await ctx.prisma.project.findMany({ where: projectWhere(input), 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 stages in a program */ getYearOverYear: observerProcedure .input(z.object({ programId: z.string() })) .query(async ({ ctx, input }) => { const stages = await ctx.prisma.stage.findMany({ where: { track: { pipeline: { programId: input.programId } } }, select: { id: true, name: true, createdAt: true }, orderBy: { createdAt: 'asc' }, }) const stats = await Promise.all( stages.map(async (stage) => { const [projectCount, evaluationCount, assignmentCount] = await Promise.all([ ctx.prisma.project.count({ where: { assignments: { some: { stageId: stage.id } } }, }), ctx.prisma.evaluation.count({ where: { assignment: { stageId: stage.id }, status: 'SUBMITTED', }, }), ctx.prisma.assignment.count({ where: { stageId: stage.id } }), ]) const completionRate = assignmentCount > 0 ? Math.round((evaluationCount / assignmentCount) * 100) : 0 const evaluations = await ctx.prisma.evaluation.findMany({ where: { assignment: { stageId: stage.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 { stageId: stage.id, stageName: stage.name, createdAt: stage.createdAt, projectCount, evaluationCount, completionRate, averageScore, } }) ) return stats }), /** * Get dashboard stats (optionally scoped to a stage) */ getDashboardStats: observerProcedure .input(z.object({ stageId: z.string().optional() }).optional()) .query(async ({ ctx, input }) => { const stageId = input?.stageId const projectFilter = stageId ? { assignments: { some: { stageId } } } : {} const assignmentFilter = stageId ? { stageId } : {} const evalFilter = stageId ? { assignment: { stageId }, status: 'SUBMITTED' as const } : { status: 'SUBMITTED' as const } const [ programCount, activeStageCount, projectCount, jurorCount, submittedEvaluations, totalAssignments, evaluationScores, ] = await Promise.all([ ctx.prisma.program.count(), ctx.prisma.stage.count({ where: { status: 'STAGE_ACTIVE' } }), ctx.prisma.project.count({ where: projectFilter }), ctx.prisma.user.count({ where: { role: 'JURY_MEMBER', status: 'ACTIVE' } }), ctx.prisma.evaluation.count({ where: evalFilter }), ctx.prisma.assignment.count({ where: assignmentFilter }), ctx.prisma.evaluation.findMany({ where: { ...evalFilter, 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, activeStageCount, projectCount, jurorCount, submittedEvaluations, totalEvaluations: totalAssignments, completionRate, scoreDistribution, } }), // ========================================================================= // Stage-Scoped Analytics (Phase 4) // ========================================================================= /** * Get score distribution histogram for stage evaluations */ getStageScoreDistribution: observerProcedure .input(z.object({ stageId: z.string() })) .query(async ({ ctx, input }) => { const evaluations = await ctx.prisma.evaluation.findMany({ where: { status: 'SUBMITTED', assignment: { stageId: input.stageId }, }, select: { globalScore: true, criterionScoresJson: true, }, }) // Global score distribution (1-10 buckets) const globalScores = evaluations .map((e) => e.globalScore) .filter((s): s is number => s !== null) const globalDistribution = Array.from({ length: 10 }, (_, i) => ({ score: i + 1, count: globalScores.filter((s) => Math.round(s) === i + 1).length, })) // Per-criterion score distribution const criterionScores: Record = {} evaluations.forEach((e) => { const scores = e.criterionScoresJson as Record | null if (scores) { Object.entries(scores).forEach(([key, value]) => { if (typeof value === 'number') { if (!criterionScores[key]) criterionScores[key] = [] criterionScores[key].push(value) } }) } }) const criterionDistributions = Object.entries(criterionScores).map(([criterionId, scores]) => ({ criterionId, average: scores.reduce((a, b) => a + b, 0) / scores.length, count: scores.length, distribution: Array.from({ length: 10 }, (_, i) => ({ score: i + 1, count: scores.filter((s) => Math.round(s) === i + 1).length, })), })) return { globalDistribution, totalEvaluations: evaluations.length, averageGlobalScore: globalScores.length > 0 ? globalScores.reduce((a, b) => a + b, 0) / globalScores.length : 0, criterionDistributions, } }), /** * Get per-stage completion summary for a pipeline */ getStageCompletionOverview: observerProcedure .input(z.object({ pipelineId: z.string() })) .query(async ({ ctx, input }) => { // Get all stages in the pipeline via tracks const tracks = await ctx.prisma.track.findMany({ where: { pipelineId: input.pipelineId }, include: { stages: { orderBy: { sortOrder: 'asc' }, select: { id: true, name: true, stageType: true, status: true, sortOrder: true, }, }, }, orderBy: { sortOrder: 'asc' }, }) const stages = tracks.flatMap((t) => t.stages.map((s) => ({ ...s, trackName: t.name, trackId: t.id })) ) // For each stage, get project counts, assignment coverage, evaluation completion const stageOverviews = await Promise.all( stages.map(async (stage) => { const [ projectStates, totalAssignments, completedEvaluations, distinctJurors, ] = await Promise.all([ ctx.prisma.projectStageState.groupBy({ by: ['state'], where: { stageId: stage.id }, _count: true, }), ctx.prisma.assignment.count({ where: { stageId: stage.id }, }), ctx.prisma.evaluation.count({ where: { assignment: { stageId: stage.id }, status: 'SUBMITTED', }, }), ctx.prisma.assignment.groupBy({ by: ['userId'], where: { stageId: stage.id }, }), ]) const stateBreakdown = projectStates.map((ps) => ({ state: ps.state, count: ps._count, })) const totalProjects = projectStates.reduce((sum, ps) => sum + ps._count, 0) const completionRate = totalAssignments > 0 ? Math.round((completedEvaluations / totalAssignments) * 100) : 0 return { stageId: stage.id, stageName: stage.name, stageType: stage.stageType, stageStatus: stage.status, trackName: stage.trackName, trackId: stage.trackId, sortOrder: stage.sortOrder, totalProjects, stateBreakdown, totalAssignments, completedEvaluations, pendingEvaluations: totalAssignments - completedEvaluations, completionRate, jurorCount: distinctJurors.length, } }) ) return { pipelineId: input.pipelineId, stages: stageOverviews, summary: { totalStages: stages.length, totalProjects: stageOverviews.reduce((sum, s) => sum + s.totalProjects, 0), totalAssignments: stageOverviews.reduce((sum, s) => sum + s.totalAssignments, 0), totalCompleted: stageOverviews.reduce((sum, s) => sum + s.completedEvaluations, 0), }, } }), // ========================================================================= // Award Analytics (Phase 5) // ========================================================================= /** * Get per-award-track summary for a pipeline */ getAwardSummary: observerProcedure .input(z.object({ pipelineId: z.string() })) .query(async ({ ctx, input }) => { // Find all AWARD tracks in the pipeline const awardTracks = await ctx.prisma.track.findMany({ where: { pipelineId: input.pipelineId, kind: 'AWARD' }, include: { specialAward: { include: { winnerProject: { select: { id: true, title: true, teamName: true } }, }, }, stages: { orderBy: { sortOrder: 'asc' }, select: { id: true, name: true, stageType: true, status: true }, }, }, orderBy: { sortOrder: 'asc' }, }) const awards = await Promise.all( awardTracks.map(async (track) => { const award = track.specialAward // Count projects in this track (active PSS) const projectCount = await ctx.prisma.projectStageState.count({ where: { trackId: track.id }, }) // Count evaluations in this track's stages const stageIds = track.stages.map((s) => s.id) const [totalAssignments, completedEvals] = await Promise.all([ ctx.prisma.assignment.count({ where: { stageId: { in: stageIds } }, }), ctx.prisma.evaluation.count({ where: { assignment: { stageId: { in: stageIds } }, status: 'SUBMITTED', }, }), ]) const completionRate = totalAssignments > 0 ? Math.round((completedEvals / totalAssignments) * 100) : 0 return { trackId: track.id, trackName: track.name, routingMode: track.routingMode, awardId: award?.id ?? null, awardName: award?.name ?? track.name, awardStatus: award?.status ?? null, scoringMode: award?.scoringMode ?? null, projectCount, totalAssignments, completedEvaluations: completedEvals, completionRate, winner: award?.winnerProject ? { projectId: award.winnerProject.id, title: award.winnerProject.title, teamName: award.winnerProject.teamName, overridden: award.winnerOverridden, } : null, stages: track.stages, } }) ) return awards }), /** * Get per-project vote/score distribution for an award stage */ getAwardVoteDistribution: observerProcedure .input(z.object({ stageId: z.string() })) .query(async ({ ctx, input }) => { // Get all evaluations for this stage const evaluations = await ctx.prisma.evaluation.findMany({ where: { status: 'SUBMITTED', assignment: { stageId: input.stageId }, }, select: { globalScore: true, assignment: { select: { projectId: true, project: { select: { title: true, teamName: true } }, }, }, }, }) // Also get any AwardVotes linked to the stage's track's award const stage = await ctx.prisma.stage.findUnique({ where: { id: input.stageId }, select: { track: { select: { specialAward: { select: { id: true }, }, }, }, }, }) const awardId = stage?.track?.specialAward?.id let awardVotes: Array<{ projectId: string; rank: number | null }> = [] if (awardId) { awardVotes = await ctx.prisma.awardVote.findMany({ where: { awardId }, select: { projectId: true, rank: true }, }) } // Group evaluation scores by project const projectMap = new Map() for (const ev of evaluations) { const pid = ev.assignment.projectId if (!projectMap.has(pid)) { projectMap.set(pid, { title: ev.assignment.project.title, teamName: ev.assignment.project.teamName, scores: [], voteCount: 0, avgRank: null, }) } if (ev.globalScore !== null) { projectMap.get(pid)!.scores.push(ev.globalScore) } } // Merge award votes const ranksByProject = new Map() for (const vote of awardVotes) { const entry = projectMap.get(vote.projectId) if (entry) { entry.voteCount++ } if (vote.rank !== null) { if (!ranksByProject.has(vote.projectId)) ranksByProject.set(vote.projectId, []) ranksByProject.get(vote.projectId)!.push(vote.rank) } } // Calculate averages const results = Array.from(projectMap.entries()).map(([projectId, data]) => { const avgScore = data.scores.length > 0 ? data.scores.reduce((a, b) => a + b, 0) / data.scores.length : null const minScore = data.scores.length > 0 ? Math.min(...data.scores) : null const maxScore = data.scores.length > 0 ? Math.max(...data.scores) : null const ranks = ranksByProject.get(projectId) const avgRank = ranks?.length ? ranks.reduce((a, b) => a + b, 0) / ranks.length : null return { projectId, title: data.title, teamName: data.teamName, evaluationCount: data.scores.length, voteCount: data.voteCount, avgScore, minScore, maxScore, avgRank, } }) // Sort by avgScore descending results.sort((a, b) => (b.avgScore ?? 0) - (a.avgScore ?? 0)) return { stageId: input.stageId, projects: results, totalEvaluations: evaluations.length, totalVotes: awardVotes.length, } }), /** * Get all projects with pagination, filtering, and search (for observer dashboard) */ getAllProjects: observerProcedure .input( z.object({ stageId: 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.stageId) { where.assignments = { some: { stageId: input.stageId } } } 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, assignments: { select: { stageId: true, stage: { select: { id: true, name: true } }, 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 const firstAssignment = p.assignments[0] return { id: p.id, title: p.title, teamName: p.teamName, status: p.status, country: p.country, stageId: firstAssignment?.stage?.id ?? '', stageName: firstAssignment?.stage?.name ?? '', averageScore, evaluationCount: submitted.length, } }) return { projects: mapped, total, page: input.page, perPage: input.perPage, totalPages: Math.ceil(total / input.perPage), } }), })