- Guard onboarding tRPC queries with session hydration check (fixes UNAUTHORIZED on first login) - Defer expensive queries on awards page until UI elements are opened (dialog/tab) - Fix perPage: 500 exceeding backend Zod max of 100 on awards eligibility query - Add smooth open/close animation to project filters collapsible bar - Fix seeded user status from ACTIVE to INVITED in seed-candidatures.ts - Add router.refresh() cache invalidation across ~22 admin forms - Fix geographic analytics query to use programId instead of round.programId - Fix dashboard queries to scope by programId correctly - Fix project.listPool and round queries for projects outside round context - Add rounds page useEffect for state sync after mutations Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
638 lines
19 KiB
TypeScript
638 lines
19 KiB
TypeScript
import { z } from 'zod'
|
|
import { router, protectedProcedure, adminProcedure, observerProcedure } from '../trpc'
|
|
|
|
export const analyticsRouter = router({
|
|
/**
|
|
* Get score distribution for a round (histogram data)
|
|
*/
|
|
getScoreDistribution: observerProcedure
|
|
.input(z.object({ roundId: z.string() }))
|
|
.query(async ({ ctx, input }) => {
|
|
const evaluations = await ctx.prisma.evaluation.findMany({
|
|
where: {
|
|
assignment: { roundId: input.roundId },
|
|
status: 'SUBMITTED',
|
|
},
|
|
select: {
|
|
criterionScoresJson: true,
|
|
},
|
|
})
|
|
|
|
// Extract all scores and calculate distribution
|
|
const allScores: number[] = []
|
|
evaluations.forEach((evaluation) => {
|
|
const scores = evaluation.criterionScoresJson as Record<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(z.object({ roundId: z.string() }))
|
|
.query(async ({ ctx, input }) => {
|
|
const evaluations = await ctx.prisma.evaluation.findMany({
|
|
where: {
|
|
assignment: { roundId: input.roundId },
|
|
status: 'SUBMITTED',
|
|
},
|
|
select: {
|
|
submittedAt: true,
|
|
},
|
|
orderBy: { submittedAt: 'asc' },
|
|
})
|
|
|
|
// Group by date
|
|
const byDate: Record<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(z.object({ roundId: z.string() }))
|
|
.query(async ({ ctx, input }) => {
|
|
const assignments = await ctx.prisma.assignment.findMany({
|
|
where: { roundId: input.roundId },
|
|
include: {
|
|
user: { select: { name: true, email: true } },
|
|
evaluation: {
|
|
select: { id: true, status: true },
|
|
},
|
|
},
|
|
})
|
|
|
|
// Group by user
|
|
const byUser: Record<
|
|
string,
|
|
{ name: string; assigned: number; completed: number }
|
|
> = {}
|
|
|
|
assignments.forEach((assignment) => {
|
|
const userId = assignment.userId
|
|
if (!byUser[userId]) {
|
|
byUser[userId] = {
|
|
name: assignment.user.name || assignment.user.email || 'Unknown',
|
|
assigned: 0,
|
|
completed: 0,
|
|
}
|
|
}
|
|
byUser[userId].assigned++
|
|
if (assignment.evaluation?.status === 'SUBMITTED') {
|
|
byUser[userId].completed++
|
|
}
|
|
})
|
|
|
|
return Object.entries(byUser)
|
|
.map(([id, data]) => ({
|
|
id,
|
|
...data,
|
|
completionRate:
|
|
data.assigned > 0
|
|
? Math.round((data.completed / data.assigned) * 100)
|
|
: 0,
|
|
}))
|
|
.sort((a, b) => b.assigned - a.assigned)
|
|
}),
|
|
|
|
/**
|
|
* Get project rankings with average scores
|
|
*/
|
|
getProjectRankings: observerProcedure
|
|
.input(z.object({ roundId: z.string(), limit: z.number().optional() }))
|
|
.query(async ({ ctx, input }) => {
|
|
const projects = await ctx.prisma.project.findMany({
|
|
where: { roundId: input.roundId },
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
teamName: true,
|
|
status: true,
|
|
assignments: {
|
|
select: {
|
|
evaluation: {
|
|
select: { criterionScoresJson: true, status: true },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
// Calculate average scores
|
|
const rankings = projects
|
|
.map((project) => {
|
|
const allScores: number[] = []
|
|
|
|
project.assignments.forEach((assignment) => {
|
|
const evaluation = assignment.evaluation
|
|
if (evaluation?.status === 'SUBMITTED') {
|
|
const scores = evaluation.criterionScoresJson as Record<
|
|
string,
|
|
number
|
|
> | null
|
|
if (scores) {
|
|
const scoreValues = Object.values(scores).filter(
|
|
(s): s is number => typeof s === 'number'
|
|
)
|
|
if (scoreValues.length > 0) {
|
|
const average =
|
|
scoreValues.reduce((a, b) => a + b, 0) / scoreValues.length
|
|
allScores.push(average)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
const averageScore =
|
|
allScores.length > 0
|
|
? allScores.reduce((a, b) => a + b, 0) / allScores.length
|
|
: null
|
|
|
|
return {
|
|
id: project.id,
|
|
title: project.title,
|
|
teamName: project.teamName,
|
|
status: project.status,
|
|
averageScore,
|
|
evaluationCount: allScores.length,
|
|
}
|
|
})
|
|
.filter((p) => p.averageScore !== null)
|
|
.sort((a, b) => (b.averageScore || 0) - (a.averageScore || 0))
|
|
|
|
return input.limit ? rankings.slice(0, input.limit) : rankings
|
|
}),
|
|
|
|
/**
|
|
* Get status breakdown (pie chart data)
|
|
*/
|
|
getStatusBreakdown: observerProcedure
|
|
.input(z.object({ roundId: z.string() }))
|
|
.query(async ({ ctx, input }) => {
|
|
const projects = await ctx.prisma.project.groupBy({
|
|
by: ['status'],
|
|
where: { roundId: input.roundId },
|
|
_count: true,
|
|
})
|
|
|
|
return projects.map((p) => ({
|
|
status: p.status,
|
|
count: p._count,
|
|
}))
|
|
}),
|
|
|
|
/**
|
|
* Get overview stats for dashboard
|
|
*/
|
|
getOverviewStats: observerProcedure
|
|
.input(z.object({ roundId: z.string() }))
|
|
.query(async ({ ctx, input }) => {
|
|
const [
|
|
projectCount,
|
|
assignmentCount,
|
|
evaluationCount,
|
|
jurorCount,
|
|
statusCounts,
|
|
] = await Promise.all([
|
|
ctx.prisma.project.count({ where: { roundId: input.roundId } }),
|
|
ctx.prisma.assignment.count({ where: { roundId: input.roundId } }),
|
|
ctx.prisma.evaluation.count({
|
|
where: {
|
|
assignment: { roundId: input.roundId },
|
|
status: 'SUBMITTED',
|
|
},
|
|
}),
|
|
ctx.prisma.assignment.groupBy({
|
|
by: ['userId'],
|
|
where: { roundId: input.roundId },
|
|
}),
|
|
ctx.prisma.project.groupBy({
|
|
by: ['status'],
|
|
where: { roundId: input.roundId },
|
|
_count: true,
|
|
}),
|
|
])
|
|
|
|
const completionRate =
|
|
assignmentCount > 0
|
|
? Math.round((evaluationCount / assignmentCount) * 100)
|
|
: 0
|
|
|
|
return {
|
|
projectCount,
|
|
assignmentCount,
|
|
evaluationCount,
|
|
jurorCount: jurorCount.length,
|
|
completionRate,
|
|
statusBreakdown: statusCounts.map((s) => ({
|
|
status: s.status,
|
|
count: s._count,
|
|
})),
|
|
}
|
|
}),
|
|
|
|
/**
|
|
* Get criteria-level score distribution
|
|
*/
|
|
getCriteriaScores: observerProcedure
|
|
.input(z.object({ roundId: z.string() }))
|
|
.query(async ({ ctx, input }) => {
|
|
// Get active evaluation form for this round
|
|
const evaluationForm = await ctx.prisma.evaluationForm.findFirst({
|
|
where: { roundId: input.roundId, isActive: true },
|
|
})
|
|
|
|
if (!evaluationForm?.criteriaJson) {
|
|
return []
|
|
}
|
|
|
|
// Parse criteria from JSON
|
|
const criteria = evaluationForm.criteriaJson as Array<{
|
|
id: string
|
|
label: string
|
|
}>
|
|
|
|
if (!criteria || criteria.length === 0) {
|
|
return []
|
|
}
|
|
|
|
// Get all evaluations
|
|
const evaluations = await ctx.prisma.evaluation.findMany({
|
|
where: {
|
|
assignment: { roundId: input.roundId },
|
|
status: 'SUBMITTED',
|
|
},
|
|
select: { criterionScoresJson: true },
|
|
})
|
|
|
|
// Calculate average score per criterion
|
|
const criteriaScores = criteria.map((criterion) => {
|
|
const scores: number[] = []
|
|
|
|
evaluations.forEach((evaluation) => {
|
|
const criterionScoresJson = evaluation.criterionScoresJson as Record<
|
|
string,
|
|
number
|
|
> | null
|
|
if (criterionScoresJson && typeof criterionScoresJson[criterion.id] === 'number') {
|
|
scores.push(criterionScoresJson[criterion.id])
|
|
}
|
|
})
|
|
|
|
return {
|
|
id: criterion.id,
|
|
name: criterion.label,
|
|
averageScore:
|
|
scores.length > 0
|
|
? scores.reduce((a, b) => a + b, 0) / scores.length
|
|
: 0,
|
|
count: scores.length,
|
|
}
|
|
})
|
|
|
|
return criteriaScores
|
|
}),
|
|
|
|
/**
|
|
* Get geographic distribution of projects by country
|
|
*/
|
|
getGeographicDistribution: observerProcedure
|
|
.input(
|
|
z.object({
|
|
programId: z.string(),
|
|
roundId: z.string().optional(),
|
|
})
|
|
)
|
|
.query(async ({ ctx, input }) => {
|
|
const where = input.roundId
|
|
? { roundId: input.roundId }
|
|
: { programId: input.programId }
|
|
|
|
const distribution = await ctx.prisma.project.groupBy({
|
|
by: ['country'],
|
|
where: { ...where, country: { not: null } },
|
|
_count: { id: true },
|
|
})
|
|
|
|
return distribution.map((d) => ({
|
|
countryCode: d.country || 'UNKNOWN',
|
|
count: d._count.id,
|
|
}))
|
|
}),
|
|
|
|
// =========================================================================
|
|
// Advanced Analytics (F10)
|
|
// =========================================================================
|
|
|
|
/**
|
|
* Compare metrics across multiple rounds
|
|
*/
|
|
getCrossRoundComparison: observerProcedure
|
|
.input(z.object({ roundIds: z.array(z.string()).min(2) }))
|
|
.query(async ({ ctx, input }) => {
|
|
const comparisons = await Promise.all(
|
|
input.roundIds.map(async (roundId) => {
|
|
const round = await ctx.prisma.round.findUniqueOrThrow({
|
|
where: { id: roundId },
|
|
select: { id: true, name: true },
|
|
})
|
|
|
|
const [projectCount, assignmentCount, evaluationCount] = await Promise.all([
|
|
ctx.prisma.project.count({ where: { roundId } }),
|
|
ctx.prisma.assignment.count({ where: { roundId } }),
|
|
ctx.prisma.evaluation.count({
|
|
where: {
|
|
assignment: { roundId },
|
|
status: 'SUBMITTED',
|
|
},
|
|
}),
|
|
])
|
|
|
|
const completionRate = assignmentCount > 0
|
|
? Math.round((evaluationCount / assignmentCount) * 100)
|
|
: 0
|
|
|
|
// Get average scores
|
|
const evaluations = await ctx.prisma.evaluation.findMany({
|
|
where: {
|
|
assignment: { roundId },
|
|
status: 'SUBMITTED',
|
|
},
|
|
select: { globalScore: true },
|
|
})
|
|
|
|
const globalScores = evaluations
|
|
.map((e) => e.globalScore)
|
|
.filter((s): s is number => s !== null)
|
|
|
|
const averageScore = globalScores.length > 0
|
|
? globalScores.reduce((a, b) => a + b, 0) / globalScores.length
|
|
: null
|
|
|
|
// Score distribution
|
|
const distribution = Array.from({ length: 10 }, (_, i) => ({
|
|
score: i + 1,
|
|
count: globalScores.filter((s) => Math.round(s) === i + 1).length,
|
|
}))
|
|
|
|
return {
|
|
roundId,
|
|
roundName: round.name,
|
|
projectCount,
|
|
evaluationCount,
|
|
completionRate,
|
|
averageScore,
|
|
scoreDistribution: distribution,
|
|
}
|
|
})
|
|
)
|
|
|
|
return comparisons
|
|
}),
|
|
|
|
/**
|
|
* Get juror consistency metrics for a round
|
|
*/
|
|
getJurorConsistency: observerProcedure
|
|
.input(z.object({ roundId: z.string() }))
|
|
.query(async ({ ctx, input }) => {
|
|
const evaluations = await ctx.prisma.evaluation.findMany({
|
|
where: {
|
|
assignment: { roundId: input.roundId },
|
|
status: 'SUBMITTED',
|
|
},
|
|
include: {
|
|
assignment: {
|
|
include: {
|
|
user: { select: { id: true, name: true, email: true } },
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
// Group scores by juror
|
|
const jurorScores: Record<string, { name: string; email: string; scores: number[] }> = {}
|
|
|
|
evaluations.forEach((e) => {
|
|
const userId = e.assignment.userId
|
|
if (!jurorScores[userId]) {
|
|
jurorScores[userId] = {
|
|
name: e.assignment.user.name || e.assignment.user.email || 'Unknown',
|
|
email: e.assignment.user.email || '',
|
|
scores: [],
|
|
}
|
|
}
|
|
if (e.globalScore !== null) {
|
|
jurorScores[userId].scores.push(e.globalScore)
|
|
}
|
|
})
|
|
|
|
// Calculate overall average
|
|
const allScores = Object.values(jurorScores).flatMap((j) => j.scores)
|
|
const overallAverage = allScores.length > 0
|
|
? allScores.reduce((a, b) => a + b, 0) / allScores.length
|
|
: 0
|
|
|
|
// Calculate per-juror metrics
|
|
const metrics = Object.entries(jurorScores).map(([userId, data]) => {
|
|
const avg = data.scores.length > 0
|
|
? data.scores.reduce((a, b) => a + b, 0) / data.scores.length
|
|
: 0
|
|
const variance = data.scores.length > 1
|
|
? data.scores.reduce((sum, s) => sum + Math.pow(s - avg, 2), 0) / data.scores.length
|
|
: 0
|
|
const stddev = Math.sqrt(variance)
|
|
const deviationFromOverall = Math.abs(avg - overallAverage)
|
|
|
|
return {
|
|
userId,
|
|
name: data.name,
|
|
email: data.email,
|
|
evaluationCount: data.scores.length,
|
|
averageScore: avg,
|
|
stddev,
|
|
deviationFromOverall,
|
|
isOutlier: deviationFromOverall > 2, // Flag if 2+ points from mean
|
|
}
|
|
})
|
|
|
|
return {
|
|
overallAverage,
|
|
jurors: metrics.sort((a, b) => b.deviationFromOverall - a.deviationFromOverall),
|
|
}
|
|
}),
|
|
|
|
/**
|
|
* Get diversity metrics for projects in a round
|
|
*/
|
|
getDiversityMetrics: observerProcedure
|
|
.input(z.object({ roundId: z.string() }))
|
|
.query(async ({ ctx, input }) => {
|
|
const projects = await ctx.prisma.project.findMany({
|
|
where: { roundId: input.roundId },
|
|
select: {
|
|
country: true,
|
|
competitionCategory: true,
|
|
oceanIssue: true,
|
|
tags: true,
|
|
},
|
|
})
|
|
|
|
const total = projects.length
|
|
if (total === 0) {
|
|
return { total: 0, byCountry: [], byCategory: [], byOceanIssue: [], byTag: [] }
|
|
}
|
|
|
|
// By country
|
|
const countryCounts: Record<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 rounds = await ctx.prisma.round.findMany({
|
|
where: { programId: input.programId },
|
|
select: { id: true, name: true, createdAt: true },
|
|
orderBy: { createdAt: 'asc' },
|
|
})
|
|
|
|
const stats = await Promise.all(
|
|
rounds.map(async (round) => {
|
|
const [projectCount, evaluationCount, assignmentCount] = await Promise.all([
|
|
ctx.prisma.project.count({ where: { roundId: round.id } }),
|
|
ctx.prisma.evaluation.count({
|
|
where: {
|
|
assignment: { roundId: round.id },
|
|
status: 'SUBMITTED',
|
|
},
|
|
}),
|
|
ctx.prisma.assignment.count({ where: { roundId: round.id } }),
|
|
])
|
|
|
|
const completionRate = assignmentCount > 0
|
|
? Math.round((evaluationCount / assignmentCount) * 100)
|
|
: 0
|
|
|
|
// Average score
|
|
const evaluations = await ctx.prisma.evaluation.findMany({
|
|
where: {
|
|
assignment: { roundId: round.id },
|
|
status: 'SUBMITTED',
|
|
},
|
|
select: { globalScore: true },
|
|
})
|
|
|
|
const scores = evaluations
|
|
.map((e) => e.globalScore)
|
|
.filter((s): s is number => s !== null)
|
|
|
|
const averageScore = scores.length > 0
|
|
? scores.reduce((a, b) => a + b, 0) / scores.length
|
|
: null
|
|
|
|
return {
|
|
roundId: round.id,
|
|
roundName: round.name,
|
|
createdAt: round.createdAt,
|
|
projectCount,
|
|
evaluationCount,
|
|
completionRate,
|
|
averageScore,
|
|
}
|
|
})
|
|
)
|
|
|
|
return stats
|
|
}),
|
|
})
|