Observer platform redesign Phase 4: migrate charts to Tremor, redesign all pages
Some checks failed
Build and Push Docker Image / build (push) Failing after 23s

- Migrate 9 chart components from Nivo to @tremor/react (BarChart, AreaChart, DonutChart, ScatterChart)
- Remove @nivo/*, @react-spring/web dependencies (45 packages removed)
- Redesign dashboard: 6 stat tiles, competition pipeline, score distribution, juror workload, activity feed
- Add new /observer/projects page with search, filters, sorting, pagination, CSV export
- Restructure reports page from 5 tabs to 3 (Progress, Jurors, Scores & Analytics) with per-tab CSV export
- Redesign project detail: breadcrumb nav, score card header, 3-tab layout (Overview/Evaluations/Files)
- Update loading skeletons to match new layouts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-20 21:45:01 +01:00
parent 77cbc64b33
commit 8125ca6567
24 changed files with 3412 additions and 3401 deletions

View File

@@ -627,92 +627,6 @@ export const analyticsRouter = router({
return { total, byCountry, byCategory, byOceanIssue, byTag }
}),
/**
* Get year-over-year stats across all rounds in a program
*/
getYearOverYear: observerProcedure
.input(z.object({ programId: z.string() }))
.query(async ({ ctx, input }) => {
const competitions = await ctx.prisma.competition.findMany({
where: { programId: input.programId },
include: {
rounds: {
select: { id: true, name: true, createdAt: true },
orderBy: { createdAt: 'asc' },
},
},
orderBy: { createdAt: 'asc' },
})
const allRounds = competitions.flatMap((c) => c.rounds)
const roundIds = allRounds.map((r) => r.id)
if (roundIds.length === 0) return []
// Batch: fetch assignments, evaluations, and distinct projects in 3 queries
const [assignmentCounts, evaluations, projectAssignments] = await Promise.all([
ctx.prisma.assignment.groupBy({
by: ['roundId'],
where: { roundId: { in: roundIds } },
_count: true,
}),
ctx.prisma.evaluation.findMany({
where: {
assignment: { roundId: { in: roundIds } },
status: 'SUBMITTED',
},
select: { globalScore: true, assignment: { select: { roundId: true } } },
}),
ctx.prisma.assignment.findMany({
where: { roundId: { in: roundIds } },
select: { roundId: true, projectId: true },
distinct: ['roundId', 'projectId'],
}),
])
const assignmentCountMap = new Map(assignmentCounts.map((a) => [a.roundId, a._count]))
// Group evaluation scores by round
const scoresByRound = new Map<string, number[]>()
const evalCountByRound = new Map<string, number>()
for (const e of evaluations) {
const rid = e.assignment.roundId
evalCountByRound.set(rid, (evalCountByRound.get(rid) ?? 0) + 1)
if (e.globalScore !== null) {
if (!scoresByRound.has(rid)) scoresByRound.set(rid, [])
scoresByRound.get(rid)!.push(e.globalScore)
}
}
// Count distinct projects per round
const projectsByRound = new Map<string, number>()
for (const pa of projectAssignments) {
projectsByRound.set(pa.roundId, (projectsByRound.get(pa.roundId) ?? 0) + 1)
}
return allRounds.map((round) => {
const scores = scoresByRound.get(round.id) ?? []
const assignmentCount = assignmentCountMap.get(round.id) ?? 0
const evaluationCount = evalCountByRound.get(round.id) ?? 0
const completionRate = assignmentCount > 0
? Math.round((evaluationCount / assignmentCount) * 100)
: 0
const averageScore = scores.length > 0
? scores.reduce((a, b) => a + b, 0) / scores.length
: null
return {
roundId: round.id,
roundName: round.name,
createdAt: round.createdAt,
projectCount: projectsByRound.get(round.id) ?? 0,
evaluationCount,
completionRate,
averageScore,
}
})
}),
/**
* Get dashboard stats (optionally scoped to a round)
*/
@@ -875,61 +789,86 @@ export const analyticsRouter = router({
},
})
// For each round, get assignment coverage and evaluation completion
const roundOverviews = await Promise.all(
rounds.map(async (round) => {
const [
projectRoundStates,
totalAssignments,
completedEvaluations,
distinctJurors,
] = await Promise.all([
ctx.prisma.projectRoundState.groupBy({
by: ['state'],
where: { roundId: round.id },
_count: true,
}),
ctx.prisma.assignment.count({
where: { roundId: round.id },
}),
ctx.prisma.evaluation.count({
where: {
assignment: { roundId: round.id },
status: 'SUBMITTED',
},
}),
ctx.prisma.assignment.groupBy({
by: ['userId'],
where: { roundId: round.id },
}),
])
// Batch all queries by roundIds to avoid N+1
const roundIds = rounds.map((r) => r.id)
const stateBreakdown = projectRoundStates.map((ps) => ({
state: ps.state,
count: ps._count,
}))
const [
allProjectRoundStates,
allAssignmentCounts,
allCompletedEvals,
allDistinctJurors,
] = await Promise.all([
ctx.prisma.projectRoundState.groupBy({
by: ['roundId', 'state'],
where: { roundId: { in: roundIds } },
_count: true,
}),
ctx.prisma.assignment.groupBy({
by: ['roundId'],
where: { roundId: { in: roundIds } },
_count: true,
}),
// groupBy on relation field not supported, use raw count per round
ctx.prisma.$queryRaw<{ roundId: string; count: bigint }[]>`
SELECT a."roundId", COUNT(e.id)::bigint as count
FROM "Evaluation" e
JOIN "Assignment" a ON e."assignmentId" = a.id
WHERE a."roundId" = ANY(${roundIds}) AND e.status = 'SUBMITTED'
GROUP BY a."roundId"
`,
ctx.prisma.assignment.groupBy({
by: ['roundId', 'userId'],
where: { roundId: { in: roundIds } },
}),
])
const totalProjects = projectRoundStates.reduce((sum, ps) => sum + ps._count, 0)
const completionRate = totalAssignments > 0
? Math.round((completedEvaluations / totalAssignments) * 100)
: 0
// Build lookup maps
const statesByRound = new Map<string, { state: string; count: number }[]>()
for (const ps of allProjectRoundStates) {
const list = statesByRound.get(ps.roundId) || []
list.push({ state: ps.state, count: ps._count })
statesByRound.set(ps.roundId, list)
}
return {
roundId: round.id,
roundName: round.name,
roundType: round.roundType,
roundStatus: round.status,
sortOrder: round.sortOrder,
totalProjects,
stateBreakdown,
totalAssignments,
completedEvaluations,
pendingEvaluations: totalAssignments - completedEvaluations,
completionRate,
jurorCount: distinctJurors.length,
}
})
)
const assignmentCountByRound = new Map<string, number>()
for (const ac of allAssignmentCounts) {
assignmentCountByRound.set(ac.roundId, ac._count)
}
const completedEvalsByRound = new Map<string, number>()
for (const ce of allCompletedEvals) {
completedEvalsByRound.set(ce.roundId, Number(ce.count))
}
const jurorCountByRound = new Map<string, number>()
for (const j of allDistinctJurors) {
jurorCountByRound.set(j.roundId, (jurorCountByRound.get(j.roundId) || 0) + 1)
}
const roundOverviews = rounds.map((round) => {
const stateBreakdown = statesByRound.get(round.id) || []
const totalProjects = stateBreakdown.reduce((sum, ps) => sum + ps.count, 0)
const totalAssignments = assignmentCountByRound.get(round.id) || 0
const completedEvaluations = completedEvalsByRound.get(round.id) || 0
const completionRate = totalAssignments > 0
? Math.round((completedEvaluations / totalAssignments) * 100)
: 0
return {
roundId: round.id,
roundName: round.name,
roundType: round.roundType,
roundStatus: round.status,
sortOrder: round.sortOrder,
totalProjects,
stateBreakdown,
totalAssignments,
completedEvaluations,
pendingEvaluations: totalAssignments - completedEvaluations,
completionRate,
jurorCount: jurorCountByRound.get(round.id) || 0,
}
})
return {
competitionId: input.competitionId,
@@ -972,7 +911,7 @@ export const analyticsRouter = router({
const where: Record<string, unknown> = {}
if (input.roundId) {
where.assignments = { some: { roundId: input.roundId } }
where.projectRoundStates = { some: { roundId: input.roundId } }
}
if (input.status) {
@@ -1370,4 +1309,47 @@ export const analyticsRouter = router({
allRequirements,
}
}),
/**
* Activity feed — recent audit log entries for observer dashboard
*/
getActivityFeed: observerProcedure
.input(z.object({ limit: z.number().min(1).max(50).default(10) }).optional())
.query(async ({ ctx, input }) => {
const limit = input?.limit ?? 10
const entries = await ctx.prisma.decisionAuditLog.findMany({
orderBy: { createdAt: 'desc' },
take: limit,
select: {
id: true,
eventType: true,
entityType: true,
entityId: true,
actorId: true,
detailsJson: true,
createdAt: true,
},
})
// Batch-fetch actor names
const actorIds = [...new Set(entries.map((e) => e.actorId).filter(Boolean))] as string[]
const actors = actorIds.length > 0
? await ctx.prisma.user.findMany({
where: { id: { in: actorIds } },
select: { id: true, name: true },
})
: []
const actorMap = new Map(actors.map((a) => [a.id, a.name]))
return entries.map((entry) => ({
id: entry.id,
eventType: entry.eventType,
entityType: entry.entityType,
entityId: entry.entityId,
actorName: entry.actorId ? actorMap.get(entry.actorId) ?? null : null,
details: entry.detailsJson as Record<string, unknown> | null,
createdAt: entry.createdAt,
}))
}),
})

View File

@@ -20,9 +20,9 @@ export const fileRouter = router({
})
)
.query(async ({ ctx, input }) => {
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER'].includes(ctx.user.role)
if (!isAdmin) {
if (!isAdminOrObserver) {
const file = await ctx.prisma.projectFile.findFirst({
where: { bucket: input.bucket, objectKey: input.objectKey },
select: {
@@ -283,9 +283,9 @@ export const fileRouter = router({
roundId: z.string().optional(),
}))
.query(async ({ ctx, input }) => {
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER'].includes(ctx.user.role)
if (!isAdmin) {
if (!isAdminOrObserver) {
const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([
ctx.prisma.assignment.findFirst({
where: { userId: ctx.user.id, projectId: input.projectId },
@@ -348,9 +348,9 @@ export const fileRouter = router({
roundId: z.string(),
}))
.query(async ({ ctx, input }) => {
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER'].includes(ctx.user.role)
if (!isAdmin) {
if (!isAdminOrObserver) {
const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([
ctx.prisma.assignment.findFirst({
where: { userId: ctx.user.id, projectId: input.projectId },
@@ -468,9 +468,9 @@ export const fileRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER'].includes(ctx.user.role)
if (!isAdmin) {
if (!isAdminOrObserver) {
// Check user has access to the project (assigned or team member)
const [assignment, mentorAssignment, teamMembership] = await Promise.all([
ctx.prisma.assignment.findFirst({
@@ -652,9 +652,9 @@ export const fileRouter = router({
})
)
.query(async ({ ctx, input }) => {
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER'].includes(ctx.user.role)
if (!isAdmin) {
if (!isAdminOrObserver) {
const [assignment, mentorAssignment, teamMembership] = await Promise.all([
ctx.prisma.assignment.findFirst({
where: { userId: ctx.user.id, projectId: input.projectId },