Add round-type-specific observer reports with dynamic tabs
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m44s

Refactor the observer reports page from a static 3-tab layout to a
dynamic tab system that adapts to each round type (INTAKE, FILTERING,
EVALUATION, SUBMISSION, MENTORING, LIVE_FINAL, DELIBERATION). Adds a
persistent Global tab for edition-wide analytics, juror score heatmap,
expandable juror assignment rows, filtering screening bar, and
deliberation results with tie detection.

- Add 5 observer proxy procedures to analytics router
- Create JurorScoreHeatmap, ExpandableJurorTable, FilteringScreeningBar
- Create 8 round-type tab components + GlobalAnalyticsTab
- Reduce reports page from 914 to ~190 lines (thin dispatcher)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-21 09:29:26 +01:00
parent ee3bfec8b0
commit 2e4b95f29c
14 changed files with 2326 additions and 786 deletions

View File

@@ -10,3 +10,4 @@ export { GeographicSummaryCard } from './geographic-summary-card'
export { CrossStageComparisonChart } from './cross-round-comparison'
export { JurorConsistencyChart } from './juror-consistency'
export { DiversityMetricsChart } from './diversity-metrics'
export { JurorScoreHeatmap } from './juror-score-heatmap'

View File

@@ -0,0 +1,116 @@
'use client'
import { Fragment } from 'react'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { scoreGradient } from './chart-theme'
interface JurorScoreHeatmapProps {
jurors: { id: string; name: string }[]
projects: { id: string; title: string }[]
cells: { jurorId: string; projectId: string; score: number | null }[]
truncated?: boolean
totalProjects?: number
}
function getScoreColor(score: number | null): string {
if (score === null) return 'transparent'
return scoreGradient(score)
}
function getTextColor(score: number | null): string {
if (score === null) return 'inherit'
return score >= 6 ? '#ffffff' : '#1a1a1a'
}
export function JurorScoreHeatmap({
jurors,
projects,
cells,
truncated,
totalProjects,
}: JurorScoreHeatmapProps) {
const cellMap = new Map<string, number | null>()
for (const c of cells) {
cellMap.set(`${c.jurorId}:${c.projectId}`, c.score)
}
if (jurors.length === 0 || projects.length === 0) {
return (
<Card>
<CardContent className="flex items-center justify-center py-12">
<p className="text-sm text-muted-foreground">No score data available for heatmap</p>
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader>
<CardTitle>Score Heatmap</CardTitle>
<CardDescription>
{jurors.length} jurors × {projects.length} projects
{truncated && totalProjects ? ` (showing top 30 of ${totalProjects})` : ''}
</CardDescription>
</CardHeader>
<CardContent>
{/* Mobile fallback */}
<div className="md:hidden text-center py-8">
<p className="text-sm text-muted-foreground">
View on a larger screen for the score heatmap
</p>
</div>
{/* Desktop heatmap */}
<div className="hidden md:block overflow-auto max-h-[600px]">
<div
className="grid gap-px"
style={{
gridTemplateColumns: `180px repeat(${projects.length}, minmax(60px, 1fr))`,
}}
>
{/* Header row */}
<div className="sticky left-0 z-10 bg-background" />
{projects.map((p) => (
<div
key={p.id}
className="text-xs font-medium text-muted-foreground truncate px-1 pb-2 text-center"
title={p.title}
>
{p.title.length > 12 ? p.title.slice(0, 10) + '…' : p.title}
</div>
))}
{/* Data rows */}
{jurors.map((j) => (
<Fragment key={j.id}>
<div
className="sticky left-0 z-10 bg-background text-sm font-medium truncate pr-3 flex items-center"
title={j.name}
>
{j.name}
</div>
{projects.map((p) => {
const score = cellMap.get(`${j.id}:${p.id}`) ?? null
return (
<div
key={`${j.id}-${p.id}`}
className="flex items-center justify-center rounded-sm text-xs font-medium tabular-nums h-8"
style={{
backgroundColor: getScoreColor(score),
color: getTextColor(score),
}}
title={`${j.name}${p.title}: ${score !== null ? score.toFixed(1) : 'N/A'}`}
>
{score !== null ? score.toFixed(1) : '—'}
</div>
)
})}
</Fragment>
))}
</div>
</div>
</CardContent>
</Card>
)
}