Add round-type-specific observer reports with dynamic tabs
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m44s
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:
@@ -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,
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user