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
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:
@@ -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,
|
||||
}))
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user