All checks were successful
Build and Push Docker Image / build (push) Successful in 8m59s
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>
1241 lines
41 KiB
TypeScript
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: {} }
|
|
}
|
|
}),
|
|
})
|