Round system redesign: Phases 1-7 complete
Full pipeline/track/stage architecture replacing the legacy round system. Schema: 11 new models (Pipeline, Track, Stage, StageTransition, ProjectStageState, RoutingRule, Cohort, CohortProject, LiveProgressCursor, OverrideAction, AudienceVoter) + 8 new enums. Backend: 9 new routers (pipeline, stage, routing, stageFiltering, stageAssignment, cohort, live, decision, award) + 6 new services (stage-engine, routing-engine, stage-filtering, stage-assignment, stage-notifications, live-control). Frontend: Pipeline wizard (17 components), jury stage pages (7), applicant pipeline pages (3), public stage pages (2), admin pipeline pages (5), shared stage components (3), SSE route, live hook. Phase 6 refit: 23 routers/services migrated from roundId to stageId, all frontend components refitted. Deleted round.ts (985 lines), roundTemplate.ts, round-helpers.ts, round-settings.ts, round-type-settings.tsx, 10 legacy admin pages, 7 legacy jury pages, 3 legacy dialogs. Phase 7 validation: 36 tests (10 unit + 8 integration files) all passing, TypeScript 0 errors, Next.js build succeeds, 13 integrity checks, legacy symbol sweep clean, auto-seed on first Docker startup. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,38 +1,36 @@
|
||||
import { z } from 'zod'
|
||||
import { router, protectedProcedure, adminProcedure, observerProcedure } from '../trpc'
|
||||
import { router, observerProcedure } from '../trpc'
|
||||
|
||||
// Shared input schema: either roundId or programId (for entire edition)
|
||||
const editionOrRoundInput = z.object({
|
||||
roundId: z.string().optional(),
|
||||
const editionOrStageInput = z.object({
|
||||
stageId: z.string().optional(),
|
||||
programId: z.string().optional(),
|
||||
}).refine(data => data.roundId || data.programId, {
|
||||
message: 'Either roundId or programId is required',
|
||||
}).refine(data => data.stageId || data.programId, {
|
||||
message: 'Either stageId or programId is required',
|
||||
})
|
||||
|
||||
// Build Prisma where-clauses from the shared input
|
||||
function projectWhere(input: { roundId?: string; programId?: string }) {
|
||||
if (input.roundId) return { roundId: input.roundId }
|
||||
function projectWhere(input: { stageId?: string; programId?: string }) {
|
||||
if (input.stageId) return { assignments: { some: { stageId: input.stageId } } }
|
||||
return { programId: input.programId! }
|
||||
}
|
||||
|
||||
function assignmentWhere(input: { roundId?: string; programId?: string }) {
|
||||
if (input.roundId) return { roundId: input.roundId }
|
||||
return { round: { programId: input.programId! } }
|
||||
function assignmentWhere(input: { stageId?: string; programId?: string }) {
|
||||
if (input.stageId) return { stageId: input.stageId }
|
||||
return { stage: { track: { pipeline: { programId: input.programId! } } } }
|
||||
}
|
||||
|
||||
function evalWhere(input: { roundId?: string; programId?: string }, extra: Record<string, unknown> = {}) {
|
||||
const base = input.roundId
|
||||
? { assignment: { roundId: input.roundId } }
|
||||
: { assignment: { round: { programId: input.programId! } } }
|
||||
function evalWhere(input: { stageId?: string; programId?: string }, extra: Record<string, unknown> = {}) {
|
||||
const base = input.stageId
|
||||
? { assignment: { stageId: input.stageId } }
|
||||
: { assignment: { stage: { track: { pipeline: { programId: input.programId! } } } } }
|
||||
return { ...base, ...extra }
|
||||
}
|
||||
|
||||
export const analyticsRouter = router({
|
||||
/**
|
||||
* Get score distribution for a round (histogram data)
|
||||
* Get score distribution (histogram data)
|
||||
*/
|
||||
getScoreDistribution: observerProcedure
|
||||
.input(editionOrRoundInput)
|
||||
.input(editionOrStageInput)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const evaluations = await ctx.prisma.evaluation.findMany({
|
||||
where: evalWhere(input, { status: 'SUBMITTED' }),
|
||||
@@ -74,7 +72,7 @@ export const analyticsRouter = router({
|
||||
* Get evaluation completion over time (timeline data)
|
||||
*/
|
||||
getEvaluationTimeline: observerProcedure
|
||||
.input(editionOrRoundInput)
|
||||
.input(editionOrStageInput)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const evaluations = await ctx.prisma.evaluation.findMany({
|
||||
where: evalWhere(input, { status: 'SUBMITTED' }),
|
||||
@@ -117,7 +115,7 @@ export const analyticsRouter = router({
|
||||
* Get juror workload distribution
|
||||
*/
|
||||
getJurorWorkload: observerProcedure
|
||||
.input(editionOrRoundInput)
|
||||
.input(editionOrStageInput)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const assignments = await ctx.prisma.assignment.findMany({
|
||||
where: assignmentWhere(input),
|
||||
@@ -166,7 +164,7 @@ export const analyticsRouter = router({
|
||||
* Get project rankings with average scores
|
||||
*/
|
||||
getProjectRankings: observerProcedure
|
||||
.input(editionOrRoundInput.and(z.object({ limit: z.number().optional() })))
|
||||
.input(editionOrStageInput.and(z.object({ limit: z.number().optional() })))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: projectWhere(input),
|
||||
@@ -234,7 +232,7 @@ export const analyticsRouter = router({
|
||||
* Get status breakdown (pie chart data)
|
||||
*/
|
||||
getStatusBreakdown: observerProcedure
|
||||
.input(editionOrRoundInput)
|
||||
.input(editionOrStageInput)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const projects = await ctx.prisma.project.groupBy({
|
||||
by: ['status'],
|
||||
@@ -252,7 +250,7 @@ export const analyticsRouter = router({
|
||||
* Get overview stats for dashboard
|
||||
*/
|
||||
getOverviewStats: observerProcedure
|
||||
.input(editionOrRoundInput)
|
||||
.input(editionOrStageInput)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const [
|
||||
projectCount,
|
||||
@@ -299,12 +297,11 @@ export const analyticsRouter = router({
|
||||
* Get criteria-level score distribution
|
||||
*/
|
||||
getCriteriaScores: observerProcedure
|
||||
.input(editionOrRoundInput)
|
||||
.input(editionOrStageInput)
|
||||
.query(async ({ ctx, input }) => {
|
||||
// Get active evaluation forms — either for a specific round or all rounds in the edition
|
||||
const formWhere = input.roundId
|
||||
? { roundId: input.roundId, isActive: true }
|
||||
: { round: { programId: input.programId! }, isActive: true }
|
||||
const formWhere = input.stageId
|
||||
? { stageId: input.stageId, isActive: true }
|
||||
: { stage: { track: { pipeline: { programId: input.programId! } } }, isActive: true }
|
||||
|
||||
const evaluationForms = await ctx.prisma.evaluationForm.findMany({
|
||||
where: formWhere,
|
||||
@@ -314,14 +311,12 @@ export const analyticsRouter = router({
|
||||
return []
|
||||
}
|
||||
|
||||
// Merge criteria from all forms (deduplicate by label for edition-wide)
|
||||
const criteriaMap = 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) => {
|
||||
// Use label as dedup key for edition-wide, id for single round
|
||||
const key = input.roundId ? c.id : c.label
|
||||
const key = input.stageId ? c.id : c.label
|
||||
if (!criteriaMap.has(key)) {
|
||||
criteriaMap.set(key, c)
|
||||
}
|
||||
@@ -375,12 +370,12 @@ export const analyticsRouter = router({
|
||||
.input(
|
||||
z.object({
|
||||
programId: z.string(),
|
||||
roundId: z.string().optional(),
|
||||
stageId: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const where = input.roundId
|
||||
? { roundId: input.roundId }
|
||||
const where = input.stageId
|
||||
? { assignments: { some: { stageId: input.stageId } } }
|
||||
: { programId: input.programId }
|
||||
|
||||
const distribution = await ctx.prisma.project.groupBy({
|
||||
@@ -400,24 +395,26 @@ export const analyticsRouter = router({
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Compare metrics across multiple rounds
|
||||
* Compare metrics across multiple stages
|
||||
*/
|
||||
getCrossRoundComparison: observerProcedure
|
||||
.input(z.object({ roundIds: z.array(z.string()).min(2) }))
|
||||
getCrossStageComparison: observerProcedure
|
||||
.input(z.object({ stageIds: 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 },
|
||||
input.stageIds.map(async (stageId) => {
|
||||
const stage = await ctx.prisma.stage.findUniqueOrThrow({
|
||||
where: { id: stageId },
|
||||
select: { id: true, name: true },
|
||||
})
|
||||
|
||||
const [projectCount, assignmentCount, evaluationCount] = await Promise.all([
|
||||
ctx.prisma.project.count({ where: { roundId } }),
|
||||
ctx.prisma.assignment.count({ where: { roundId } }),
|
||||
ctx.prisma.project.count({
|
||||
where: { assignments: { some: { stageId } } },
|
||||
}),
|
||||
ctx.prisma.assignment.count({ where: { stageId } }),
|
||||
ctx.prisma.evaluation.count({
|
||||
where: {
|
||||
assignment: { roundId },
|
||||
assignment: { stageId },
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
}),
|
||||
@@ -427,10 +424,9 @@ export const analyticsRouter = router({
|
||||
? Math.round((evaluationCount / assignmentCount) * 100)
|
||||
: 0
|
||||
|
||||
// Get average scores
|
||||
const evaluations = await ctx.prisma.evaluation.findMany({
|
||||
where: {
|
||||
assignment: { roundId },
|
||||
assignment: { stageId },
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
select: { globalScore: true },
|
||||
@@ -444,15 +440,14 @@ export const analyticsRouter = router({
|
||||
? 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,
|
||||
stageId,
|
||||
stageName: stage.name,
|
||||
projectCount,
|
||||
evaluationCount,
|
||||
completionRate,
|
||||
@@ -466,10 +461,10 @@ export const analyticsRouter = router({
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get juror consistency metrics for a round
|
||||
* Get juror consistency metrics for a stage
|
||||
*/
|
||||
getJurorConsistency: observerProcedure
|
||||
.input(editionOrRoundInput)
|
||||
.input(editionOrStageInput)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const evaluations = await ctx.prisma.evaluation.findMany({
|
||||
where: evalWhere(input, { status: 'SUBMITTED' }),
|
||||
@@ -535,10 +530,10 @@ export const analyticsRouter = router({
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get diversity metrics for projects in a round
|
||||
* Get diversity metrics for projects in a stage
|
||||
*/
|
||||
getDiversityMetrics: observerProcedure
|
||||
.input(editionOrRoundInput)
|
||||
.input(editionOrStageInput)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: projectWhere(input),
|
||||
@@ -600,38 +595,39 @@ export const analyticsRouter = router({
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get year-over-year stats across all rounds in a program
|
||||
* Get year-over-year stats across all stages 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 },
|
||||
const stages = await ctx.prisma.stage.findMany({
|
||||
where: { track: { pipeline: { programId: input.programId } } },
|
||||
select: { id: true, name: true, createdAt: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
|
||||
const stats = await Promise.all(
|
||||
rounds.map(async (round) => {
|
||||
stages.map(async (stage) => {
|
||||
const [projectCount, evaluationCount, assignmentCount] = await Promise.all([
|
||||
ctx.prisma.project.count({ where: { roundId: round.id } }),
|
||||
ctx.prisma.project.count({
|
||||
where: { assignments: { some: { stageId: stage.id } } },
|
||||
}),
|
||||
ctx.prisma.evaluation.count({
|
||||
where: {
|
||||
assignment: { roundId: round.id },
|
||||
assignment: { stageId: stage.id },
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
}),
|
||||
ctx.prisma.assignment.count({ where: { roundId: round.id } }),
|
||||
ctx.prisma.assignment.count({ where: { stageId: stage.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 },
|
||||
assignment: { stageId: stage.id },
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
select: { globalScore: true },
|
||||
@@ -646,9 +642,9 @@ export const analyticsRouter = router({
|
||||
: null
|
||||
|
||||
return {
|
||||
roundId: round.id,
|
||||
roundName: round.name,
|
||||
createdAt: round.createdAt,
|
||||
stageId: stage.id,
|
||||
stageName: stage.name,
|
||||
createdAt: stage.createdAt,
|
||||
projectCount,
|
||||
evaluationCount,
|
||||
completionRate,
|
||||
@@ -661,22 +657,24 @@ export const analyticsRouter = router({
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get dashboard stats (optionally scoped to a round)
|
||||
* Get dashboard stats (optionally scoped to a stage)
|
||||
*/
|
||||
getDashboardStats: observerProcedure
|
||||
.input(z.object({ roundId: z.string().optional() }).optional())
|
||||
.input(z.object({ stageId: z.string().optional() }).optional())
|
||||
.query(async ({ ctx, input }) => {
|
||||
const roundId = input?.roundId
|
||||
const stageId = input?.stageId
|
||||
|
||||
const roundWhere = roundId ? { roundId } : {}
|
||||
const assignmentWhere = roundId ? { roundId } : {}
|
||||
const evalWhere = roundId
|
||||
? { assignment: { roundId }, status: 'SUBMITTED' as const }
|
||||
const projectFilter = stageId
|
||||
? { assignments: { some: { stageId } } }
|
||||
: {}
|
||||
const assignmentFilter = stageId ? { stageId } : {}
|
||||
const evalFilter = stageId
|
||||
? { assignment: { stageId }, status: 'SUBMITTED' as const }
|
||||
: { status: 'SUBMITTED' as const }
|
||||
|
||||
const [
|
||||
programCount,
|
||||
activeRoundCount,
|
||||
activeStageCount,
|
||||
projectCount,
|
||||
jurorCount,
|
||||
submittedEvaluations,
|
||||
@@ -684,13 +682,13 @@ export const analyticsRouter = router({
|
||||
evaluationScores,
|
||||
] = await Promise.all([
|
||||
ctx.prisma.program.count(),
|
||||
ctx.prisma.round.count({ where: { status: 'ACTIVE' } }),
|
||||
ctx.prisma.project.count({ where: roundWhere }),
|
||||
ctx.prisma.stage.count({ where: { status: 'STAGE_ACTIVE' } }),
|
||||
ctx.prisma.project.count({ where: projectFilter }),
|
||||
ctx.prisma.user.count({ where: { role: 'JURY_MEMBER', status: 'ACTIVE' } }),
|
||||
ctx.prisma.evaluation.count({ where: evalWhere }),
|
||||
ctx.prisma.assignment.count({ where: assignmentWhere }),
|
||||
ctx.prisma.evaluation.count({ where: evalFilter }),
|
||||
ctx.prisma.assignment.count({ where: assignmentFilter }),
|
||||
ctx.prisma.evaluation.findMany({
|
||||
where: { ...evalWhere, globalScore: { not: null } },
|
||||
where: { ...evalFilter, globalScore: { not: null } },
|
||||
select: { globalScore: true },
|
||||
}),
|
||||
])
|
||||
@@ -713,7 +711,7 @@ export const analyticsRouter = router({
|
||||
|
||||
return {
|
||||
programCount,
|
||||
activeRoundCount,
|
||||
activeStageCount,
|
||||
projectCount,
|
||||
jurorCount,
|
||||
submittedEvaluations,
|
||||
@@ -723,13 +721,380 @@ export const analyticsRouter = router({
|
||||
}
|
||||
}),
|
||||
|
||||
// =========================================================================
|
||||
// Stage-Scoped Analytics (Phase 4)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Get score distribution histogram for stage evaluations
|
||||
*/
|
||||
getStageScoreDistribution: observerProcedure
|
||||
.input(z.object({ stageId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const evaluations = await ctx.prisma.evaluation.findMany({
|
||||
where: {
|
||||
status: 'SUBMITTED',
|
||||
assignment: { stageId: input.stageId },
|
||||
},
|
||||
select: {
|
||||
globalScore: true,
|
||||
criterionScoresJson: true,
|
||||
},
|
||||
})
|
||||
|
||||
// Global score distribution (1-10 buckets)
|
||||
const globalScores = evaluations
|
||||
.map((e) => e.globalScore)
|
||||
.filter((s): s is number => s !== null)
|
||||
|
||||
const globalDistribution = Array.from({ length: 10 }, (_, i) => ({
|
||||
score: i + 1,
|
||||
count: globalScores.filter((s) => Math.round(s) === i + 1).length,
|
||||
}))
|
||||
|
||||
// Per-criterion score distribution
|
||||
const criterionScores: Record<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-stage completion summary for a pipeline
|
||||
*/
|
||||
getStageCompletionOverview: observerProcedure
|
||||
.input(z.object({ pipelineId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
// Get all stages in the pipeline via tracks
|
||||
const tracks = await ctx.prisma.track.findMany({
|
||||
where: { pipelineId: input.pipelineId },
|
||||
include: {
|
||||
stages: {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
stageType: true,
|
||||
status: true,
|
||||
sortOrder: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
})
|
||||
|
||||
const stages = tracks.flatMap((t) =>
|
||||
t.stages.map((s) => ({ ...s, trackName: t.name, trackId: t.id }))
|
||||
)
|
||||
|
||||
// For each stage, get project counts, assignment coverage, evaluation completion
|
||||
const stageOverviews = await Promise.all(
|
||||
stages.map(async (stage) => {
|
||||
const [
|
||||
projectStates,
|
||||
totalAssignments,
|
||||
completedEvaluations,
|
||||
distinctJurors,
|
||||
] = await Promise.all([
|
||||
ctx.prisma.projectStageState.groupBy({
|
||||
by: ['state'],
|
||||
where: { stageId: stage.id },
|
||||
_count: true,
|
||||
}),
|
||||
ctx.prisma.assignment.count({
|
||||
where: { stageId: stage.id },
|
||||
}),
|
||||
ctx.prisma.evaluation.count({
|
||||
where: {
|
||||
assignment: { stageId: stage.id },
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
}),
|
||||
ctx.prisma.assignment.groupBy({
|
||||
by: ['userId'],
|
||||
where: { stageId: stage.id },
|
||||
}),
|
||||
])
|
||||
|
||||
const stateBreakdown = projectStates.map((ps) => ({
|
||||
state: ps.state,
|
||||
count: ps._count,
|
||||
}))
|
||||
|
||||
const totalProjects = projectStates.reduce((sum, ps) => sum + ps._count, 0)
|
||||
const completionRate = totalAssignments > 0
|
||||
? Math.round((completedEvaluations / totalAssignments) * 100)
|
||||
: 0
|
||||
|
||||
return {
|
||||
stageId: stage.id,
|
||||
stageName: stage.name,
|
||||
stageType: stage.stageType,
|
||||
stageStatus: stage.status,
|
||||
trackName: stage.trackName,
|
||||
trackId: stage.trackId,
|
||||
sortOrder: stage.sortOrder,
|
||||
totalProjects,
|
||||
stateBreakdown,
|
||||
totalAssignments,
|
||||
completedEvaluations,
|
||||
pendingEvaluations: totalAssignments - completedEvaluations,
|
||||
completionRate,
|
||||
jurorCount: distinctJurors.length,
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
pipelineId: input.pipelineId,
|
||||
stages: stageOverviews,
|
||||
summary: {
|
||||
totalStages: stages.length,
|
||||
totalProjects: stageOverviews.reduce((sum, s) => sum + s.totalProjects, 0),
|
||||
totalAssignments: stageOverviews.reduce((sum, s) => sum + s.totalAssignments, 0),
|
||||
totalCompleted: stageOverviews.reduce((sum, s) => sum + s.completedEvaluations, 0),
|
||||
},
|
||||
}
|
||||
}),
|
||||
|
||||
// =========================================================================
|
||||
// Award Analytics (Phase 5)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Get per-award-track summary for a pipeline
|
||||
*/
|
||||
getAwardSummary: observerProcedure
|
||||
.input(z.object({ pipelineId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
// Find all AWARD tracks in the pipeline
|
||||
const awardTracks = await ctx.prisma.track.findMany({
|
||||
where: { pipelineId: input.pipelineId, kind: 'AWARD' },
|
||||
include: {
|
||||
specialAward: {
|
||||
include: {
|
||||
winnerProject: { select: { id: true, title: true, teamName: true } },
|
||||
},
|
||||
},
|
||||
stages: {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
select: { id: true, name: true, stageType: true, status: true },
|
||||
},
|
||||
},
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
})
|
||||
|
||||
const awards = await Promise.all(
|
||||
awardTracks.map(async (track) => {
|
||||
const award = track.specialAward
|
||||
|
||||
// Count projects in this track (active PSS)
|
||||
const projectCount = await ctx.prisma.projectStageState.count({
|
||||
where: { trackId: track.id },
|
||||
})
|
||||
|
||||
// Count evaluations in this track's stages
|
||||
const stageIds = track.stages.map((s) => s.id)
|
||||
const [totalAssignments, completedEvals] = await Promise.all([
|
||||
ctx.prisma.assignment.count({
|
||||
where: { stageId: { in: stageIds } },
|
||||
}),
|
||||
ctx.prisma.evaluation.count({
|
||||
where: {
|
||||
assignment: { stageId: { in: stageIds } },
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
const completionRate = totalAssignments > 0
|
||||
? Math.round((completedEvals / totalAssignments) * 100)
|
||||
: 0
|
||||
|
||||
return {
|
||||
trackId: track.id,
|
||||
trackName: track.name,
|
||||
routingMode: track.routingMode,
|
||||
awardId: award?.id ?? null,
|
||||
awardName: award?.name ?? track.name,
|
||||
awardStatus: award?.status ?? null,
|
||||
scoringMode: award?.scoringMode ?? null,
|
||||
projectCount,
|
||||
totalAssignments,
|
||||
completedEvaluations: completedEvals,
|
||||
completionRate,
|
||||
winner: award?.winnerProject
|
||||
? {
|
||||
projectId: award.winnerProject.id,
|
||||
title: award.winnerProject.title,
|
||||
teamName: award.winnerProject.teamName,
|
||||
overridden: award.winnerOverridden,
|
||||
}
|
||||
: null,
|
||||
stages: track.stages,
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return awards
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get per-project vote/score distribution for an award stage
|
||||
*/
|
||||
getAwardVoteDistribution: observerProcedure
|
||||
.input(z.object({ stageId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
// Get all evaluations for this stage
|
||||
const evaluations = await ctx.prisma.evaluation.findMany({
|
||||
where: {
|
||||
status: 'SUBMITTED',
|
||||
assignment: { stageId: input.stageId },
|
||||
},
|
||||
select: {
|
||||
globalScore: true,
|
||||
assignment: {
|
||||
select: {
|
||||
projectId: true,
|
||||
project: { select: { title: true, teamName: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Also get any AwardVotes linked to the stage's track's award
|
||||
const stage = await ctx.prisma.stage.findUnique({
|
||||
where: { id: input.stageId },
|
||||
select: {
|
||||
track: {
|
||||
select: {
|
||||
specialAward: {
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const awardId = stage?.track?.specialAward?.id
|
||||
let awardVotes: Array<{ projectId: string; rank: number | null }> = []
|
||||
if (awardId) {
|
||||
awardVotes = await ctx.prisma.awardVote.findMany({
|
||||
where: { awardId },
|
||||
select: { projectId: true, rank: true },
|
||||
})
|
||||
}
|
||||
|
||||
// Group evaluation scores by project
|
||||
const projectMap = new Map<string, {
|
||||
title: string
|
||||
teamName: string | null
|
||||
scores: number[]
|
||||
voteCount: number
|
||||
avgRank: number | null
|
||||
}>()
|
||||
|
||||
for (const ev of evaluations) {
|
||||
const pid = ev.assignment.projectId
|
||||
if (!projectMap.has(pid)) {
|
||||
projectMap.set(pid, {
|
||||
title: ev.assignment.project.title,
|
||||
teamName: ev.assignment.project.teamName,
|
||||
scores: [],
|
||||
voteCount: 0,
|
||||
avgRank: null,
|
||||
})
|
||||
}
|
||||
if (ev.globalScore !== null) {
|
||||
projectMap.get(pid)!.scores.push(ev.globalScore)
|
||||
}
|
||||
}
|
||||
|
||||
// Merge award votes
|
||||
const ranksByProject = new Map<string, number[]>()
|
||||
for (const vote of awardVotes) {
|
||||
const entry = projectMap.get(vote.projectId)
|
||||
if (entry) {
|
||||
entry.voteCount++
|
||||
}
|
||||
if (vote.rank !== null) {
|
||||
if (!ranksByProject.has(vote.projectId)) ranksByProject.set(vote.projectId, [])
|
||||
ranksByProject.get(vote.projectId)!.push(vote.rank)
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate averages
|
||||
const results = Array.from(projectMap.entries()).map(([projectId, data]) => {
|
||||
const avgScore = data.scores.length > 0
|
||||
? data.scores.reduce((a, b) => a + b, 0) / data.scores.length
|
||||
: null
|
||||
const minScore = data.scores.length > 0 ? Math.min(...data.scores) : null
|
||||
const maxScore = data.scores.length > 0 ? Math.max(...data.scores) : null
|
||||
|
||||
const ranks = ranksByProject.get(projectId)
|
||||
const avgRank = ranks?.length
|
||||
? ranks.reduce((a, b) => a + b, 0) / ranks.length
|
||||
: null
|
||||
|
||||
return {
|
||||
projectId,
|
||||
title: data.title,
|
||||
teamName: data.teamName,
|
||||
evaluationCount: data.scores.length,
|
||||
voteCount: data.voteCount,
|
||||
avgScore,
|
||||
minScore,
|
||||
maxScore,
|
||||
avgRank,
|
||||
}
|
||||
})
|
||||
|
||||
// Sort by avgScore descending
|
||||
results.sort((a, b) => (b.avgScore ?? 0) - (a.avgScore ?? 0))
|
||||
|
||||
return {
|
||||
stageId: input.stageId,
|
||||
projects: results,
|
||||
totalEvaluations: evaluations.length,
|
||||
totalVotes: awardVotes.length,
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get all projects with pagination, filtering, and search (for observer dashboard)
|
||||
*/
|
||||
getAllProjects: observerProcedure
|
||||
.input(
|
||||
z.object({
|
||||
roundId: z.string().optional(),
|
||||
stageId: z.string().optional(),
|
||||
search: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
page: z.number().min(1).default(1),
|
||||
@@ -739,8 +1104,8 @@ export const analyticsRouter = router({
|
||||
.query(async ({ ctx, input }) => {
|
||||
const where: Record<string, unknown> = {}
|
||||
|
||||
if (input.roundId) {
|
||||
where.roundId = input.roundId
|
||||
if (input.stageId) {
|
||||
where.assignments = { some: { stageId: input.stageId } }
|
||||
}
|
||||
|
||||
if (input.status) {
|
||||
@@ -763,9 +1128,10 @@ export const analyticsRouter = router({
|
||||
teamName: true,
|
||||
status: true,
|
||||
country: true,
|
||||
round: { select: { id: true, name: true } },
|
||||
assignments: {
|
||||
select: {
|
||||
stageId: true,
|
||||
stage: { select: { id: true, name: true } },
|
||||
evaluation: {
|
||||
select: { globalScore: true, status: true },
|
||||
},
|
||||
@@ -791,14 +1157,16 @@ export const analyticsRouter = router({
|
||||
? scores.reduce((a, b) => a + b, 0) / scores.length
|
||||
: null
|
||||
|
||||
const firstAssignment = p.assignments[0]
|
||||
|
||||
return {
|
||||
id: p.id,
|
||||
title: p.title,
|
||||
teamName: p.teamName,
|
||||
status: p.status,
|
||||
country: p.country,
|
||||
roundId: p.round?.id ?? '',
|
||||
roundName: p.round?.name ?? '',
|
||||
stageId: firstAssignment?.stage?.id ?? '',
|
||||
stageName: firstAssignment?.stage?.name ?? '',
|
||||
averageScore,
|
||||
evaluationCount: submitted.length,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user