From ae1685179ca64f1b44f0f732a44901020f91a960 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 19 Feb 2026 09:56:09 +0100 Subject: [PATCH] Comprehensive admin UI stats audit: fix 16 display bugs HIGH fixes: - H1: Competition detail project count no longer double-counts across rounds - H2: Rounds page header stats use unfiltered round set - H3: Rounds page "eval" label corrected to "asgn" (assignment count) - H4: Observer reports project count uses distinct analytics count - H5: Awards eligibility count filters to only eligible=true (backend) - H6: Round detail projectCount derived from projectStates for consistency - H7: Deliberation hasVoted derived from votes array (was always undefined) MEDIUM fixes: - M1: Reports page round status badges use correct ROUND_ACTIVE/ROUND_CLOSED enums - M2: Observer reports badges use ROUND_ prefix instead of stale STAGE_ prefix - M3: Deliberation list status badges use correct VOTING/TALLYING/RUNOFF enums - M4: Competition list/detail round count excludes special-award rounds (backend) - M5: Messages page shows actual recipient count instead of hardcoded "1 user" LOW fixes: - L2: Observer analytics jurorCount scoped to round when roundId provided - L3: Analytics round-scoped project count uses ProjectRoundState not assignments - L4: JuryGroup delete audit log reports member count (not assignment count) - L5: Project rankings include unevaluated projects at bottom instead of hiding Co-Authored-By: Claude Opus 4.6 --- .../deliberation/[sessionId]/page.tsx | 16 +++++++++----- .../[competitionId]/deliberation/page.tsx | 16 ++++++++++---- .../competitions/[competitionId]/page.tsx | 4 ++-- src/app/(admin)/admin/messages/page.tsx | 2 +- src/app/(admin)/admin/reports/page.tsx | 6 +++--- .../(admin)/admin/rounds/[roundId]/page.tsx | 2 +- src/app/(admin)/admin/rounds/page.tsx | 7 ++++--- src/app/(observer)/observer/reports/page.tsx | 15 ++++++------- src/server/routers/analytics.ts | 21 ++++++++++++++----- src/server/routers/competition.ts | 8 +++++-- src/server/routers/juryGroup.ts | 4 ++-- src/server/routers/specialAward.ts | 4 ++-- 12 files changed, 68 insertions(+), 37 deletions(-) diff --git a/src/app/(admin)/admin/competitions/[competitionId]/deliberation/[sessionId]/page.tsx b/src/app/(admin)/admin/competitions/[competitionId]/deliberation/[sessionId]/page.tsx index a262f9d..9cb56ce 100644 --- a/src/app/(admin)/admin/competitions/[competitionId]/deliberation/[sessionId]/page.tsx +++ b/src/app/(admin)/admin/competitions/[competitionId]/deliberation/[sessionId]/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { use } from 'react'; +import { use, useMemo } from 'react'; import { useRouter } from 'next/navigation'; import { trpc } from '@/lib/trpc/client'; import { Button } from '@/components/ui/button'; @@ -46,6 +46,12 @@ export default function DeliberationSessionPage({ } }); + // Derive which participants have voted from the votes array + const voterUserIds = useMemo(() => { + if (!session?.votes) return new Set(); + return new Set(session.votes.map((v: any) => v.juryMember?.user?.id).filter(Boolean)); + }, [session?.votes]); + if (isLoading) { return (
@@ -153,8 +159,8 @@ export default function DeliberationSessionPage({

{participant.user?.name}

{participant.user?.email}

- - {participant.hasVoted ? 'Voted' : 'Pending'} + + {voterUserIds.has(participant.user?.user?.id) ? 'Voted' : 'Pending'} ))} @@ -206,8 +212,8 @@ export default function DeliberationSessionPage({ className="flex items-center justify-between rounded-lg border p-3" > {participant.user?.name} - - {participant.hasVoted ? 'Submitted' : 'Not Voted'} + + {voterUserIds.has(participant.user?.user?.id) ? 'Submitted' : 'Not Voted'} ))} diff --git a/src/app/(admin)/admin/competitions/[competitionId]/deliberation/page.tsx b/src/app/(admin)/admin/competitions/[competitionId]/deliberation/page.tsx index 05a4966..b9f0053 100644 --- a/src/app/(admin)/admin/competitions/[competitionId]/deliberation/page.tsx +++ b/src/app/(admin)/admin/competitions/[competitionId]/deliberation/page.tsx @@ -106,11 +106,19 @@ export default function DeliberationListPage({ const getStatusBadge = (status: string) => { const variants: Record = { DELIB_OPEN: 'outline', - DELIB_VOTING: 'default', - DELIB_TALLYING: 'secondary', - DELIB_LOCKED: 'destructive' + VOTING: 'default', + TALLYING: 'secondary', + RUNOFF: 'secondary', + DELIB_LOCKED: 'destructive', }; - return {status}; + const labels: Record = { + DELIB_OPEN: 'Open', + VOTING: 'Voting', + TALLYING: 'Tallying', + RUNOFF: 'Runoff', + DELIB_LOCKED: 'Locked', + }; + return {labels[status] || status}; }; if (isLoading) { diff --git a/src/app/(admin)/admin/competitions/[competitionId]/page.tsx b/src/app/(admin)/admin/competitions/[competitionId]/page.tsx index e12d4bd..a1af298 100644 --- a/src/app/(admin)/admin/competitions/[competitionId]/page.tsx +++ b/src/app/(admin)/admin/competitions/[competitionId]/page.tsx @@ -285,7 +285,7 @@ export default function CompetitionDetailPage() { Rounds -

{competition.rounds.length}

+

{competition.rounds.filter((r: any) => !r.specialAwardId).length}

@@ -304,7 +304,7 @@ export default function CompetitionDetailPage() { Projects

- {competition.rounds.reduce((sum: number, r: any) => sum + (r._count?.projectRoundStates ?? 0), 0)} + {(competition as any).distinctProjectCount ?? 0}

diff --git a/src/app/(admin)/admin/messages/page.tsx b/src/app/(admin)/admin/messages/page.tsx index eaafe41..3cae895 100644 --- a/src/app/(admin)/admin/messages/page.tsx +++ b/src/app/(admin)/admin/messages/page.tsx @@ -592,7 +592,7 @@ export default function MessagesPage() { : msg.recipientType === 'ROUND_JURY' ? 'Round jury' : msg.recipientType === 'USER' - ? '1 user' + ? `${recipientCount || 1} user${recipientCount > 1 ? 's' : ''}` : msg.recipientType} {recipientCount > 0 && ` (${recipientCount})`} diff --git a/src/app/(admin)/admin/reports/page.tsx b/src/app/(admin)/admin/reports/page.tsx index 85f3807..945f05b 100644 --- a/src/app/(admin)/admin/reports/page.tsx +++ b/src/app/(admin)/admin/reports/page.tsx @@ -354,14 +354,14 @@ function ReportsOverview() { - {round.status} + {round.status?.replace('ROUND_', '') || round.status} diff --git a/src/app/(admin)/admin/rounds/[roundId]/page.tsx b/src/app/(admin)/admin/rounds/[roundId]/page.tsx index c016819..a7c848e 100644 --- a/src/app/(admin)/admin/rounds/[roundId]/page.tsx +++ b/src/app/(admin)/admin/rounds/[roundId]/page.tsx @@ -441,7 +441,7 @@ export default function RoundDetailPage() { }, [configJson]) // ── Computed values ──────────────────────────────────────────────────── - const projectCount = round?._count?.projectRoundStates ?? 0 + const projectCount = projectStates?.length ?? round?._count?.projectRoundStates ?? 0 const stateCounts = useMemo(() => projectStates?.reduce((acc: Record, ps: any) => { acc[ps.state] = (acc[ps.state] || 0) + 1 diff --git a/src/app/(admin)/admin/rounds/page.tsx b/src/app/(admin)/admin/rounds/page.tsx index 2da7ccf..4a6e856 100644 --- a/src/app/(admin)/admin/rounds/page.tsx +++ b/src/app/(admin)/admin/rounds/page.tsx @@ -284,7 +284,8 @@ export default function RoundsPage() { const activeFilter = filterType !== 'all' const totalProjects = (compDetail as any)?.distinctProjectCount ?? 0 - const totalAssignments = rounds.reduce((s, r) => s + r._count.assignments, 0) + const allRounds = (compDetail?.rounds ?? []) as RoundWithStats[] + const totalAssignments = allRounds.reduce((s, r) => s + r._count.assignments, 0) const activeRound = rounds.find((r) => r.status === 'ROUND_ACTIVE') return ( @@ -326,7 +327,7 @@ export default function RoundsPage() {
- {rounds.length} rounds + {allRounds.length} rounds | {totalProjects} projects | @@ -493,7 +494,7 @@ export default function RoundsPage() { {projectCount} {assignmentCount > 0 && ( - {assignmentCount} eval + {assignmentCount} asgn )} {(round.windowOpenAt || round.windowCloseAt) && ( diff --git a/src/app/(observer)/observer/reports/page.tsx b/src/app/(observer)/observer/reports/page.tsx index adf2462..1834e8f 100644 --- a/src/app/(observer)/observer/reports/page.tsx +++ b/src/app/(observer)/observer/reports/page.tsx @@ -100,7 +100,8 @@ function OverviewTab({ selectedValue }: { selectedValue: string | null }) { ) } - const totalProjects = stages.reduce((acc, s) => acc + (s._count?.projects || 0), 0) + // Count distinct projects by collecting unique IDs, not summing per-round states + const totalProjects = overviewStats?.projectCount ?? stages.reduce((acc, s) => acc + (s._count?.projects || 0), 0) const activeStages = stages.filter((s) => s.status === 'ROUND_ACTIVE').length const totalPrograms = programs?.length || 0 @@ -285,14 +286,14 @@ function OverviewTab({ selectedValue }: { selectedValue: string | null }) { - {stage.status === 'STAGE_ACTIVE' ? 'Active' : stage.status === 'STAGE_CLOSED' ? 'Closed' : stage.status} + {stage.status === 'ROUND_ACTIVE' ? 'Active' : stage.status === 'ROUND_CLOSED' ? 'Closed' : stage.status} @@ -312,14 +313,14 @@ function OverviewTab({ selectedValue }: { selectedValue: string | null }) {

{stage.name}

- {stage.status === 'STAGE_ACTIVE' ? 'Active' : stage.status === 'STAGE_CLOSED' ? 'Closed' : stage.status} + {stage.status === 'ROUND_ACTIVE' ? 'Active' : stage.status === 'ROUND_CLOSED' ? 'Closed' : stage.status}

{stage.programName}

diff --git a/src/server/routers/analytics.ts b/src/server/routers/analytics.ts index e45ec73..8c358e0 100644 --- a/src/server/routers/analytics.ts +++ b/src/server/routers/analytics.ts @@ -10,7 +10,7 @@ const editionOrRoundInput = z.object({ }) function projectWhere(input: { roundId?: string; programId?: string }) { - if (input.roundId) return { assignments: { some: { roundId: input.roundId } } } + if (input.roundId) return { projectRoundStates: { some: { roundId: input.roundId } } } return { programId: input.programId! } } @@ -223,8 +223,13 @@ export const analyticsRouter = router({ evaluationCount: allScores.length, } }) - .filter((p) => p.averageScore !== null) - .sort((a, b) => (b.averageScore || 0) - (a.averageScore || 0)) + .sort((a, b) => { + // Evaluated projects first (sorted by score desc), unevaluated at bottom + if (a.averageScore !== null && b.averageScore !== null) return b.averageScore - a.averageScore + if (a.averageScore !== null) return -1 + if (b.averageScore !== null) return 1 + return 0 + }) return input.limit ? rankings.slice(0, input.limit) : rankings }), @@ -709,7 +714,7 @@ export const analyticsRouter = router({ const roundId = input?.roundId const projectFilter = roundId - ? { assignments: { some: { roundId } } } + ? { projectRoundStates: { some: { roundId } } } : {} const assignmentFilter = roundId ? { roundId } : {} const evalFilter = roundId @@ -728,7 +733,13 @@ export const analyticsRouter = router({ ctx.prisma.program.count(), ctx.prisma.round.count({ where: { status: 'ROUND_ACTIVE' } }), ctx.prisma.project.count({ where: projectFilter }), - ctx.prisma.user.count({ where: { role: 'JURY_MEMBER', status: 'ACTIVE' } }), + roundId + ? ctx.prisma.assignment.findMany({ + where: { roundId }, + select: { userId: true }, + distinct: ['userId'], + }).then((rows) => rows.length) + : ctx.prisma.user.count({ where: { role: 'JURY_MEMBER', status: 'ACTIVE' } }), ctx.prisma.evaluation.count({ where: evalFilter }), ctx.prisma.assignment.count({ where: assignmentFilter }), ctx.prisma.evaluation.findMany({ diff --git a/src/server/routers/competition.ts b/src/server/routers/competition.ts index 6ee3472..309e8b8 100644 --- a/src/server/routers/competition.ts +++ b/src/server/routers/competition.ts @@ -146,7 +146,11 @@ export const competitionRouter = router({ orderBy: { createdAt: 'desc' }, include: { _count: { - select: { rounds: true, juryGroups: true, submissionWindows: true }, + select: { + rounds: { where: { specialAwardId: null } }, + juryGroups: true, + submissionWindows: true, + }, }, }, }) @@ -256,7 +260,7 @@ export const competitionRouter = router({ orderBy: { sortOrder: 'asc' }, select: { id: true, name: true, roundType: true, status: true }, }, - _count: { select: { rounds: true, juryGroups: true } }, + _count: { select: { rounds: { where: { specialAwardId: null } }, juryGroups: true } }, }, orderBy: { createdAt: 'desc' }, }) diff --git a/src/server/routers/juryGroup.ts b/src/server/routers/juryGroup.ts index 4c5a16f..4e1fb1f 100644 --- a/src/server/routers/juryGroup.ts +++ b/src/server/routers/juryGroup.ts @@ -258,7 +258,7 @@ export const juryGroupRouter = router({ const group = await ctx.prisma.juryGroup.findUniqueOrThrow({ where: { id: input.id }, include: { - _count: { select: { assignments: true, rounds: true } }, + _count: { select: { members: true, assignments: true, rounds: true } }, }, }) @@ -284,7 +284,7 @@ export const juryGroupRouter = router({ detailsJson: { name: group.name, competitionId: group.competitionId, - memberCount: group._count.assignments, + memberCount: group._count.members, }, ipAddress: ctx.ip, userAgent: ctx.userAgent, diff --git a/src/server/routers/specialAward.ts b/src/server/routers/specialAward.ts index 25ef52c..5852176 100644 --- a/src/server/routers/specialAward.ts +++ b/src/server/routers/specialAward.ts @@ -43,7 +43,7 @@ export const specialAwardRouter = router({ include: { _count: { select: { - eligibilities: true, + eligibilities: { where: { eligible: true } }, jurors: true, votes: true, }, @@ -66,7 +66,7 @@ export const specialAwardRouter = router({ include: { _count: { select: { - eligibilities: true, + eligibilities: { where: { eligible: true } }, jurors: true, votes: true, },