Observer platform overhaul: Nivo charts, round-type stats, UX improvements
All checks were successful
Build and Push Docker Image / build (push) Successful in 12m29s
All checks were successful
Build and Push Docker Image / build (push) Successful in 12m29s
Phase 1: Fix 6 backend data bugs in analytics.ts (roundName filtering, unscored projects, criteria scores, activeRoundCount scoping, email privacy leaks in juror consistency + workload) Phase 2-3: Migrate all 9 chart components from Recharts to Nivo (@nivo/bar, @nivo/line, @nivo/pie, @nivo/scatterplot) with shared brand theme, scoreGradient colors, and STATUS_COLORS map. Fixes scatter plot outlier coloring and pie chart label visibility bugs. Phase 4: Add round-type-aware stats (getRoundTypeStats backend + RoundTypeStatsCards component) showing appropriate metrics per round type (intake/filtering/evaluation/submission/mentoring/live/deliberation). Phase 5: UX improvements — Stage→Round terminology, clickable dashboard round links, URL-based round selection (?round=), round type indicators in selectors, accessible Toggle-based cross-round comparison, sortable project table columns (title/score/evaluations), brand score colors on dashboard bar chart with aria labels. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -121,7 +121,7 @@ export const analyticsRouter = router({
|
||||
const assignments = await ctx.prisma.assignment.findMany({
|
||||
where: assignmentWhere(input),
|
||||
include: {
|
||||
user: { select: { name: true, email: true } },
|
||||
user: { select: { name: true } },
|
||||
evaluation: {
|
||||
select: { id: true, status: true },
|
||||
},
|
||||
@@ -138,7 +138,7 @@ export const analyticsRouter = router({
|
||||
const userId = assignment.userId
|
||||
if (!byUser[userId]) {
|
||||
byUser[userId] = {
|
||||
name: assignment.user.name || assignment.user.email || 'Unknown',
|
||||
name: assignment.user.name || 'Unknown',
|
||||
assigned: 0,
|
||||
completed: 0,
|
||||
}
|
||||
@@ -317,21 +317,24 @@ export const analyticsRouter = router({
|
||||
return []
|
||||
}
|
||||
|
||||
const criteriaMap = new Map<string, { id: string; label: string }>()
|
||||
// Build label → Set<id> map so program-level queries match all IDs for the same criterion label
|
||||
const labelToIds = new Map<string, Set<string>>()
|
||||
const labelToFirst = 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) => {
|
||||
const key = input.roundId ? c.id : c.label
|
||||
if (!criteriaMap.has(key)) {
|
||||
criteriaMap.set(key, c)
|
||||
if (!labelToIds.has(c.label)) {
|
||||
labelToIds.set(c.label, new Set())
|
||||
labelToFirst.set(c.label, c)
|
||||
}
|
||||
labelToIds.get(c.label)!.add(c.id)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const criteria = Array.from(criteriaMap.values())
|
||||
if (criteria.length === 0) {
|
||||
const criteriaLabels = Array.from(labelToFirst.values())
|
||||
if (criteriaLabels.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -341,17 +344,23 @@ export const analyticsRouter = router({
|
||||
select: { criterionScoresJson: true },
|
||||
})
|
||||
|
||||
// Calculate average score per criterion
|
||||
const criteriaScores = criteria.map((criterion) => {
|
||||
// Calculate average score per criterion, checking ALL IDs that share the same label
|
||||
const criteriaScores = criteriaLabels.map((criterion) => {
|
||||
const scores: number[] = []
|
||||
const ids = labelToIds.get(criterion.label) ?? new Set([criterion.id])
|
||||
|
||||
evaluations.forEach((evaluation) => {
|
||||
const criterionScoresJson = evaluation.criterionScoresJson as Record<
|
||||
string,
|
||||
number
|
||||
> | null
|
||||
if (criterionScoresJson && typeof criterionScoresJson[criterion.id] === 'number') {
|
||||
scores.push(criterionScoresJson[criterion.id])
|
||||
if (criterionScoresJson) {
|
||||
for (const cid of ids) {
|
||||
if (typeof criterionScoresJson[cid] === 'number') {
|
||||
scores.push(criterionScoresJson[cid])
|
||||
break // Only count one score per evaluation per criterion
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -496,21 +505,20 @@ export const analyticsRouter = router({
|
||||
include: {
|
||||
assignment: {
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
user: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Group scores by juror
|
||||
const jurorScores: Record<string, { name: string; email: string; scores: number[] }> = {}
|
||||
const jurorScores: Record<string, { name: 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 || '',
|
||||
name: e.assignment.user.name || 'Unknown',
|
||||
scores: [],
|
||||
}
|
||||
}
|
||||
@@ -539,7 +547,6 @@ export const analyticsRouter = router({
|
||||
return {
|
||||
userId,
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
evaluationCount: data.scores.length,
|
||||
averageScore: avg,
|
||||
stddev,
|
||||
@@ -731,7 +738,12 @@ export const analyticsRouter = router({
|
||||
evaluationScores,
|
||||
] = await Promise.all([
|
||||
ctx.prisma.program.count(),
|
||||
ctx.prisma.round.count({ where: { status: 'ROUND_ACTIVE' } }),
|
||||
roundId
|
||||
? ctx.prisma.round.findUnique({ where: { id: roundId }, select: { competitionId: true } })
|
||||
.then((r) => r?.competitionId
|
||||
? ctx.prisma.round.count({ where: { competitionId: r.competitionId, status: 'ROUND_ACTIVE' } })
|
||||
: ctx.prisma.round.count({ where: { status: 'ROUND_ACTIVE' } }))
|
||||
: ctx.prisma.round.count({ where: { status: 'ROUND_ACTIVE' } }),
|
||||
ctx.prisma.project.count({ where: projectFilter }),
|
||||
roundId
|
||||
? ctx.prisma.assignment.findMany({
|
||||
@@ -949,6 +961,8 @@ export const analyticsRouter = router({
|
||||
roundId: z.string().optional(),
|
||||
search: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
sortBy: z.enum(['title', 'score', 'evaluations']).default('title'),
|
||||
sortDir: z.enum(['asc', 'desc']).default('asc'),
|
||||
page: z.number().min(1).default(1),
|
||||
perPage: z.number().min(1).max(100).default(20),
|
||||
})
|
||||
@@ -971,6 +985,11 @@ export const analyticsRouter = router({
|
||||
]
|
||||
}
|
||||
|
||||
// Prisma-level sort for title; score/evaluations sorted post-query
|
||||
const prismaOrderBy = input.sortBy === 'title'
|
||||
? { title: input.sortDir as 'asc' | 'desc' }
|
||||
: { title: 'asc' as const }
|
||||
|
||||
const [projects, total] = await Promise.all([
|
||||
ctx.prisma.project.findMany({
|
||||
where,
|
||||
@@ -990,9 +1009,11 @@ export const analyticsRouter = router({
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { title: 'asc' },
|
||||
skip: (input.page - 1) * input.perPage,
|
||||
take: input.perPage,
|
||||
orderBy: prismaOrderBy,
|
||||
// When sorting by computed fields, fetch all then slice in JS
|
||||
...(input.sortBy === 'title'
|
||||
? { skip: (input.page - 1) * input.perPage, take: input.perPage }
|
||||
: {}),
|
||||
}),
|
||||
ctx.prisma.project.count({ where }),
|
||||
])
|
||||
@@ -1009,7 +1030,10 @@ export const analyticsRouter = router({
|
||||
? scores.reduce((a, b) => a + b, 0) / scores.length
|
||||
: null
|
||||
|
||||
const firstAssignment = p.assignments[0]
|
||||
// Filter assignments to the queried round so we show the correct round name
|
||||
const roundAssignment = input.roundId
|
||||
? p.assignments.find((a) => a.roundId === input.roundId)
|
||||
: p.assignments[0]
|
||||
|
||||
return {
|
||||
id: p.id,
|
||||
@@ -1017,19 +1041,200 @@ export const analyticsRouter = router({
|
||||
teamName: p.teamName,
|
||||
status: p.status,
|
||||
country: p.country,
|
||||
roundId: firstAssignment?.round?.id ?? '',
|
||||
roundName: firstAssignment?.round?.name ?? '',
|
||||
roundId: roundAssignment?.round?.id ?? '',
|
||||
roundName: roundAssignment?.round?.name ?? '',
|
||||
averageScore,
|
||||
evaluationCount: submitted.length,
|
||||
}
|
||||
})
|
||||
|
||||
// Sort by computed fields (score, evaluations) in JS
|
||||
let sorted = mapped
|
||||
if (input.sortBy === 'score') {
|
||||
sorted = mapped.sort((a, b) => {
|
||||
const sa = a.averageScore ?? -1
|
||||
const sb = b.averageScore ?? -1
|
||||
return input.sortDir === 'asc' ? sa - sb : sb - sa
|
||||
})
|
||||
} else if (input.sortBy === 'evaluations') {
|
||||
sorted = mapped.sort((a, b) =>
|
||||
input.sortDir === 'asc'
|
||||
? a.evaluationCount - b.evaluationCount
|
||||
: b.evaluationCount - a.evaluationCount
|
||||
)
|
||||
}
|
||||
|
||||
// Paginate in JS for computed-field sorts
|
||||
const paginated = input.sortBy !== 'title'
|
||||
? sorted.slice((input.page - 1) * input.perPage, input.page * input.perPage)
|
||||
: sorted
|
||||
|
||||
return {
|
||||
projects: mapped,
|
||||
projects: paginated,
|
||||
total,
|
||||
page: input.page,
|
||||
perPage: input.perPage,
|
||||
totalPages: Math.ceil(total / input.perPage),
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get round-type-aware stats for a specific round.
|
||||
* Returns different metrics depending on the round type.
|
||||
*/
|
||||
getRoundTypeStats: observerProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.roundId },
|
||||
select: { id: true, roundType: true, competitionId: true },
|
||||
})
|
||||
|
||||
const roundType = round.roundType
|
||||
|
||||
switch (roundType) {
|
||||
case 'INTAKE': {
|
||||
const [total, byState, byCategory] = await Promise.all([
|
||||
ctx.prisma.projectRoundState.count({ where: { roundId: input.roundId } }),
|
||||
ctx.prisma.projectRoundState.groupBy({
|
||||
by: ['state'],
|
||||
where: { roundId: input.roundId },
|
||||
_count: true,
|
||||
}),
|
||||
ctx.prisma.project.groupBy({
|
||||
by: ['competitionCategory'],
|
||||
where: { projectRoundStates: { some: { roundId: input.roundId } } },
|
||||
_count: true,
|
||||
}),
|
||||
])
|
||||
return {
|
||||
roundType,
|
||||
stats: {
|
||||
totalProjects: total,
|
||||
byState: byState.map((s) => ({ state: s.state, count: s._count })),
|
||||
byCategory: byCategory.map((c) => ({
|
||||
category: c.competitionCategory ?? 'Uncategorized',
|
||||
count: c._count,
|
||||
})),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
case 'FILTERING': {
|
||||
const [total, byOutcome] = await Promise.all([
|
||||
ctx.prisma.filteringResult.count({ where: { roundId: input.roundId } }),
|
||||
ctx.prisma.filteringResult.groupBy({
|
||||
by: ['outcome'],
|
||||
where: { roundId: input.roundId },
|
||||
_count: true,
|
||||
}),
|
||||
])
|
||||
const passed = byOutcome.find((o) => o.outcome === 'PASSED')?._count ?? 0
|
||||
return {
|
||||
roundType,
|
||||
stats: {
|
||||
totalScreened: total,
|
||||
passed,
|
||||
filteredOut: byOutcome.find((o) => o.outcome === 'FILTERED_OUT')?._count ?? 0,
|
||||
flagged: byOutcome.find((o) => o.outcome === 'FLAGGED')?._count ?? 0,
|
||||
passRate: total > 0 ? Math.round((passed / total) * 100) : 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
case 'EVALUATION': {
|
||||
const [assignmentCount, submittedCount, jurorCount] = await Promise.all([
|
||||
ctx.prisma.assignment.count({ where: { roundId: input.roundId } }),
|
||||
ctx.prisma.evaluation.count({
|
||||
where: { assignment: { roundId: input.roundId }, status: 'SUBMITTED' },
|
||||
}),
|
||||
ctx.prisma.assignment.findMany({
|
||||
where: { roundId: input.roundId },
|
||||
select: { userId: true },
|
||||
distinct: ['userId'],
|
||||
}).then((rows) => rows.length),
|
||||
])
|
||||
return {
|
||||
roundType,
|
||||
stats: {
|
||||
totalAssignments: assignmentCount,
|
||||
completedEvaluations: submittedCount,
|
||||
completionRate: assignmentCount > 0 ? Math.round((submittedCount / assignmentCount) * 100) : 0,
|
||||
activeJurors: jurorCount,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
case 'SUBMISSION': {
|
||||
const [fileCount, teamsWithFiles] = await Promise.all([
|
||||
ctx.prisma.projectFile.count({ where: { roundId: input.roundId } }),
|
||||
ctx.prisma.projectFile.findMany({
|
||||
where: { roundId: input.roundId },
|
||||
select: { projectId: true },
|
||||
distinct: ['projectId'],
|
||||
}).then((rows) => rows.length),
|
||||
])
|
||||
return {
|
||||
roundType,
|
||||
stats: {
|
||||
totalFiles: fileCount,
|
||||
teamsSubmitted: teamsWithFiles,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
case 'MENTORING': {
|
||||
const [assignmentCount, messageCount] = await Promise.all([
|
||||
ctx.prisma.mentorAssignment.count({
|
||||
where: { project: { projectRoundStates: { some: { roundId: input.roundId } } } },
|
||||
}),
|
||||
ctx.prisma.mentorMessage.count({
|
||||
where: { project: { projectRoundStates: { some: { roundId: input.roundId } } } },
|
||||
}),
|
||||
])
|
||||
return {
|
||||
roundType,
|
||||
stats: {
|
||||
mentorAssignments: assignmentCount,
|
||||
totalMessages: messageCount,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
case 'LIVE_FINAL': {
|
||||
const session = await ctx.prisma.liveVotingSession.findUnique({
|
||||
where: { roundId: input.roundId },
|
||||
select: { id: true, status: true, _count: { select: { votes: true } } },
|
||||
})
|
||||
return {
|
||||
roundType,
|
||||
stats: {
|
||||
sessionStatus: session?.status ?? 'NOT_STARTED',
|
||||
voteCount: session?._count.votes ?? 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
case 'DELIBERATION': {
|
||||
const [sessions, votes, locks] = await Promise.all([
|
||||
ctx.prisma.deliberationSession.count({ where: { roundId: input.roundId } }),
|
||||
ctx.prisma.deliberationVote.count({
|
||||
where: { session: { roundId: input.roundId } },
|
||||
}),
|
||||
ctx.prisma.resultLock.count({ where: { roundId: input.roundId } }),
|
||||
])
|
||||
return {
|
||||
roundType,
|
||||
stats: {
|
||||
totalSessions: sessions,
|
||||
totalVotes: votes,
|
||||
resultsLocked: locks,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return { roundType, stats: {} }
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -63,6 +63,7 @@ export const programRouter = router({
|
||||
name: round.name,
|
||||
competitionId: round.competitionId,
|
||||
status: round.status,
|
||||
roundType: round.roundType,
|
||||
votingEndAt: round.windowCloseAt,
|
||||
_count: {
|
||||
projects: round._count?.projectRoundStates || 0,
|
||||
|
||||
Reference in New Issue
Block a user