Add round-type-specific observer reports with dynamic tabs
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m44s

Refactor the observer reports page from a static 3-tab layout to a
dynamic tab system that adapts to each round type (INTAKE, FILTERING,
EVALUATION, SUBMISSION, MENTORING, LIVE_FINAL, DELIBERATION). Adds a
persistent Global tab for edition-wide analytics, juror score heatmap,
expandable juror assignment rows, filtering screening bar, and
deliberation results with tie detection.

- Add 5 observer proxy procedures to analytics router
- Create JurorScoreHeatmap, ExpandableJurorTable, FilteringScreeningBar
- Create 8 round-type tab components + GlobalAnalyticsTab
- Reduce reports page from 914 to ~190 lines (thin dispatcher)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-21 09:29:26 +01:00
parent ee3bfec8b0
commit 2e4b95f29c
14 changed files with 2326 additions and 786 deletions

View File

@@ -2,6 +2,7 @@ import { z } from 'zod'
import { router, observerProcedure } from '../trpc'
import { normalizeCountryToCode } from '@/lib/countries'
import { getUserAvatarUrl } from '../utils/avatar-url'
import { aggregateVotes } from '../services/deliberation'
const editionOrRoundInput = z.object({
roundId: z.string().optional(),
@@ -1456,4 +1457,231 @@ export const analyticsRouter = router({
createdAt: entry.createdAt,
}))
}),
// =========================================================================
// Round-Type-Specific Observer Reports
// =========================================================================
/**
* Get filtering result stats for a round (observer proxy of filtering.getResultStats)
*/
getFilteringResultStats: observerProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const [passed, filteredOut, flagged, overridden] = await Promise.all([
ctx.prisma.filteringResult.count({
where: {
roundId: input.roundId,
OR: [
{ finalOutcome: 'PASSED' },
{ finalOutcome: null, outcome: 'PASSED' },
],
},
}),
ctx.prisma.filteringResult.count({
where: {
roundId: input.roundId,
OR: [
{ finalOutcome: 'FILTERED_OUT' },
{ finalOutcome: null, outcome: 'FILTERED_OUT' },
],
},
}),
ctx.prisma.filteringResult.count({
where: {
roundId: input.roundId,
OR: [
{ finalOutcome: 'FLAGGED' },
{ finalOutcome: null, outcome: 'FLAGGED' },
],
},
}),
ctx.prisma.filteringResult.count({
where: { roundId: input.roundId, overriddenBy: { not: null } },
}),
])
const round = await ctx.prisma.round.findUnique({
where: { id: input.roundId },
select: { competitionId: true },
})
let routedToAwards = 0
if (round?.competitionId) {
routedToAwards = await ctx.prisma.awardEligibility.count({
where: {
award: {
competitionId: round.competitionId,
eligibilityMode: 'SEPARATE_POOL',
},
shortlisted: true,
confirmedAt: { not: null },
},
})
}
return { passed, filteredOut, flagged, overridden, routedToAwards, total: passed + filteredOut + flagged }
}),
/**
* Get filtering results list for a round (observer proxy of filtering.getResults)
*/
getFilteringResults: observerProcedure
.input(z.object({
roundId: z.string(),
outcome: z.enum(['PASSED', 'FILTERED_OUT', 'FLAGGED']).optional(),
page: z.number().int().min(1).default(1),
perPage: z.number().int().min(1).max(100).default(20),
}))
.query(async ({ ctx, input }) => {
const { roundId, outcome, page, perPage } = input
const skip = (page - 1) * perPage
const where: Record<string, unknown> = { roundId }
if (outcome) {
where.OR = [
{ finalOutcome: outcome },
{ finalOutcome: null, outcome },
]
}
const [results, total] = await Promise.all([
ctx.prisma.filteringResult.findMany({
where,
skip,
take: perPage,
orderBy: { createdAt: 'desc' },
include: {
project: {
select: {
id: true,
title: true,
teamName: true,
competitionCategory: true,
country: true,
awardEligibilities: {
where: {
shortlisted: true,
confirmedAt: { not: null },
award: { eligibilityMode: 'SEPARATE_POOL' },
},
select: {
award: { select: { name: true } },
},
},
},
},
},
}),
ctx.prisma.filteringResult.count({ where }),
])
return {
results,
total,
page,
perPage,
totalPages: Math.ceil(total / perPage),
}
}),
/**
* Get deliberation sessions for a round
*/
getDeliberationSessions: observerProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const sessions = await ctx.prisma.deliberationSession.findMany({
where: { roundId: input.roundId },
select: {
id: true,
category: true,
status: true,
mode: true,
_count: { select: { votes: true, participants: true } },
},
orderBy: { createdAt: 'desc' },
})
return sessions
}),
/**
* Get aggregated vote results for a deliberation session
*/
getDeliberationAggregate: observerProcedure
.input(z.object({ sessionId: z.string() }))
.query(async ({ ctx, input }) => {
const agg = await aggregateVotes(input.sessionId, ctx.prisma)
const projectIds = agg.rankings.map((r) => r.projectId)
const projects = await ctx.prisma.project.findMany({
where: { id: { in: projectIds } },
select: { id: true, title: true, teamName: true },
})
const projectMap = new Map(projects.map((p) => [p.id, p]))
return {
rankings: agg.rankings.map((r) => ({
...r,
projectTitle: projectMap.get(r.projectId)?.title ?? 'Unknown',
teamName: projectMap.get(r.projectId)?.teamName ?? '',
})),
hasTies: agg.hasTies,
tiedProjectIds: agg.tiedProjectIds,
}
}),
/**
* Get juror score matrix for a round (capped at 30 most-assigned projects)
*/
getJurorScoreMatrix: 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: { id: true, name: true } },
project: { select: { id: true, title: true } },
evaluation: {
select: { globalScore: true, status: true },
},
},
})
const jurorMap = new Map<string, string>()
const projectMap = new Map<string, string>()
const cells: { jurorId: string; projectId: string; score: number | null }[] = []
for (const a of assignments) {
jurorMap.set(a.user.id, a.user.name ?? 'Unknown')
projectMap.set(a.project.id, a.project.title)
if (a.evaluation?.status === 'SUBMITTED') {
cells.push({
jurorId: a.user.id,
projectId: a.project.id,
score: a.evaluation.globalScore,
})
}
}
const projectAssignCounts = new Map<string, number>()
for (const a of assignments) {
projectAssignCounts.set(a.project.id, (projectAssignCounts.get(a.project.id) ?? 0) + 1)
}
const topProjectIds = [...projectAssignCounts.entries()]
.sort(([, a], [, b]) => b - a)
.slice(0, 30)
.map(([id]) => id)
const topProjectSet = new Set(topProjectIds)
return {
jurors: [...jurorMap.entries()].map(([id, name]) => ({ id, name })),
projects: topProjectIds.map((id) => ({ id, title: projectMap.get(id) ?? 'Unknown' })),
cells: cells.filter((c) => topProjectSet.has(c.projectId)),
truncated: projectAssignCounts.size > 30,
totalProjects: projectAssignCounts.size,
}
}),
})