fix: scope analytics.getProjectDetail by optional roundId

The procedure pulled every SUBMITTED evaluation for a project across
every round it ever participated in, then computed Avg Score / Pass
Rate / Evaluators from that pool. Meanwhile the per-juror list rendered
in the admin sheet filters to the current round, producing a card that
disagreed with the visible list. With roundId in the input, callers
opt into round-scoped stats; omitting it preserves the old aggregate
behavior for any caller that hasn't been updated yet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-04-27 13:12:27 +02:00
parent 260baf3a41
commit 7147115918
2 changed files with 77 additions and 2 deletions

View File

@@ -1368,7 +1368,7 @@ export const analyticsRouter = router({
* Read-only combined endpoint to avoid multiple round-trips.
*/
getProjectDetail: observerProcedure
.input(z.object({ id: z.string() }))
.input(z.object({ id: z.string(), roundId: z.string().optional() }))
.query(async ({ ctx, input }) => {
const [projectRaw, projectTags, assignments, submittedEvaluations] = await Promise.all([
ctx.prisma.project.findUniqueOrThrow({
@@ -1417,7 +1417,10 @@ export const analyticsRouter = router({
ctx.prisma.evaluation.findMany({
where: {
status: 'SUBMITTED',
assignment: { projectId: input.id },
assignment: {
projectId: input.id,
...(input.roundId ? { roundId: input.roundId } : {}),
},
},
}),
])

View File

@@ -0,0 +1,72 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { prisma, createCaller } from '../setup'
import {
createTestUser, createTestProgram, createTestCompetition, createTestRound,
createTestProject, createTestProjectRoundState, createTestAssignment,
createTestEvaluation, createTestEvaluationForm, cleanupTestData, uid,
} from '../helpers'
import { analyticsRouter } from '../../src/server/routers/analytics'
describe('analytics.getProjectDetail round scoping', () => {
let programId: string
let admin: { id: string; email: string; role: 'SUPER_ADMIN' }
let projectId: string
let roundAId: string
let roundBId: string
const userIds: string[] = []
beforeAll(async () => {
const program = await createTestProgram({ name: `bal-scope-${uid()}` })
programId = program.id
const competition = await createTestCompetition(programId)
const roundA = await createTestRound(competition.id, { name: 'Round A', sortOrder: 0, status: 'ROUND_CLOSED' })
const roundB = await createTestRound(competition.id, { name: 'Round B', sortOrder: 1, status: 'ROUND_ACTIVE' })
roundAId = roundA.id
roundBId = roundB.id
const formA = await createTestEvaluationForm(roundA.id)
const formB = await createTestEvaluationForm(roundB.id)
const project = await createTestProject(programId)
projectId = project.id
await createTestProjectRoundState(projectId, roundA.id, { state: 'PASSED' })
await createTestProjectRoundState(projectId, roundB.id, { state: 'IN_PROGRESS' })
// 2 evaluations on Round A: 7.0, 8.0 (mean 7.5)
for (const score of [7, 8]) {
const juror = await createTestUser('JURY_MEMBER')
userIds.push(juror.id)
const a = await createTestAssignment(juror.id, projectId, roundA.id)
await createTestEvaluation(a.id, formA.id, { status: 'SUBMITTED', globalScore: score, submittedAt: new Date() })
}
// 3 evaluations on Round B: 9.0, 8.0, 8.0 (mean 8.333…)
for (const score of [9, 8, 8]) {
const juror = await createTestUser('JURY_MEMBER')
userIds.push(juror.id)
const a = await createTestAssignment(juror.id, projectId, roundB.id)
await createTestEvaluation(a.id, formB.id, { status: 'SUBMITTED', globalScore: score, submittedAt: new Date() })
}
const adminUser = await createTestUser('SUPER_ADMIN')
userIds.push(adminUser.id)
admin = { id: adminUser.id, email: adminUser.email, role: 'SUPER_ADMIN' }
})
afterAll(async () => {
await cleanupTestData(programId, userIds)
})
it('returns only round-B stats when roundId=roundB is passed', async () => {
const caller = createCaller(analyticsRouter, admin)
const result = await caller.getProjectDetail({ id: projectId, roundId: roundBId })
expect(result.stats).not.toBeNull()
expect(result.stats!.totalEvaluations).toBe(3)
expect(result.stats!.averageGlobalScore).toBeCloseTo(8.333, 2)
})
it('returns aggregated stats across all rounds when roundId is omitted', async () => {
const caller = createCaller(analyticsRouter, admin)
const result = await caller.getProjectDetail({ id: projectId })
expect(result.stats!.totalEvaluations).toBe(5)
})
})