Implement 15 platform features: digest, availability, templates, comparison, live voting SSE, file versioning, mentorship, messaging, analytics, drafts, webhooks, peer review, audit enhancements, i18n

Features implemented:
- F1: Email digest notifications with cron endpoint and per-user frequency
- F2: Jury availability windows and workload preferences in smart assignment
- F3: Round templates with save-from-round and CRUD management
- F4: Side-by-side project comparison view for jury members
- F5: Real-time voting dashboard with Server-Sent Events (SSE)
- F6: Live voting UX: QR codes, audience voting, tie-breaking, score animations
- F7: File versioning, inline preview, bulk download with presigned URLs
- F8: Mentor dashboard: milestones, private notes, activity tracking
- F9: Communication hub with broadcasts, templates, and recipient targeting
- F10: Advanced analytics: cross-round comparison, juror consistency, diversity metrics, PDF export
- F11: Applicant draft saving with magic link resume and cron cleanup
- F12: Webhook integration layer with HMAC signing, retry, and delivery logs
- F13: Peer review discussions with anonymized scores and threaded comments
- F14: Audit log enhancements: before/after diffs, session grouping, anomaly detection, retention
- F15: i18n foundation with next-intl (EN/FR), cookie-based locale, language switcher

Schema: 12 new models, field additions to User, Project, ProjectFile, LiveVotingSession, LiveVote, MentorAssignment, AuditLog, Program
New routers: roundTemplate, message, webhook (registered in _app.ts)
New services: email-digest, webhook-dispatcher
New cron endpoints: /api/cron/digest, /api/cron/draft-cleanup, /api/cron/audit-cleanup
New API routes: /api/live-voting/stream (SSE), /api/files/bulk-download

All features are admin-configurable via SystemSettings or per-model settingsJson fields.
Docker build verified successfully.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-05 23:31:41 +01:00
parent f038c95777
commit 59436ed67a
68 changed files with 14541 additions and 546 deletions

View File

@@ -35,7 +35,12 @@ import {
ClipboardList,
CheckCircle2,
TrendingUp,
GitCompare,
UserCheck,
Globe,
Printer,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { formatDateOnly } from '@/lib/utils'
import {
ScoreDistributionChart,
@@ -44,6 +49,9 @@ import {
JurorWorkloadChart,
ProjectRankingsChart,
CriteriaScoresChart,
CrossRoundComparisonChart,
JurorConsistencyChart,
DiversityMetricsChart,
} from '@/components/charts'
function OverviewTab({ selectedRoundId }: { selectedRoundId: string | null }) {
@@ -410,6 +418,121 @@ function AnalyticsTab({ selectedRoundId }: { selectedRoundId: string }) {
)
}
function CrossRoundTab() {
const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeRounds: true })
const rounds = programs?.flatMap(p =>
p.rounds.map(r => ({ id: r.id, name: r.name, programName: `${p.year} Edition` }))
) || []
const [selectedRoundIds, setSelectedRoundIds] = useState<string[]>([])
const { data: comparison, isLoading: comparisonLoading } =
trpc.analytics.getCrossRoundComparison.useQuery(
{ roundIds: selectedRoundIds },
{ enabled: selectedRoundIds.length >= 2 }
)
const toggleRound = (roundId: string) => {
setSelectedRoundIds((prev) =>
prev.includes(roundId)
? prev.filter((id) => id !== roundId)
: [...prev, roundId]
)
}
if (programsLoading) return <Skeleton className="h-[400px]" />
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Select Rounds to Compare</CardTitle>
<CardDescription>Choose at least 2 rounds</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{rounds.map((round) => (
<Badge
key={round.id}
variant={selectedRoundIds.includes(round.id) ? 'default' : 'outline'}
className="cursor-pointer text-sm py-1.5 px-3"
onClick={() => toggleRound(round.id)}
>
{round.programName} - {round.name}
</Badge>
))}
</div>
{selectedRoundIds.length < 2 && (
<p className="text-sm text-muted-foreground mt-3">
Select at least 2 rounds to enable comparison
</p>
)}
</CardContent>
</Card>
{comparisonLoading && selectedRoundIds.length >= 2 && <Skeleton className="h-[350px]" />}
{comparison && (
<CrossRoundComparisonChart data={comparison as Array<{
roundId: string; roundName: string; projectCount: number; evaluationCount: number
completionRate: number; averageScore: number | null
scoreDistribution: { score: number; count: number }[]
}>} />
)}
</div>
)
}
function JurorConsistencyTab({ selectedRoundId }: { selectedRoundId: string }) {
const { data: consistency, isLoading } =
trpc.analytics.getJurorConsistency.useQuery(
{ roundId: selectedRoundId },
{ enabled: !!selectedRoundId }
)
if (isLoading) return <Skeleton className="h-[400px]" />
if (!consistency) return null
return (
<JurorConsistencyChart
data={consistency as {
overallAverage: number
jurors: Array<{
userId: string; name: string; email: string
evaluationCount: number; averageScore: number
stddev: number; deviationFromOverall: number; isOutlier: boolean
}>
}}
/>
)
}
function DiversityTab({ selectedRoundId }: { selectedRoundId: string }) {
const { data: diversity, isLoading } =
trpc.analytics.getDiversityMetrics.useQuery(
{ roundId: selectedRoundId },
{ enabled: !!selectedRoundId }
)
if (isLoading) return <Skeleton className="h-[400px]" />
if (!diversity) return null
return (
<DiversityMetricsChart
data={diversity as {
total: number
byCountry: { country: string; count: number; percentage: number }[]
byCategory: { category: string; count: number; percentage: number }[]
byOceanIssue: { issue: string; count: number; percentage: number }[]
byTag: { tag: string; count: number; percentage: number }[]
}}
/>
)
}
export default function ObserverReportsPage() {
const [selectedRoundId, setSelectedRoundId] = useState<string | null>(null)
@@ -462,16 +585,38 @@ export default function ObserverReportsPage() {
{/* Tabs */}
<Tabs defaultValue="overview" className="space-y-6">
<TabsList>
<TabsTrigger value="overview" className="gap-2">
<FileSpreadsheet className="h-4 w-4" />
Overview
</TabsTrigger>
<TabsTrigger value="analytics" className="gap-2" disabled={!selectedRoundId}>
<TrendingUp className="h-4 w-4" />
Analytics
</TabsTrigger>
</TabsList>
<div className="flex items-center justify-between flex-wrap gap-4">
<TabsList>
<TabsTrigger value="overview" className="gap-2">
<FileSpreadsheet className="h-4 w-4" />
Overview
</TabsTrigger>
<TabsTrigger value="analytics" className="gap-2" disabled={!selectedRoundId}>
<TrendingUp className="h-4 w-4" />
Analytics
</TabsTrigger>
<TabsTrigger value="cross-round" className="gap-2">
<GitCompare className="h-4 w-4" />
Cross-Round
</TabsTrigger>
<TabsTrigger value="consistency" className="gap-2" disabled={!selectedRoundId}>
<UserCheck className="h-4 w-4" />
Juror Consistency
</TabsTrigger>
<TabsTrigger value="diversity" className="gap-2" disabled={!selectedRoundId}>
<Globe className="h-4 w-4" />
Diversity
</TabsTrigger>
</TabsList>
<Button
variant="outline"
size="sm"
onClick={() => window.print()}
>
<Printer className="mr-2 h-4 w-4" />
Export PDF
</Button>
</div>
<TabsContent value="overview">
<OverviewTab selectedRoundId={selectedRoundId} />
@@ -492,6 +637,42 @@ export default function ObserverReportsPage() {
</Card>
)}
</TabsContent>
<TabsContent value="cross-round">
<CrossRoundTab />
</TabsContent>
<TabsContent value="consistency">
{selectedRoundId ? (
<JurorConsistencyTab selectedRoundId={selectedRoundId} />
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<UserCheck className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">Select a round</p>
<p className="text-sm text-muted-foreground">
Choose a round above to view juror consistency metrics
</p>
</CardContent>
</Card>
)}
</TabsContent>
<TabsContent value="diversity">
{selectedRoundId ? (
<DiversityTab selectedRoundId={selectedRoundId} />
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Globe className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">Select a round</p>
<p className="text-sm text-muted-foreground">
Choose a round above to view diversity metrics
</p>
</CardContent>
</Card>
)}
</TabsContent>
</Tabs>
</div>
)