Add round-type-specific observer reports with dynamic tabs
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m44s
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:
@@ -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'
|
||||
|
||||
116
src/components/charts/juror-score-heatmap.tsx
Normal file
116
src/components/charts/juror-score-heatmap.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user