Platform-wide visual overhaul, team invites, analytics improvements, and deployment hardening

UI overhaul applying jury dashboard design patterns across all pages:
- Stat cards with border-l-4 accent + icon pills on admin, observer, mentor, applicant dashboards and reports
- Card section headers with color-coded icon pills throughout
- Hover lift effects (translate-y + shadow) on cards and list items
- Gradient progress bars (brand-teal to brand-blue) platform-wide
- AnimatedCard stagger animations on all dashboard sections
- Auth pages with gradient accent strip and polished icon containers
- EmptyState component upgraded with rounded icon pill containers
- Replaced AI-looking icons (Brain/Sparkles/Bot/Wand2/Cpu) with descriptive alternatives across 12 files
- Removed gradient overlay from jury dashboard header
- Quick actions restyled as card links with group hover effects

Backend improvements:
- Team member invite emails with account setup flow and notification logging
- Analytics routers accept edition-wide queries (programId) in addition to roundId
- Round detail endpoint returns inline progress data (eliminates extra getProgress call)
- Award voting endpoints parallelized with Promise.all
- Bulk invite supports optional sendInvitation flag
- AwardVote composite index migration for query performance

Infrastructure:
- Docker entrypoint with migration retry loop (configurable retries/delay)
- docker-compose pull_policy: always for automatic image refresh
- Simplified deploy/update scripts using docker compose up -d --pull always
- Updated deployment documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 13:20:52 +01:00
parent 98f4a957cc
commit ce4069bf92
59 changed files with 1949 additions and 913 deletions

View File

@@ -1,18 +1,41 @@
import { z } from 'zod'
import { router, protectedProcedure, adminProcedure, observerProcedure } from '../trpc'
// Shared input schema: either roundId or programId (for entire edition)
const editionOrRoundInput = z.object({
roundId: z.string().optional(),
programId: z.string().optional(),
}).refine(data => data.roundId || data.programId, {
message: 'Either roundId or programId is required',
})
// Build Prisma where-clauses from the shared input
function projectWhere(input: { roundId?: string; programId?: string }) {
if (input.roundId) return { roundId: input.roundId }
return { programId: input.programId! }
}
function assignmentWhere(input: { roundId?: string; programId?: string }) {
if (input.roundId) return { roundId: input.roundId }
return { round: { programId: input.programId! } }
}
function evalWhere(input: { roundId?: string; programId?: string }, extra: Record<string, unknown> = {}) {
const base = input.roundId
? { assignment: { roundId: input.roundId } }
: { assignment: { round: { programId: input.programId! } } }
return { ...base, ...extra }
}
export const analyticsRouter = router({
/**
* Get score distribution for a round (histogram data)
*/
getScoreDistribution: observerProcedure
.input(z.object({ roundId: z.string() }))
.input(editionOrRoundInput)
.query(async ({ ctx, input }) => {
const evaluations = await ctx.prisma.evaluation.findMany({
where: {
assignment: { roundId: input.roundId },
status: 'SUBMITTED',
},
where: evalWhere(input, { status: 'SUBMITTED' }),
select: {
criterionScoresJson: true,
},
@@ -51,13 +74,10 @@ export const analyticsRouter = router({
* Get evaluation completion over time (timeline data)
*/
getEvaluationTimeline: observerProcedure
.input(z.object({ roundId: z.string() }))
.input(editionOrRoundInput)
.query(async ({ ctx, input }) => {
const evaluations = await ctx.prisma.evaluation.findMany({
where: {
assignment: { roundId: input.roundId },
status: 'SUBMITTED',
},
where: evalWhere(input, { status: 'SUBMITTED' }),
select: {
submittedAt: true,
},
@@ -97,10 +117,10 @@ export const analyticsRouter = router({
* Get juror workload distribution
*/
getJurorWorkload: observerProcedure
.input(z.object({ roundId: z.string() }))
.input(editionOrRoundInput)
.query(async ({ ctx, input }) => {
const assignments = await ctx.prisma.assignment.findMany({
where: { roundId: input.roundId },
where: assignmentWhere(input),
include: {
user: { select: { name: true, email: true } },
evaluation: {
@@ -146,10 +166,10 @@ export const analyticsRouter = router({
* Get project rankings with average scores
*/
getProjectRankings: observerProcedure
.input(z.object({ roundId: z.string(), limit: z.number().optional() }))
.input(editionOrRoundInput.and(z.object({ limit: z.number().optional() })))
.query(async ({ ctx, input }) => {
const projects = await ctx.prisma.project.findMany({
where: { roundId: input.roundId },
where: projectWhere(input),
select: {
id: true,
title: true,
@@ -214,11 +234,11 @@ export const analyticsRouter = router({
* Get status breakdown (pie chart data)
*/
getStatusBreakdown: observerProcedure
.input(z.object({ roundId: z.string() }))
.input(editionOrRoundInput)
.query(async ({ ctx, input }) => {
const projects = await ctx.prisma.project.groupBy({
by: ['status'],
where: { roundId: input.roundId },
where: projectWhere(input),
_count: true,
})
@@ -232,7 +252,7 @@ export const analyticsRouter = router({
* Get overview stats for dashboard
*/
getOverviewStats: observerProcedure
.input(z.object({ roundId: z.string() }))
.input(editionOrRoundInput)
.query(async ({ ctx, input }) => {
const [
projectCount,
@@ -241,21 +261,18 @@ export const analyticsRouter = router({
jurorCount,
statusCounts,
] = await Promise.all([
ctx.prisma.project.count({ where: { roundId: input.roundId } }),
ctx.prisma.assignment.count({ where: { roundId: input.roundId } }),
ctx.prisma.project.count({ where: projectWhere(input) }),
ctx.prisma.assignment.count({ where: assignmentWhere(input) }),
ctx.prisma.evaluation.count({
where: {
assignment: { roundId: input.roundId },
status: 'SUBMITTED',
},
where: evalWhere(input, { status: 'SUBMITTED' }),
}),
ctx.prisma.assignment.groupBy({
by: ['userId'],
where: { roundId: input.roundId },
where: assignmentWhere(input),
}),
ctx.prisma.project.groupBy({
by: ['status'],
where: { roundId: input.roundId },
where: projectWhere(input),
_count: true,
}),
])
@@ -282,33 +299,44 @@ export const analyticsRouter = router({
* Get criteria-level score distribution
*/
getCriteriaScores: observerProcedure
.input(z.object({ roundId: z.string() }))
.input(editionOrRoundInput)
.query(async ({ ctx, input }) => {
// Get active evaluation form for this round
const evaluationForm = await ctx.prisma.evaluationForm.findFirst({
where: { roundId: input.roundId, isActive: true },
// Get active evaluation forms — either for a specific round or all rounds in the edition
const formWhere = input.roundId
? { roundId: input.roundId, isActive: true }
: { round: { programId: input.programId! }, isActive: true }
const evaluationForms = await ctx.prisma.evaluationForm.findMany({
where: formWhere,
})
if (!evaluationForm?.criteriaJson) {
if (!evaluationForms.length) {
return []
}
// Parse criteria from JSON
const criteria = evaluationForm.criteriaJson as Array<{
id: string
label: string
}>
// Merge criteria from all forms (deduplicate by label for edition-wide)
const criteriaMap = 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) => {
// Use label as dedup key for edition-wide, id for single round
const key = input.roundId ? c.id : c.label
if (!criteriaMap.has(key)) {
criteriaMap.set(key, c)
}
})
}
})
if (!criteria || criteria.length === 0) {
const criteria = Array.from(criteriaMap.values())
if (criteria.length === 0) {
return []
}
// Get all evaluations
const evaluations = await ctx.prisma.evaluation.findMany({
where: {
assignment: { roundId: input.roundId },
status: 'SUBMITTED',
},
where: evalWhere(input, { status: 'SUBMITTED' }),
select: { criterionScoresJson: true },
})
@@ -441,13 +469,10 @@ export const analyticsRouter = router({
* Get juror consistency metrics for a round
*/
getJurorConsistency: observerProcedure
.input(z.object({ roundId: z.string() }))
.input(editionOrRoundInput)
.query(async ({ ctx, input }) => {
const evaluations = await ctx.prisma.evaluation.findMany({
where: {
assignment: { roundId: input.roundId },
status: 'SUBMITTED',
},
where: evalWhere(input, { status: 'SUBMITTED' }),
include: {
assignment: {
include: {
@@ -513,10 +538,10 @@ export const analyticsRouter = router({
* Get diversity metrics for projects in a round
*/
getDiversityMetrics: observerProcedure
.input(z.object({ roundId: z.string() }))
.input(editionOrRoundInput)
.query(async ({ ctx, input }) => {
const projects = await ctx.prisma.project.findMany({
where: { roundId: input.roundId },
where: projectWhere(input),
select: {
country: true,
competitionCategory: true,