From 161cd1684ae8d7ea77677fb67f7168eaf0084e0e Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 21 Feb 2026 10:12:21 +0100 Subject: [PATCH] Fix observer reports: charts, filtering, project preview, dashboard stats - Rewrite diversity metrics: horizontal bar charts for ocean issues and geographic distribution (replaces unreadable vertical/donut charts) - Rewrite juror score heatmap: expandable table with score distribution - Rewrite juror consistency: horizontal bar visual with juror names - Merge filtering tabs into single screening view with per-project AI reasoning and expandable rows - Add project preview dialog for juror performance table - Fix status breakdown for evaluation rounds (Fully/Partially/Not Reviewed) - Show active round name instead of count on observer dashboard - Move Global tab to last position, default to first round-specific tab - Add 4-card stats layout for evaluation with reviews/project ratio - Fix oceanIssue field (singular) and remove non-existent aiSummary Co-Authored-By: Claude Opus 4.6 --- src/app/(observer)/observer/reports/page.tsx | 10 +- src/components/charts/chart-theme.ts | 14 +- src/components/charts/diversity-metrics.tsx | 154 ++++++----- src/components/charts/juror-consistency.tsx | 208 +++++++++------ src/components/charts/juror-score-heatmap.tsx | 246 +++++++++++++----- .../observer/observer-dashboard-content.tsx | 6 +- .../reports/evaluation-report-tabs.tsx | 40 ++- .../reports/expandable-juror-table.tsx | 67 +++-- .../reports/filtering-report-tabs.tsx | 232 ++++++++++++----- .../observer/reports/global-analytics-tab.tsx | 38 +-- .../reports/project-preview-dialog.tsx | 183 +++++++++++++ src/server/routers/analytics.ts | 84 +++++- 12 files changed, 948 insertions(+), 334 deletions(-) create mode 100644 src/components/observer/reports/project-preview-dialog.tsx diff --git a/src/app/(observer)/observer/reports/page.tsx b/src/app/(observer)/observer/reports/page.tsx index 26c8f00..a9d4a1b 100644 --- a/src/app/(observer)/observer/reports/page.tsx +++ b/src/app/(observer)/observer/reports/page.tsx @@ -129,7 +129,7 @@ function ReportsPageContent() { const searchParams = useSearchParams() const roundFromUrl = searchParams.get('round') const [selectedValue, setSelectedValue] = useState(roundFromUrl) - const [activeTab, setActiveTab] = useState('global') + const [activeTab, setActiveTab] = useState(null) const { data: programs, isLoading: stagesLoading } = trpc.program.list.useQuery({ includeStages: true }) @@ -150,9 +150,9 @@ function ReportsPageContent() { } }, [stages.length, selectedValue]) - // Reset to global tab when round selection changes + // Reset to first round-specific tab when round selection changes useEffect(() => { - setActiveTab('global') + setActiveTab(null) }, [selectedValue]) const isAllRounds = selectedValue?.startsWith('all:') @@ -167,8 +167,8 @@ function ReportsPageContent() { : getRoundTabs(roundType) const allTabs: TabDef[] = [ - { value: 'global', label: 'Global', icon: Globe }, ...roundSpecificTabs, + { value: 'global', label: 'Global', icon: Globe }, ] return ( @@ -206,7 +206,7 @@ function ReportsPageContent() { {selectedValue && ( - + {allTabs.map((tab) => ( diff --git a/src/components/charts/chart-theme.ts b/src/components/charts/chart-theme.ts index 456dbc2..dce436f 100644 --- a/src/components/charts/chart-theme.ts +++ b/src/components/charts/chart-theme.ts @@ -52,6 +52,10 @@ export const TREMOR_STATUS_COLORS: Record = { IN_PROGRESS: 'blue', PASSED: 'emerald', COMPLETED: 'indigo', + // Evaluation review states + FULLY_REVIEWED: 'emerald', + PARTIALLY_REVIEWED: 'amber', + NOT_REVIEWED: 'rose', } // Project status colors — mapped to actual ProjectStatus enum values @@ -64,12 +68,16 @@ export const STATUS_COLORS: Record = { REJECTED: '#de0f1e', // Red DRAFT: '#9ca3af', // Gray WITHDRAWN: '#6b7280', // Dark Gray + // Evaluation review states + FULLY_REVIEWED: '#2d8659', // Sea Green + PARTIALLY_REVIEWED: '#d97706', // Amber + NOT_REVIEWED: '#de0f1e', // Red } // Human-readable status labels export const STATUS_LABELS: Record = { SUBMITTED: 'Submitted', - ELIGIBLE: 'Eligible', + ELIGIBLE: 'In-Competition', ASSIGNED: 'Special Award', SEMIFINALIST: 'Semi-finalist', FINALIST: 'Finalist', @@ -81,6 +89,10 @@ export const STATUS_LABELS: Record = { IN_PROGRESS: 'In Progress', PASSED: 'Passed', COMPLETED: 'Completed', + // Evaluation review states + FULLY_REVIEWED: 'Fully Reviewed', + PARTIALLY_REVIEWED: 'Partially Reviewed', + NOT_REVIEWED: 'Not Reviewed', } /** diff --git a/src/components/charts/diversity-metrics.tsx b/src/components/charts/diversity-metrics.tsx index f346211..067fb1c 100644 --- a/src/components/charts/diversity-metrics.tsx +++ b/src/components/charts/diversity-metrics.tsx @@ -1,9 +1,8 @@ 'use client' -import { DonutChart, BarChart } from '@tremor/react' +import { BarChart } from '@tremor/react' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' -import { TREMOR_CHART_COLORS } from './chart-theme' interface DiversityData { total: number @@ -48,76 +47,69 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) { ) } - // Top countries for donut chart (max 10, others grouped) - const topCountries = (data.byCountry || []).slice(0, 10) - const otherCountries = (data.byCountry || []).slice(10) - const countryData = otherCountries.length > 0 - ? [...topCountries, { - country: 'Others', - count: otherCountries.reduce((sum, c) => sum + c.count, 0), - percentage: otherCountries.reduce((sum, c) => sum + c.percentage, 0), - }] - : topCountries - - const donutData = countryData.map((c) => ({ - name: getCountryName(c.country), - value: c.count, + // Top countries — horizontal bar chart for readability + const countryBarData = (data.byCountry || []).slice(0, 15).map((c) => ({ + country: getCountryName(c.country), + Projects: c.count, })) const categoryData = (data.byCategory || []).slice(0, 10).map((c) => ({ category: formatLabel(c.category), - Count: c.count, + Projects: c.count, })) const oceanIssueData = (data.byOceanIssue || []).slice(0, 15).map((o) => ({ issue: formatLabel(o.issue), - Count: o.count, + Projects: o.count, })) return (
- {/* Summary */} -
+ {/* Summary stats row */} +
- -
{data.total}
-

Total Projects

+ +

{data.total}

+

Total Projects

- -
{(data.byCountry || []).length}
-

Countries Represented

+ +

{(data.byCountry || []).length}

+

Countries

- -
{(data.byCategory || []).length}
-

Categories

+ +

{(data.byCategory || []).length}

+

Categories

- -
{(data.byTag || []).length}
-

Unique Tags

+ +

{(data.byOceanIssue || []).length}

+

Ocean Issues

- {/* Country Distribution */} + {/* Country Distribution — horizontal bars */} - - Geographic Distribution + + Geographic Distribution - {donutData.length > 0 ? ( - 0 ? ( + ) : (

No geographic data

@@ -125,23 +117,47 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
- {/* Category Distribution */} + {/* Competition Categories — horizontal bars */} - - Competition Categories + + Competition Categories {categoryData.length > 0 ? ( - + categoryData.length <= 4 ? ( + /* Clean stacked bars for few categories */ +
+ {categoryData.map((c) => { + const maxCount = Math.max(...categoryData.map((d) => d.Projects)) + const pct = maxCount > 0 ? (c.Projects / maxCount) * 100 : 0 + return ( +
+
+ {c.category} + {c.Projects} +
+
+
+
+
+ ) + })} +
+ ) : ( + + ) ) : (

No category data

)} @@ -149,45 +165,43 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
- {/* Ocean Issues */} + {/* Ocean Issues — horizontal bars for readability */} {oceanIssueData.length > 0 && ( - - Ocean Issues Addressed + + Ocean Issues Addressed )} - {/* Tags Cloud */} + {/* Tags — clean pill cloud */} {(data.byTag || []).length > 0 && ( - - Project Tags + + Project Tags
{(data.byTag || []).slice(0, 30).map((tag) => ( - {tag.tag} ({tag.count}) + {tag.tag} + ({tag.count}) ))}
diff --git a/src/components/charts/juror-consistency.tsx b/src/components/charts/juror-consistency.tsx index 064e5f1..378b319 100644 --- a/src/components/charts/juror-consistency.tsx +++ b/src/components/charts/juror-consistency.tsx @@ -1,6 +1,5 @@ 'use client' -import { ScatterChart } from '@tremor/react' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import { @@ -12,6 +11,7 @@ import { TableRow, } from '@/components/ui/table' import { AlertTriangle } from 'lucide-react' +import { scoreGradient } from './chart-theme' interface JurorMetric { userId: string @@ -30,6 +30,24 @@ interface JurorConsistencyProps { } } +function ScoreDot({ score, maxScore = 10 }: { score: number; maxScore?: number }) { + const pct = ((score / maxScore) * 100).toFixed(1) + return ( +
+
+
+
+ {score.toFixed(1)} +
+ ) +} + export function JurorConsistencyChart({ data }: JurorConsistencyProps) { if (!data?.jurors?.length) { return ( @@ -42,27 +60,19 @@ export function JurorConsistencyChart({ data }: JurorConsistencyProps) { } const outlierCount = data.jurors.filter((j) => j.isOutlier).length - - const scatterData = data.jurors.map((j) => ({ - 'Average Score': parseFloat(j.averageScore.toFixed(2)), - 'Std Deviation': parseFloat(j.stddev.toFixed(2)), - category: j.isOutlier ? 'Outlier' : 'Normal', - name: j.name, - evaluations: j.evaluationCount, - size: Math.max(8, Math.min(20, j.evaluationCount * 2)), - })) + const sorted = [...data.jurors].sort((a, b) => b.averageScore - a.averageScore) return (
- {/* Scatter: Average Score vs Standard Deviation */} + {/* Juror Scoring Patterns — bar-based visual instead of scatter */} - - Juror Scoring Patterns - + + Juror Scoring Patterns + Overall Avg: {data.overallAverage.toFixed(2)} {outlierCount > 0 && ( - + {outlierCount} outlier{outlierCount > 1 ? 's' : ''} )} @@ -70,18 +80,31 @@ export function JurorConsistencyChart({ data }: JurorConsistencyProps) { - -

- Dot size represents number of evaluations. Red dots indicate outlier - jurors (2+ points from mean). +

+ {sorted.map((juror) => ( +
+
+ {juror.name} +
+
+ +
+
+ σ {juror.stddev.toFixed(1)} + {juror.evaluationCount} eval{juror.evaluationCount !== 1 ? 's' : ''} +
+ {juror.isOutlier && ( + + )} +
+ ))} +
+ {/* Overall average line */} +

+ Bars show average score per juror. σ = standard deviation. Outliers deviate 2+ points from the overall mean.

@@ -89,57 +112,92 @@ export function JurorConsistencyChart({ data }: JurorConsistencyProps) { {/* Juror details table */} - Juror Consistency Details + Juror Consistency Details - - - - Juror - Evaluations - Avg Score - Std Dev - - Deviation from Mean - - Status - - - - {data.jurors.map((juror) => ( - - -

{juror.name}

-
- - {juror.evaluationCount} - - - {juror.averageScore.toFixed(2)} - - - {juror.stddev.toFixed(2)} - - - {juror.deviationFromOverall.toFixed(2)} - - - {juror.isOutlier ? ( - - - Outlier - - ) : ( - Normal - )} - + {/* Desktop table */} +
+
+ + + Juror + Evaluations + Avg Score + Std Dev + Deviation + Status - ))} - -
+ + + {sorted.map((juror) => ( + + {juror.name} + + {juror.evaluationCount} + + + {juror.averageScore.toFixed(2)} + + + {juror.stddev.toFixed(2)} + + + {juror.deviationFromOverall >= 0 ? '+' : ''}{juror.deviationFromOverall.toFixed(2)} + + + {juror.isOutlier ? ( + + + Outlier + + ) : ( + Normal + )} + + + ))} + + +
+ + {/* Mobile card stack */} +
+ {sorted.map((juror) => ( +
+
+ {juror.name} + {juror.isOutlier ? ( + + + Outlier + + ) : ( + Normal + )} +
+
+
+

Avg Score

+

{juror.averageScore.toFixed(2)}

+
+
+

Std Dev

+

{juror.stddev.toFixed(2)}

+
+
+

Evals

+

{juror.evaluationCount}

+
+
+
+ ))} +
diff --git a/src/components/charts/juror-score-heatmap.tsx b/src/components/charts/juror-score-heatmap.tsx index 4b6e4f5..2947571 100644 --- a/src/components/charts/juror-score-heatmap.tsx +++ b/src/components/charts/juror-score-heatmap.tsx @@ -1,7 +1,8 @@ 'use client' -import { Fragment } from 'react' +import { Fragment, useState } from 'react' import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' import { scoreGradient } from './chart-theme' interface JurorScoreHeatmapProps { @@ -22,6 +23,121 @@ function getTextColor(score: number | null): string { return score >= 6 ? '#ffffff' : '#1a1a1a' } +function ScoreBadge({ score }: { score: number }) { + return ( + + {score.toFixed(1)} + + ) +} + +function JurorSummaryRow({ + juror, + scores, + averageScore, + projectCount, + isExpanded, + onToggle, + projects, +}: { + juror: { id: string; name: string } + scores: { projectId: string; score: number | null }[] + averageScore: number | null + projectCount: number + isExpanded: boolean + onToggle: () => void + projects: { id: string; title: string }[] +}) { + const scored = scores.filter((s) => s.score !== null) + const unscored = projectCount - scored.length + + return ( + <> + + +
+ + › + + {juror.name} +
+ + + {scored.length} + /{projectCount} + + + {averageScore !== null ? ( + + ) : ( + + )} + + + {/* Mini score bar */} +
+ {scored + .sort((a, b) => (a.score ?? 0) - (b.score ?? 0)) + .map((s, i) => ( +
+ ))} + {unscored > 0 && + Array.from({ length: Math.min(unscored, 10) }).map((_, i) => ( +
+ ))} +
+ + + {isExpanded && ( + + +
+ {projects.map((p) => { + const cell = scores.find((s) => s.projectId === p.id) + const score = cell?.score ?? null + return ( +
+ {score !== null ? ( + + ) : ( + + — + + )} + + {p.title} + +
+ ) + })} +
+ + + )} + + ) +} + export function JurorScoreHeatmap({ jurors, projects, @@ -29,6 +145,8 @@ export function JurorScoreHeatmap({ truncated, totalProjects, }: JurorScoreHeatmapProps) { + const [expandedId, setExpandedId] = useState(null) + const cellMap = new Map() for (const c of cells) { cellMap.set(`${c.jurorId}:${c.projectId}`, c.score) @@ -44,71 +162,77 @@ export function JurorScoreHeatmap({ ) } + // Compute per-juror data + const jurorData = jurors.map((j) => { + const scores = projects.map((p) => ({ + projectId: p.id, + score: cellMap.get(`${j.id}:${p.id}`) ?? null, + })) + const scored = scores.filter((s) => s.score !== null) + const avg = scored.length > 0 + ? scored.reduce((sum, s) => sum + (s.score ?? 0), 0) / scored.length + : null + return { juror: j, scores, averageScore: avg ? parseFloat(avg.toFixed(1)) : null, scoredCount: scored.length } + }) + + // Sort: jurors with most evaluations first + jurorData.sort((a, b) => b.scoredCount - a.scoredCount) + + // Color legend + const legendScores = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + return ( - Score Heatmap - - {jurors.length} jurors × {projects.length} projects - {truncated && totalProjects ? ` (showing top 30 of ${totalProjects})` : ''} - +
+
+ Score Heatmap + + {jurors.length} juror{jurors.length !== 1 ? 's' : ''} · {projects.length} project{projects.length !== 1 ? 's' : ''} + {truncated && totalProjects ? ` (top ${projects.length} of ${totalProjects})` : ''} + +
+ {/* Color legend */} +
+ Low + {legendScores.map((s) => ( +
+ ))} + High +
+
- {/* Mobile fallback */} -
-

- View on a larger screen for the score heatmap -

-
- - {/* Desktop heatmap */} -
-
- {/* Header row */} -
- {projects.map((p) => ( -
- {p.title.length > 12 ? p.title.slice(0, 10) + '…' : p.title} -
- ))} - - {/* Data rows */} - {jurors.map((j) => ( - -
- {j.name} -
- {projects.map((p) => { - const score = cellMap.get(`${j.id}:${p.id}`) ?? null - return ( -
- {score !== null ? score.toFixed(1) : '—'} -
- ) - })} -
- ))} -
+
+ + + + + + + + + + + {jurorData.map(({ juror, scores, averageScore }) => ( + setExpandedId(expandedId === juror.id ? null : juror.id)} + projects={projects} + /> + ))} + +
JurorReviewedAvgScore Distribution
diff --git a/src/components/observer/observer-dashboard-content.tsx b/src/components/observer/observer-dashboard-content.tsx index 3b7273b..7f99e68 100644 --- a/src/components/observer/observer-dashboard-content.tsx +++ b/src/components/observer/observer-dashboard-content.tsx @@ -210,14 +210,16 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
{[ { value: stats.projectCount, label: 'Projects' }, - { value: stats.activeRoundCount, label: 'Active Rounds' }, + { value: stats.activeRoundName ?? `${stats.activeRoundCount} Active`, label: 'Active Round', isText: !!stats.activeRoundName }, { value: avgScore, label: 'Avg Score' }, { value: `${stats.completionRate}%`, label: 'Completion' }, { value: stats.jurorCount, label: 'Jurors' }, { value: countryCount, label: 'Countries' }, ].map((stat) => (
-

{stat.value}

+

{stat.value}

{stat.label}

))} diff --git a/src/components/observer/reports/evaluation-report-tabs.tsx b/src/components/observer/reports/evaluation-report-tabs.tsx index 69fd879..cdea5c7 100644 --- a/src/components/observer/reports/evaluation-report-tabs.tsx +++ b/src/components/observer/reports/evaluation-report-tabs.tsx @@ -23,6 +23,7 @@ import { TrendingUp, Download, Clock, + ClipboardCheck, } from 'lucide-react' import { formatDateOnly } from '@/lib/utils' import { @@ -190,15 +191,15 @@ function ProgressSubTab({ ))}
) : overviewStats ? ( -
+
-

Project Count

+

Projects

{overviewStats.projectCount}

-

In selection

+

In round

@@ -209,13 +210,38 @@ function ProgressSubTab({ + + +
+
+

Assignments

+

{overviewStats.assignmentCount}

+

+ {overviewStats.projectCount > 0 + ? `${(overviewStats.assignmentCount / overviewStats.projectCount).toFixed(1)} reviews/project` + : 'No projects'} +

+
+
+ +
+
+
+
+
+ +
-

Evaluation Count

+

Evaluations

{overviewStats.evaluationCount}

-

Submitted

+

+ {overviewStats.assignmentCount > 0 + ? `${overviewStats.evaluationCount}/${overviewStats.assignmentCount} submitted` + : 'Submitted'} +

@@ -225,13 +251,13 @@ function ProgressSubTab({ - +
-

Completion Rate

+

Completion

{overviewStats.completionRate}%

diff --git a/src/components/observer/reports/expandable-juror-table.tsx b/src/components/observer/reports/expandable-juror-table.tsx index 279098e..f77b15b 100644 --- a/src/components/observer/reports/expandable-juror-table.tsx +++ b/src/components/observer/reports/expandable-juror-table.tsx @@ -13,7 +13,8 @@ import { TableRow, } from '@/components/ui/table' import { ChevronDown, ChevronUp } from 'lucide-react' -import Link from 'next/link' +import { scoreGradient } from '@/components/charts/chart-theme' +import { ProjectPreviewDialog } from './project-preview-dialog' interface JurorRow { userId: string @@ -24,7 +25,7 @@ interface JurorRow { averageScore: number stddev: number isOutlier: boolean - projects: { id: string; title: string; evalStatus: string }[] + projects: { id: string; title: string; evalStatus: string; score?: number | null }[] } interface ExpandableJurorTableProps { @@ -42,13 +43,32 @@ function evalStatusBadge(status: string) { } } +function ScorePill({ score }: { score: number }) { + const bg = scoreGradient(score) + const text = score >= 6 ? '#ffffff' : '#1a1a1a' + return ( + + {score.toFixed(1)} + + ) +} + export function ExpandableJurorTable({ jurors }: ExpandableJurorTableProps) { const [expanded, setExpanded] = useState(null) + const [previewProjectId, setPreviewProjectId] = useState(null) function toggle(userId: string) { setExpanded((prev) => (prev === userId ? null : userId)) } + function openPreview(projectId: string, e: React.MouseEvent) { + e.stopPropagation() + setPreviewProjectId(projectId) + } + if (jurors.length === 0) { return ( @@ -127,22 +147,29 @@ export function ExpandableJurorTable({ jurors }: ExpandableJurorTableProps) { Project - Evaluation Status + Score + Status {j.projects.map((p) => ( - - e.stopPropagation()} + + - {evalStatusBadge(p.evalStatus)} + + {p.score != null ? ( + + ) : ( + + )} + + {evalStatusBadge(p.evalStatus)} ))} @@ -209,13 +236,16 @@ export function ExpandableJurorTable({ jurors }: ExpandableJurorTableProps) {
{j.projects.map((p) => (
- openPreview(p.id, e)} > {p.title} - - {evalStatusBadge(p.evalStatus)} + +
+ {p.score != null && } + {evalStatusBadge(p.evalStatus)} +
))}
@@ -227,6 +257,13 @@ export function ExpandableJurorTable({ jurors }: ExpandableJurorTableProps) {
))}
+ + {/* Project Preview Dialog */} + { if (!open) setPreviewProjectId(null) }} + /> ) } diff --git a/src/components/observer/reports/filtering-report-tabs.tsx b/src/components/observer/reports/filtering-report-tabs.tsx index ae41a32..dd1139b 100644 --- a/src/components/observer/reports/filtering-report-tabs.tsx +++ b/src/components/observer/reports/filtering-report-tabs.tsx @@ -21,10 +21,10 @@ import { SelectValue, } from '@/components/ui/select' import { Button } from '@/components/ui/button' -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' -import { ChevronLeft, ChevronRight } from 'lucide-react' +import { ChevronLeft, ChevronRight, ChevronDown, ChevronUp } from 'lucide-react' import { RoundTypeStatsCards } from '@/components/observer/round-type-stats' import { FilteringScreeningBar } from './filtering-screening-bar' +import { ProjectPreviewDialog } from './project-preview-dialog' interface FilteringReportTabsProps { roundId: string @@ -46,9 +46,30 @@ function outcomeBadge(outcome: string) { } } -function ProjectsTab({ roundId }: { roundId: string }) { +/** Extract reasoning text from aiScreeningJson */ +function extractReasoning(aiScreeningJson: unknown): string | null { + if (!aiScreeningJson || typeof aiScreeningJson !== 'object' || Array.isArray(aiScreeningJson)) { + return null + } + const obj = aiScreeningJson as Record + // Direct reasoning field + if (typeof obj.reasoning === 'string') return obj.reasoning + // Nested under rule ID: { [ruleId]: { reasoning, confidence, ... } } + for (const key of Object.keys(obj)) { + const inner = obj[key] + if (inner && typeof inner === 'object' && !Array.isArray(inner)) { + const innerObj = inner as Record + if (typeof innerObj.reasoning === 'string') return innerObj.reasoning + } + } + return null +} + +export function FilteringReportTabs({ roundId }: FilteringReportTabsProps) { const [outcomeFilter, setOutcomeFilter] = useState('ALL') const [page, setPage] = useState(1) + const [expandedId, setExpandedId] = useState(null) + const [previewProjectId, setPreviewProjectId] = useState(null) const perPage = 20 const { data, isLoading } = trpc.analytics.getFilteringResults.useQuery({ @@ -63,8 +84,21 @@ function ProjectsTab({ roundId }: { roundId: string }) { setPage(1) } + function toggleExpand(id: string) { + setExpandedId((prev) => (prev === id ? null : id)) + } + + function openPreview(projectId: string, e: React.MouseEvent) { + e.stopPropagation() + setPreviewProjectId(projectId) + } + return ( -
+
+ + + + {/* Filter + count */}
{data && (

- {data.total} result{data.total !== 1 ? 's' : ''} + {data.total} project{data.total !== 1 ? 's' : ''}

)}
@@ -93,35 +127,85 @@ function ProjectsTab({ roundId }: { roundId: string }) { - Project Title + + Project Team Category Country Outcome - Award Routing {data.results.map((r) => { const effectiveOutcome = r.finalOutcome ?? r.outcome - const awardRouting = r.project.awardEligibilities - .map((ae) => ae.award.name) - .join(', ') + const reasoning = extractReasoning(r.aiScreeningJson) + const isExpanded = expandedId === r.id return ( - - {r.project.title} - {r.project.teamName} - - {r.project.competitionCategory ?? '—'} - - - {r.project.country ?? '—'} - - {outcomeBadge(effectiveOutcome)} - - {awardRouting || '—'} - - + <> + toggleExpand(r.id)} + > + + {isExpanded ? ( + + ) : ( + + )} + + + + + {r.project.teamName} + + {r.project.competitionCategory ?? '—'} + + + {r.project.country ?? '—'} + + {outcomeBadge(effectiveOutcome)} + + {isExpanded && ( + + +
+ {reasoning ? ( +
+

AI Reasoning

+

{reasoning}

+
+ ) : ( +

No AI reasoning available

+ )} + {r.overrideReason && ( +
+

Override Reason

+

+ {r.overrideReason} +

+
+ )} + {r.project.awardEligibilities.length > 0 && ( +
+

Award Routing

+
+ {r.project.awardEligibilities.map((ae, i) => ( + {ae.award.name} + ))} +
+
+ )} +
+
+
+ )} + ) })}
@@ -132,23 +216,59 @@ function ProjectsTab({ roundId }: { roundId: string }) {
{data.results.map((r) => { const effectiveOutcome = r.finalOutcome ?? r.outcome - const awardRouting = r.project.awardEligibilities - .map((ae) => ae.award.name) - .join(', ') + const reasoning = extractReasoning(r.aiScreeningJson) + const isExpanded = expandedId === r.id return ( - -
-

{r.project.title}

- {outcomeBadge(effectiveOutcome)} -
-

{r.project.teamName}

-
- {r.project.competitionCategory && {r.project.competitionCategory}} - {r.project.country && {r.project.country}} -
- {awardRouting && ( -

Award: {awardRouting}

+ + +

{r.project.teamName}

+
+
+ {outcomeBadge(effectiveOutcome)} + {isExpanded ? ( + + ) : ( + + )} +
+ +
+ {r.project.competitionCategory && {r.project.competitionCategory}} + {r.project.country && {r.project.country}} +
+ + + {isExpanded && ( +
+ {reasoning ? ( +
+

AI Reasoning

+

{reasoning}

+
+ ) : ( +

No AI reasoning available

+ )} + {r.overrideReason && ( +
+

Override Reason

+

+ {r.overrideReason} +

+
+ )} +
)} @@ -198,34 +318,12 @@ function ProjectsTab({ roundId }: { roundId: string }) { )} + + { if (!open) setPreviewProjectId(null) }} + /> ) } - -function ScreeningResultsTab({ roundId }: { roundId: string }) { - return ( -
- - -
- ) -} - -export function FilteringReportTabs({ roundId }: FilteringReportTabsProps) { - return ( - - - Screening Results - Projects - - - - - - - - - - - ) -} diff --git a/src/components/observer/reports/global-analytics-tab.tsx b/src/components/observer/reports/global-analytics-tab.tsx index 2ad2e30..a19174f 100644 --- a/src/components/observer/reports/global-analytics-tab.tsx +++ b/src/components/observer/reports/global-analytics-tab.tsx @@ -32,14 +32,21 @@ export function GlobalAnalyticsTab({ programId, roundIds }: GlobalAnalyticsTabPr return (
- {/* Geographic Distribution */} - {geoLoading ? ( + {/* Diversity Metrics — includes summary cards, category breakdown, ocean issues, tags */} + {diversityLoading ? ( + ) : diversity ? ( + + ) : null} + + {/* Geographic Distribution — full-width map with top countries */} + {geoLoading ? ( + ) : geoData?.length ? ( ) : null} - {/* Status and Diversity side by side */} + {/* Project Status + Cross-Round Comparison */}
{statusLoading ? ( @@ -47,23 +54,16 @@ export function GlobalAnalyticsTab({ programId, roundIds }: GlobalAnalyticsTabPr ) : null} - {diversityLoading ? ( - - ) : diversity ? ( - - ) : null} + {roundIds && roundIds.length >= 2 && ( + <> + {crossLoading ? ( + + ) : crossRound ? ( + + ) : null} + + )}
- - {/* Cross-Round Comparison */} - {roundIds && roundIds.length >= 2 && ( - <> - {crossLoading ? ( - - ) : crossRound ? ( - - ) : null} - - )}
) } diff --git a/src/components/observer/reports/project-preview-dialog.tsx b/src/components/observer/reports/project-preview-dialog.tsx new file mode 100644 index 0000000..4331e42 --- /dev/null +++ b/src/components/observer/reports/project-preview-dialog.tsx @@ -0,0 +1,183 @@ +'use client' + +import { trpc } from '@/lib/trpc/client' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' +import { Separator } from '@/components/ui/separator' +import { StatusBadge } from '@/components/shared/status-badge' +import { ExternalLink, MapPin, Waves, Users } from 'lucide-react' +import Link from 'next/link' +import type { Route } from 'next' +import { scoreGradient } from '@/components/charts/chart-theme' + +interface ProjectPreviewDialogProps { + projectId: string | null + open: boolean + onOpenChange: (open: boolean) => void +} + +function ScorePill({ score }: { score: number }) { + const bg = scoreGradient(score) + const text = score >= 6 ? '#ffffff' : '#1a1a1a' + return ( + + {score.toFixed(1)} + + ) +} + +export function ProjectPreviewDialog({ projectId, open, onOpenChange }: ProjectPreviewDialogProps) { + const { data, isLoading } = trpc.analytics.getProjectDetail.useQuery( + { id: projectId! }, + { enabled: !!projectId && open }, + ) + + return ( + + + {isLoading || !data ? ( + <> + + + +
+ + + +
+ + ) : ( + <> + + + {data.project.title} + + + +
+ {/* Project info row */} +
+ + {data.project.teamName && ( + + + {data.project.teamName} + + )} + {data.project.country && ( + + + {data.project.country} + + )} + {data.project.competitionCategory && ( + {data.project.competitionCategory} + )} +
+ + {/* Description */} + {data.project.description && ( +

+ {data.project.description} +

+ )} + + {/* Ocean Issue */} + {data.project.oceanIssue && ( + + + {data.project.oceanIssue.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, (c: string) => c.toUpperCase())} + + )} + + + + {/* Evaluation summary */} + {data.stats && ( +
+

Evaluation Summary

+
+
+

+ {data.stats.averageGlobalScore != null ? ( + + ) : '—'} +

+

Avg Score

+
+
+

{data.stats.totalEvaluations ?? 0}

+

Evaluations

+
+
+

{data.assignments?.length ?? 0}

+

Assignments

+
+
+

+ {data.stats.yesPercentage != null ? `${Math.round(data.stats.yesPercentage)}%` : '—'} +

+

Recommend

+
+
+
+ )} + + {/* Individual evaluations */} + {data.assignments?.length > 0 && ( +
+

Juror Evaluations

+
+ {data.assignments.map((a: { id: string; user: { name: string | null }; evaluation: { status: string; globalScore: unknown } | null }) => { + const ev = a.evaluation + const score = ev?.status === 'SUBMITTED' && ev.globalScore != null + ? Number(ev.globalScore) + : null + return ( +
+
+ {a.user.name ?? 'Unknown'} + {ev?.status === 'SUBMITTED' ? ( + Reviewed + ) : ev?.status === 'DRAFT' ? ( + Draft + ) : ( + Pending + )} +
+ {score !== null && } +
+ ) + })} +
+
+ )} + + + + {/* View full project button */} +
+ +
+
+ + )} +
+
+ ) +} diff --git a/src/server/routers/analytics.ts b/src/server/routers/analytics.ts index 14e4950..f5eb023 100644 --- a/src/server/routers/analytics.ts +++ b/src/server/routers/analytics.ts @@ -126,7 +126,7 @@ export const analyticsRouter = router({ user: { select: { name: true } }, project: { select: { id: true, title: true } }, evaluation: { - select: { id: true, status: true }, + select: { id: true, status: true, globalScore: true }, }, }, }) @@ -134,7 +134,7 @@ export const analyticsRouter = router({ // Group by user const byUser: Record< string, - { name: string; assigned: number; completed: number; projects: { id: string; title: string; evalStatus: string }[] } + { name: string; assigned: number; completed: number; projects: { id: string; title: string; evalStatus: string; score: number | null }[] } > = {} assignments.forEach((assignment) => { @@ -156,6 +156,9 @@ export const analyticsRouter = router({ id: assignment.project.id, title: assignment.project.title, evalStatus: evalStatus === 'SUBMITTED' ? 'REVIEWED' : evalStatus === 'DRAFT' ? 'UNDER_REVIEW' : 'NOT_REVIEWED', + score: evalStatus === 'SUBMITTED' && assignment.evaluation?.globalScore != null + ? Number(assignment.evaluation.globalScore) + : null, }) }) @@ -251,7 +254,59 @@ export const analyticsRouter = router({ .input(editionOrRoundInput) .query(async ({ ctx, input }) => { if (input.roundId) { - // Round-level: use ProjectRoundState for accurate per-round breakdown + // Check if this is an evaluation round — show eval-level status breakdown + const round = await ctx.prisma.round.findUnique({ + where: { id: input.roundId }, + select: { roundType: true }, + }) + + if (round?.roundType === 'EVALUATION') { + // For evaluation rounds, break down by evaluation status per project + const projects = await ctx.prisma.projectRoundState.findMany({ + where: { roundId: input.roundId }, + select: { + projectId: true, + project: { + select: { + assignments: { + where: { roundId: input.roundId }, + select: { + evaluation: { select: { status: true } }, + }, + }, + }, + }, + }, + }) + + let fullyReviewed = 0 + let partiallyReviewed = 0 + let notReviewed = 0 + + for (const p of projects) { + const assignments = p.project.assignments + if (assignments.length === 0) { + notReviewed++ + continue + } + const submitted = assignments.filter((a) => a.evaluation?.status === 'SUBMITTED').length + if (submitted === 0) { + notReviewed++ + } else if (submitted === assignments.length) { + fullyReviewed++ + } else { + partiallyReviewed++ + } + } + + const result = [] + if (fullyReviewed > 0) result.push({ status: 'FULLY_REVIEWED', count: fullyReviewed }) + if (partiallyReviewed > 0) result.push({ status: 'PARTIALLY_REVIEWED', count: partiallyReviewed }) + if (notReviewed > 0) result.push({ status: 'NOT_REVIEWED', count: notReviewed }) + return result + } + + // Non-evaluation rounds: use ProjectRoundState const states = await ctx.prisma.projectRoundState.groupBy({ by: ['state'], where: { roundId: input.roundId }, @@ -668,7 +723,7 @@ export const analyticsRouter = router({ const [ programCount, - activeRoundCount, + activeRounds, projectCount, jurorCount, submittedEvaluations, @@ -676,12 +731,11 @@ export const analyticsRouter = router({ evaluationScores, ] = await Promise.all([ ctx.prisma.program.count(), - roundId - ? ctx.prisma.round.findUnique({ where: { id: roundId }, select: { competitionId: true } }) - .then((r) => r?.competitionId - ? ctx.prisma.round.count({ where: { competitionId: r.competitionId, status: 'ROUND_ACTIVE' } }) - : ctx.prisma.round.count({ where: { status: 'ROUND_ACTIVE' } })) - : ctx.prisma.round.count({ where: { status: 'ROUND_ACTIVE' } }), + ctx.prisma.round.findMany({ + where: { status: 'ROUND_ACTIVE' }, + select: { id: true, name: true }, + take: 5, + }), ctx.prisma.project.count({ where: projectFilter }), roundId ? ctx.prisma.assignment.findMany({ @@ -716,7 +770,8 @@ export const analyticsRouter = router({ return { programCount, - activeRoundCount, + activeRoundCount: activeRounds.length, + activeRoundName: activeRounds.length === 1 ? activeRounds[0].name : null, projectCount, jurorCount, submittedEvaluations, @@ -1551,7 +1606,12 @@ export const analyticsRouter = router({ skip, take: perPage, orderBy: { createdAt: 'desc' }, - include: { + select: { + id: true, + outcome: true, + finalOutcome: true, + aiScreeningJson: true, + overrideReason: true, project: { select: { id: true,