Implement 10 platform features: evaluation UX, admin tools, AI summaries, applicant portal

Batch 1 - Quick Wins:
- F1: Evaluation progress indicator with touch tracking in sticky status bar
- F2: Export filtering results as CSV with dynamic AI column flattening
- F3: Observer access to analytics dashboards (8 procedures changed to observerProcedure)

Batch 2 - Jury Experience:
- F4: Countdown timer component with urgency colors + email reminder service with cron endpoint
- F5: Conflict of interest declaration system (dialog, admin management, review workflow)

Batch 3 - Admin & AI Enhancements:
- F6: Bulk status update UI with selection checkboxes, floating toolbar, status history recording
- F7: AI-powered evaluation summary with anonymized data, OpenAI integration, scoring patterns
- F8: Smart assignment improvements (geo diversity penalty, round familiarity bonus, COI blocking)

Batch 4 - Form Flexibility & Applicant Portal:
- F9: Evaluation form flexibility (text, boolean, section_header types, conditional visibility)
- F10: Applicant portal (status timeline, per-round documents, mentor messaging)

Schema: 5 new models (ReminderLog, ConflictOfInterest, EvaluationSummary, ProjectStatusHistory, MentorMessage), ProjectFile extended with roundId + isLate.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-05 21:58:27 +01:00
parent 002a9dbfc3
commit 699248e40b
38 changed files with 5437 additions and 533 deletions

View File

@@ -1,11 +1,11 @@
import { z } from 'zod'
import { router, protectedProcedure, adminProcedure } from '../trpc'
import { router, protectedProcedure, adminProcedure, observerProcedure } from '../trpc'
export const analyticsRouter = router({
/**
* Get score distribution for a round (histogram data)
*/
getScoreDistribution: adminProcedure
getScoreDistribution: observerProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const evaluations = await ctx.prisma.evaluation.findMany({
@@ -50,7 +50,7 @@ export const analyticsRouter = router({
/**
* Get evaluation completion over time (timeline data)
*/
getEvaluationTimeline: adminProcedure
getEvaluationTimeline: observerProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const evaluations = await ctx.prisma.evaluation.findMany({
@@ -96,7 +96,7 @@ export const analyticsRouter = router({
/**
* Get juror workload distribution
*/
getJurorWorkload: adminProcedure
getJurorWorkload: observerProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const assignments = await ctx.prisma.assignment.findMany({
@@ -145,7 +145,7 @@ export const analyticsRouter = router({
/**
* Get project rankings with average scores
*/
getProjectRankings: adminProcedure
getProjectRankings: observerProcedure
.input(z.object({ roundId: z.string(), limit: z.number().optional() }))
.query(async ({ ctx, input }) => {
const projects = await ctx.prisma.project.findMany({
@@ -213,7 +213,7 @@ export const analyticsRouter = router({
/**
* Get status breakdown (pie chart data)
*/
getStatusBreakdown: adminProcedure
getStatusBreakdown: observerProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const projects = await ctx.prisma.project.groupBy({
@@ -231,7 +231,7 @@ export const analyticsRouter = router({
/**
* Get overview stats for dashboard
*/
getOverviewStats: adminProcedure
getOverviewStats: observerProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const [
@@ -281,7 +281,7 @@ export const analyticsRouter = router({
/**
* Get criteria-level score distribution
*/
getCriteriaScores: adminProcedure
getCriteriaScores: observerProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
// Get active evaluation form for this round
@@ -343,7 +343,7 @@ export const analyticsRouter = router({
/**
* Get geographic distribution of projects by country
*/
getGeographicDistribution: adminProcedure
getGeographicDistribution: observerProcedure
.input(
z.object({
programId: z.string(),