Initial commit: MOPC platform with Docker deployment setup
Full Next.js 15 platform with tRPC, Prisma, PostgreSQL, NextAuth. Includes production Dockerfile (multi-stage, port 7600), docker-compose with registry-based image pull, Gitea Actions CI workflow, nginx config for portal.monaco-opc.com, deployment scripts, and DEPLOYMENT.md guide. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
365
src/server/routers/analytics.ts
Normal file
365
src/server/routers/analytics.ts
Normal file
@@ -0,0 +1,365 @@
|
||||
import { z } from 'zod'
|
||||
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
||||
|
||||
export const analyticsRouter = router({
|
||||
/**
|
||||
* Get score distribution for a round (histogram data)
|
||||
*/
|
||||
getScoreDistribution: adminProcedure
|
||||
.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: adminProcedure
|
||||
.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: adminProcedure
|
||||
.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: adminProcedure
|
||||
.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 },
|
||||
include: {
|
||||
assignments: {
|
||||
include: {
|
||||
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: adminProcedure
|
||||
.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: adminProcedure
|
||||
.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: adminProcedure
|
||||
.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: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
programId: z.string(),
|
||||
roundId: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const where = input.roundId
|
||||
? { roundId: input.roundId }
|
||||
: { round: { programId: input.programId } }
|
||||
|
||||
const distribution = await ctx.prisma.project.groupBy({
|
||||
by: ['country'],
|
||||
where,
|
||||
_count: { id: true },
|
||||
})
|
||||
|
||||
return distribution.map((d) => ({
|
||||
countryCode: d.country || 'UNKNOWN',
|
||||
count: d._count.id,
|
||||
}))
|
||||
}),
|
||||
})
|
||||
Reference in New Issue
Block a user