Files
MOPC-Portal/src/server/routers/analytics.ts
Matt fbcbf895be
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m59s
Add defensive null guards to all chart components and analytics
All 9 chart components now have early-return null/empty checks before
calling .map() on data props. The diversity-metrics chart guards all
nested array fields (byCountry, byCategory, byOceanIssue, byTag).
Analytics backend guards p.tags in getDiversityMetrics. This prevents
any "Cannot read properties of null (reading 'map')" crashes even if
upstream data shapes are unexpected.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 13:42:31 +01:00

1241 lines
41 KiB
TypeScript

import { z } from 'zod'
import { router, observerProcedure } from '../trpc'
import { normalizeCountryToCode } from '@/lib/countries'
const editionOrRoundInput = z.object({
roundId: z.string().optional(),
programId: z.string().optional(),
}).refine(data => data.roundId || data.programId, {
message: 'Either roundId or programId is required',
})
function projectWhere(input: { roundId?: string; programId?: string }) {
if (input.roundId) return { projectRoundStates: { some: { roundId: input.roundId } } }
return { programId: input.programId! }
}
function assignmentWhere(input: { roundId?: string; programId?: string }) {
if (input.roundId) return { roundId: input.roundId }
return { round: { competition: { programId: input.programId! } } }
}
function evalWhere(input: { roundId?: string; programId?: string }, extra: Record<string, unknown> = {}) {
const base = input.roundId
? { assignment: { roundId: input.roundId } }
: { assignment: { round: { competition: { programId: input.programId! } } } }
return { ...base, ...extra }
}
export const analyticsRouter = router({
/**
* Get score distribution (histogram data)
*/
getScoreDistribution: observerProcedure
.input(editionOrRoundInput)
.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<string, number> | 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(editionOrRoundInput)
.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<string, number> = {}
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(editionOrRoundInput)
.query(async ({ ctx, input }) => {
const assignments = await ctx.prisma.assignment.findMany({
where: assignmentWhere(input),
include: {
user: { select: { name: 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 || '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(editionOrRoundInput.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,
}
})
.sort((a, b) => {
// Evaluated projects first (sorted by score desc), unevaluated at bottom
if (a.averageScore !== null && b.averageScore !== null) return b.averageScore - a.averageScore
if (a.averageScore !== null) return -1
if (b.averageScore !== null) return 1
return 0
})
return input.limit ? rankings.slice(0, input.limit) : rankings
}),
/**
* Get status breakdown (pie chart data)
*/
getStatusBreakdown: observerProcedure
.input(editionOrRoundInput)
.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(editionOrRoundInput)
.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(editionOrRoundInput)
.query(async ({ ctx, input }) => {
const formWhere = input.roundId
? { roundId: input.roundId, isActive: true }
: { round: { competition: { programId: input.programId! } }, isActive: true }
const evaluationForms = await ctx.prisma.evaluationForm.findMany({
where: formWhere,
})
if (!evaluationForms.length) {
return []
}
// Build label → Set<id> map so program-level queries match all IDs for the same criterion label
const labelToIds = new Map<string, Set<string>>()
const labelToFirst = new Map<string, { id: string; label: string }>()
evaluationForms.forEach((form) => {
const criteria = form.criteriaJson as Array<{ id: string; label: string }> | null
if (criteria) {
criteria.forEach((c) => {
if (!labelToIds.has(c.label)) {
labelToIds.set(c.label, new Set())
labelToFirst.set(c.label, c)
}
labelToIds.get(c.label)!.add(c.id)
})
}
})
const criteriaLabels = Array.from(labelToFirst.values())
if (criteriaLabels.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, checking ALL IDs that share the same label
const criteriaScores = criteriaLabels.map((criterion) => {
const scores: number[] = []
const ids = labelToIds.get(criterion.label) ?? new Set([criterion.id])
evaluations.forEach((evaluation) => {
const criterionScoresJson = evaluation.criterionScoresJson as Record<
string,
number
> | null
if (criterionScoresJson) {
for (const cid of ids) {
if (typeof criterionScoresJson[cid] === 'number') {
scores.push(criterionScoresJson[cid])
break // Only count one score per evaluation per criterion
}
}
}
})
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
? { assignments: { some: { roundId: input.roundId } } }
: { 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<string, number>()
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 rounds
*/
getCrossRoundComparison: observerProcedure
.input(z.object({ roundIds: z.array(z.string()).min(2) }))
.query(async ({ ctx, input }) => {
const { roundIds } = input
// Batch: fetch all rounds, assignments, and evaluations in 3 queries
const [rounds, assignments, evaluations] = await Promise.all([
ctx.prisma.round.findMany({
where: { id: { in: roundIds } },
select: { id: true, name: true },
}),
ctx.prisma.assignment.groupBy({
by: ['roundId'],
where: { roundId: { in: roundIds } },
_count: true,
}),
ctx.prisma.evaluation.findMany({
where: {
assignment: { roundId: { in: roundIds } },
status: 'SUBMITTED',
},
select: { globalScore: true, assignment: { select: { roundId: true } } },
}),
])
const roundMap = new Map(rounds.map((r) => [r.id, r.name]))
const assignmentCountMap = new Map(assignments.map((a) => [a.roundId, a._count]))
// Group evaluations by round
const evalsByRound = new Map<string, number[]>()
const projectsByRound = new Map<string, Set<string>>()
for (const e of evaluations) {
const rid = e.assignment.roundId
if (!evalsByRound.has(rid)) evalsByRound.set(rid, [])
if (e.globalScore !== null) evalsByRound.get(rid)!.push(e.globalScore)
}
// Count distinct projects per round via assignments
const projectAssignments = await ctx.prisma.assignment.findMany({
where: { roundId: { in: roundIds } },
select: { roundId: true, projectId: true },
distinct: ['roundId', 'projectId'],
})
for (const pa of projectAssignments) {
if (!projectsByRound.has(pa.roundId)) projectsByRound.set(pa.roundId, new Set())
projectsByRound.get(pa.roundId)!.add(pa.projectId)
}
return roundIds.map((roundId) => {
const globalScores = evalsByRound.get(roundId) ?? []
const assignmentCount = assignmentCountMap.get(roundId) ?? 0
const evaluationCount = globalScores.length
const completionRate = assignmentCount > 0
? Math.round((evaluationCount / assignmentCount) * 100)
: 0
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 {
roundId,
roundName: roundMap.get(roundId) ?? roundId,
projectCount: projectsByRound.get(roundId)?.size ?? 0,
evaluationCount,
completionRate,
averageScore,
scoreDistribution: distribution,
}
})
}),
/**
* Get juror consistency metrics for a round
*/
getJurorConsistency: observerProcedure
.input(editionOrRoundInput)
.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 } },
},
},
},
})
// Group scores by juror
const jurorScores: Record<string, { name: string; scores: number[] }> = {}
evaluations.forEach((e) => {
const userId = e.assignment.userId
if (!jurorScores[userId]) {
jurorScores[userId] = {
name: e.assignment.user.name || 'Unknown',
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,
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(editionOrRoundInput)
.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<string, number> = {}
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<string, number> = {}
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<string, number> = {}
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<string, number> = {}
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 competitions = await ctx.prisma.competition.findMany({
where: { programId: input.programId },
include: {
rounds: {
select: { id: true, name: true, createdAt: true },
orderBy: { createdAt: 'asc' },
},
},
orderBy: { createdAt: 'asc' },
})
const allRounds = competitions.flatMap((c) => c.rounds)
const roundIds = allRounds.map((r) => r.id)
if (roundIds.length === 0) return []
// Batch: fetch assignments, evaluations, and distinct projects in 3 queries
const [assignmentCounts, evaluations, projectAssignments] = await Promise.all([
ctx.prisma.assignment.groupBy({
by: ['roundId'],
where: { roundId: { in: roundIds } },
_count: true,
}),
ctx.prisma.evaluation.findMany({
where: {
assignment: { roundId: { in: roundIds } },
status: 'SUBMITTED',
},
select: { globalScore: true, assignment: { select: { roundId: true } } },
}),
ctx.prisma.assignment.findMany({
where: { roundId: { in: roundIds } },
select: { roundId: true, projectId: true },
distinct: ['roundId', 'projectId'],
}),
])
const assignmentCountMap = new Map(assignmentCounts.map((a) => [a.roundId, a._count]))
// Group evaluation scores by round
const scoresByRound = new Map<string, number[]>()
const evalCountByRound = new Map<string, number>()
for (const e of evaluations) {
const rid = e.assignment.roundId
evalCountByRound.set(rid, (evalCountByRound.get(rid) ?? 0) + 1)
if (e.globalScore !== null) {
if (!scoresByRound.has(rid)) scoresByRound.set(rid, [])
scoresByRound.get(rid)!.push(e.globalScore)
}
}
// Count distinct projects per round
const projectsByRound = new Map<string, number>()
for (const pa of projectAssignments) {
projectsByRound.set(pa.roundId, (projectsByRound.get(pa.roundId) ?? 0) + 1)
}
return allRounds.map((round) => {
const scores = scoresByRound.get(round.id) ?? []
const assignmentCount = assignmentCountMap.get(round.id) ?? 0
const evaluationCount = evalCountByRound.get(round.id) ?? 0
const completionRate = assignmentCount > 0
? Math.round((evaluationCount / assignmentCount) * 100)
: 0
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: projectsByRound.get(round.id) ?? 0,
evaluationCount,
completionRate,
averageScore,
}
})
}),
/**
* 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 projectFilter = roundId
? { projectRoundStates: { some: { roundId } } }
: {}
const assignmentFilter = roundId ? { roundId } : {}
const evalFilter = 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(),
roundId
? ctx.prisma.round.findUnique({ where: { id: roundId }, select: { competitionId: true } })
.then((r) => r?.competitionId
? ctx.prisma.round.count({ where: { competitionId: r.competitionId, status: 'ROUND_ACTIVE' } })
: ctx.prisma.round.count({ where: { status: 'ROUND_ACTIVE' } }))
: ctx.prisma.round.count({ where: { status: 'ROUND_ACTIVE' } }),
ctx.prisma.project.count({ where: projectFilter }),
roundId
? ctx.prisma.assignment.findMany({
where: { roundId },
select: { userId: true },
distinct: ['userId'],
}).then((rows) => rows.length)
: 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: Infinity },
{ label: '7-8', min: 7, max: 9 },
{ label: '5-6', min: 5, max: 7 },
{ label: '3-4', min: 3, max: 5 },
{ label: '1-2', min: 1, max: 3 },
].map((b) => ({
label: b.label,
count: scores.filter((s) => s >= b.min && s < b.max).length,
}))
return {
programCount,
activeRoundCount,
projectCount,
jurorCount,
submittedEvaluations,
totalAssignments,
completionRate,
scoreDistribution,
}
}),
// =========================================================================
// Stage-Scoped Analytics (Phase 4)
// =========================================================================
/**
* Get score distribution histogram for round evaluations
*/
getRoundScoreDistribution: observerProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const evaluations = await ctx.prisma.evaluation.findMany({
where: {
status: 'SUBMITTED',
assignment: { roundId: input.roundId },
},
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<string, number[]> = {}
evaluations.forEach((e) => {
const scores = e.criterionScoresJson as Record<string, number> | 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-round completion summary for a competition
* NOTE: This replaces the old pipeline-based getStageCompletionOverview
*/
getRoundCompletionOverview: observerProcedure
.input(z.object({ competitionId: z.string() }))
.query(async ({ ctx, input }) => {
// Get all rounds in the competition
const rounds = await ctx.prisma.round.findMany({
where: { competitionId: input.competitionId },
orderBy: { sortOrder: 'asc' },
select: {
id: true,
name: true,
roundType: true,
status: true,
sortOrder: true,
},
})
// For each round, get assignment coverage and evaluation completion
const roundOverviews = await Promise.all(
rounds.map(async (round) => {
const [
projectRoundStates,
totalAssignments,
completedEvaluations,
distinctJurors,
] = await Promise.all([
ctx.prisma.projectRoundState.groupBy({
by: ['state'],
where: { roundId: round.id },
_count: true,
}),
ctx.prisma.assignment.count({
where: { roundId: round.id },
}),
ctx.prisma.evaluation.count({
where: {
assignment: { roundId: round.id },
status: 'SUBMITTED',
},
}),
ctx.prisma.assignment.groupBy({
by: ['userId'],
where: { roundId: round.id },
}),
])
const stateBreakdown = projectRoundStates.map((ps) => ({
state: ps.state,
count: ps._count,
}))
const totalProjects = projectRoundStates.reduce((sum, ps) => sum + ps._count, 0)
const completionRate = totalAssignments > 0
? Math.round((completedEvaluations / totalAssignments) * 100)
: 0
return {
roundId: round.id,
roundName: round.name,
roundType: round.roundType,
roundStatus: round.status,
sortOrder: round.sortOrder,
totalProjects,
stateBreakdown,
totalAssignments,
completedEvaluations,
pendingEvaluations: totalAssignments - completedEvaluations,
completionRate,
jurorCount: distinctJurors.length,
}
})
)
return {
competitionId: input.competitionId,
rounds: roundOverviews,
summary: {
totalRounds: rounds.length,
totalProjects: roundOverviews.reduce((sum, s) => sum + s.totalProjects, 0),
totalAssignments: roundOverviews.reduce((sum, s) => sum + s.totalAssignments, 0),
totalCompleted: roundOverviews.reduce((sum, s) => sum + s.completedEvaluations, 0),
},
}
}),
// =========================================================================
// Award Analytics (Phase 5)
// =========================================================================
// NOTE: getAwardSummary procedure removed - depends on deleted Pipeline/Track/Stage/SpecialAward models
// Will need to be reimplemented with new Competition/Round/Award architecture
// NOTE: getAwardVoteDistribution procedure removed - depends on deleted Stage/Track/SpecialAward/AwardVote models
// Will need to be reimplemented with new Competition/Round/Award architecture
/**
* 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(),
sortBy: z.enum(['title', 'score', 'evaluations']).default('title'),
sortDir: z.enum(['asc', 'desc']).default('asc'),
page: z.number().min(1).default(1),
perPage: z.number().min(1).max(100).default(20),
})
)
.query(async ({ ctx, input }) => {
const where: Record<string, unknown> = {}
if (input.roundId) {
where.assignments = { some: { 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' } },
]
}
// Prisma-level sort for title; score/evaluations sorted post-query
const prismaOrderBy = input.sortBy === 'title'
? { title: input.sortDir as 'asc' | 'desc' }
: { title: 'asc' as const }
const [projects, total] = await Promise.all([
ctx.prisma.project.findMany({
where,
select: {
id: true,
title: true,
teamName: true,
status: true,
country: true,
assignments: {
select: {
roundId: true,
round: { select: { id: true, name: true } },
evaluation: {
select: { globalScore: true, status: true },
},
},
},
},
orderBy: prismaOrderBy,
// When sorting by computed fields, fetch all then slice in JS
...(input.sortBy === 'title'
? { 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
// Filter assignments to the queried round so we show the correct round name
const roundAssignment = input.roundId
? p.assignments.find((a) => a.roundId === input.roundId)
: p.assignments[0]
return {
id: p.id,
title: p.title,
teamName: p.teamName,
status: p.status,
country: p.country,
roundId: roundAssignment?.round?.id ?? '',
roundName: roundAssignment?.round?.name ?? '',
averageScore,
evaluationCount: submitted.length,
}
})
// Sort by computed fields (score, evaluations) in JS
let sorted = mapped
if (input.sortBy === 'score') {
sorted = mapped.sort((a, b) => {
const sa = a.averageScore ?? -1
const sb = b.averageScore ?? -1
return input.sortDir === 'asc' ? sa - sb : sb - sa
})
} else if (input.sortBy === 'evaluations') {
sorted = mapped.sort((a, b) =>
input.sortDir === 'asc'
? a.evaluationCount - b.evaluationCount
: b.evaluationCount - a.evaluationCount
)
}
// Paginate in JS for computed-field sorts
const paginated = input.sortBy !== 'title'
? sorted.slice((input.page - 1) * input.perPage, input.page * input.perPage)
: sorted
return {
projects: paginated,
total,
page: input.page,
perPage: input.perPage,
totalPages: Math.ceil(total / input.perPage),
}
}),
/**
* Get round-type-aware stats for a specific round.
* Returns different metrics depending on the round type.
*/
getRoundTypeStats: observerProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { id: true, roundType: true, competitionId: true },
})
const roundType = round.roundType
switch (roundType) {
case 'INTAKE': {
const [total, byState, byCategory] = await Promise.all([
ctx.prisma.projectRoundState.count({ where: { roundId: input.roundId } }),
ctx.prisma.projectRoundState.groupBy({
by: ['state'],
where: { roundId: input.roundId },
_count: true,
}),
ctx.prisma.project.groupBy({
by: ['competitionCategory'],
where: { projectRoundStates: { some: { roundId: input.roundId } } },
_count: true,
}),
])
return {
roundType,
stats: {
totalProjects: total,
byState: byState.map((s) => ({ state: s.state, count: s._count })),
byCategory: byCategory.map((c) => ({
category: c.competitionCategory ?? 'Uncategorized',
count: c._count,
})),
},
}
}
case 'FILTERING': {
const [total, byOutcome] = await Promise.all([
ctx.prisma.filteringResult.count({ where: { roundId: input.roundId } }),
ctx.prisma.filteringResult.groupBy({
by: ['outcome'],
where: { roundId: input.roundId },
_count: true,
}),
])
const passed = byOutcome.find((o) => o.outcome === 'PASSED')?._count ?? 0
return {
roundType,
stats: {
totalScreened: total,
passed,
filteredOut: byOutcome.find((o) => o.outcome === 'FILTERED_OUT')?._count ?? 0,
flagged: byOutcome.find((o) => o.outcome === 'FLAGGED')?._count ?? 0,
passRate: total > 0 ? Math.round((passed / total) * 100) : 0,
},
}
}
case 'EVALUATION': {
const [assignmentCount, submittedCount, jurorCount] = await Promise.all([
ctx.prisma.assignment.count({ where: { roundId: input.roundId } }),
ctx.prisma.evaluation.count({
where: { assignment: { roundId: input.roundId }, status: 'SUBMITTED' },
}),
ctx.prisma.assignment.findMany({
where: { roundId: input.roundId },
select: { userId: true },
distinct: ['userId'],
}).then((rows) => rows.length),
])
return {
roundType,
stats: {
totalAssignments: assignmentCount,
completedEvaluations: submittedCount,
completionRate: assignmentCount > 0 ? Math.round((submittedCount / assignmentCount) * 100) : 0,
activeJurors: jurorCount,
},
}
}
case 'SUBMISSION': {
const [fileCount, teamsWithFiles] = await Promise.all([
ctx.prisma.projectFile.count({ where: { roundId: input.roundId } }),
ctx.prisma.projectFile.findMany({
where: { roundId: input.roundId },
select: { projectId: true },
distinct: ['projectId'],
}).then((rows) => rows.length),
])
return {
roundType,
stats: {
totalFiles: fileCount,
teamsSubmitted: teamsWithFiles,
},
}
}
case 'MENTORING': {
const [assignmentCount, messageCount] = await Promise.all([
ctx.prisma.mentorAssignment.count({
where: { project: { projectRoundStates: { some: { roundId: input.roundId } } } },
}),
ctx.prisma.mentorMessage.count({
where: { project: { projectRoundStates: { some: { roundId: input.roundId } } } },
}),
])
return {
roundType,
stats: {
mentorAssignments: assignmentCount,
totalMessages: messageCount,
},
}
}
case 'LIVE_FINAL': {
const session = await ctx.prisma.liveVotingSession.findUnique({
where: { roundId: input.roundId },
select: { id: true, status: true, _count: { select: { votes: true } } },
})
return {
roundType,
stats: {
sessionStatus: session?.status ?? 'NOT_STARTED',
voteCount: session?._count.votes ?? 0,
},
}
}
case 'DELIBERATION': {
const [sessions, votes, locks] = await Promise.all([
ctx.prisma.deliberationSession.count({ where: { roundId: input.roundId } }),
ctx.prisma.deliberationVote.count({
where: { session: { roundId: input.roundId } },
}),
ctx.prisma.resultLock.count({ where: { roundId: input.roundId } }),
])
return {
roundType,
stats: {
totalSessions: sessions,
totalVotes: votes,
resultsLocked: locks,
},
}
}
default:
return { roundType, stats: {} }
}
}),
})