feat: observer UX overhaul — reports, projects, charts, session & email
All checks were successful
Build and Push Docker Image / build (push) Successful in 11m2s

- Observer projects: default sort by status (rejected last), sortable status column
- Observer projects: search by country, institution, geographic zone
- Observer project detail: vertical timeline connectors between rounds
- Fix React key warning in ExpandableJurorTable and FilteringReportTabs
- Fix ScoreBadge text always white for better contrast on all backgrounds
- Remove misleading /30 denominator from heatmap juror reviewed count
- INTAKE stats: show Start-ups, Business Concepts, Countries (not States/Categories)
- DiversityMetrics: extractCountry() for country-only display in charts
- Fix nested button hydration error in filtering report mobile view
- Color project titles by outcome in filtering report (green/red/amber)
- Redesign CrossStageComparisonChart: funnel viz + metrics table with attrition %
- Center doughnut chart in StatusBreakdownChart
- Remove redundant RoundTypeStatsCards from evaluation report
- Move evaluation tab bar below overview header, rename to "Juror Assignments"
- Dev email override system (DEV_EMAIL_OVERRIDE env var)
- Session refresh on role change without re-login
- Role switcher in user dropdown menu
- formatCategory() utility for consistent category display
- Activity feed max height constraint

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 13:37:50 +01:00
parent e7b99fff63
commit a556732b46
23 changed files with 2108 additions and 326 deletions

View File

@@ -1,7 +1,18 @@
'use client'
import { BarChart } from '@tremor/react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { scoreGradient } from './chart-theme'
import { ArrowRight } from 'lucide-react'
interface StageComparison {
roundId: string
@@ -30,99 +41,115 @@ export function CrossStageComparisonChart({
)
}
const baseData = data.map((round) => ({
name: round.roundName,
Projects: round.projectCount,
Evaluations: round.evaluationCount,
'Completion Rate': round.completionRate,
'Avg Score': round.averageScore
? parseFloat(round.averageScore.toFixed(2))
: 0,
}))
const maxProjects = Math.max(...data.map((d) => d.projectCount), 1)
return (
<Card>
<CardHeader>
<CardTitle>Round Metrics Comparison</CardTitle>
<CardTitle className="text-base">Round Progression</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Projects</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<BarChart
data={baseData}
index="name"
categories={['Projects']}
colors={['blue']}
showLegend={false}
yAxisWidth={40}
className="h-[200px]"
/>
</CardContent>
</Card>
{/* Pipeline funnel visualization */}
<div className="flex items-center gap-2 mb-6 overflow-x-auto pb-2">
{data.map((round, idx) => (
<div key={round.roundId} className="flex items-center gap-2">
<div className="flex flex-col items-center min-w-[100px]">
<div
className="rounded-lg bg-[#053d57] flex items-center justify-center text-white font-bold text-lg tabular-nums transition-all"
style={{
width: `${Math.max(60, (round.projectCount / maxProjects) * 120)}px`,
height: `${Math.max(40, (round.projectCount / maxProjects) * 60)}px`,
}}
>
{round.projectCount}
</div>
<p className="text-xs text-muted-foreground mt-1.5 text-center leading-tight max-w-[100px] truncate">
{round.roundName}
</p>
</div>
{idx < data.length - 1 && (
<div className="flex flex-col items-center shrink-0">
<ArrowRight className="h-4 w-4 text-muted-foreground" />
{data[idx + 1].projectCount < round.projectCount && (
<span className="text-[10px] text-rose-500 tabular-nums font-medium">
-{round.projectCount - data[idx + 1].projectCount}
</span>
)}
</div>
)}
</div>
))}
</div>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">
Evaluations
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<BarChart
data={baseData}
index="name"
categories={['Evaluations']}
colors={['violet']}
showLegend={false}
yAxisWidth={40}
className="h-[200px]"
/>
</CardContent>
</Card>
{/* Detailed metrics table */}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Round</TableHead>
<TableHead className="text-right tabular-nums">Projects</TableHead>
<TableHead className="text-right tabular-nums">Evaluations</TableHead>
<TableHead>Completion</TableHead>
<TableHead className="text-right">Avg Score</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.map((round, idx) => {
const prevCount = idx > 0 ? data[idx - 1].projectCount : null
const attrition = prevCount !== null && prevCount > 0
? Math.round(((prevCount - round.projectCount) / prevCount) * 100)
: null
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">
Completion Rate
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<BarChart
data={baseData}
index="name"
categories={['Completion Rate']}
colors={['emerald']}
showLegend={false}
maxValue={100}
yAxisWidth={40}
valueFormatter={(v) => `${v}%`}
className="h-[200px]"
/>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">
Average Score
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<BarChart
data={baseData}
index="name"
categories={['Avg Score']}
colors={['amber']}
showLegend={false}
maxValue={10}
yAxisWidth={40}
className="h-[200px]"
/>
</CardContent>
</Card>
return (
<TableRow key={round.roundId}>
<TableCell>
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{round.roundName}</span>
{attrition !== null && attrition > 0 && (
<Badge variant="outline" className="text-[10px] text-rose-600 border-rose-200">
-{attrition}%
</Badge>
)}
</div>
</TableCell>
<TableCell className="text-right tabular-nums font-medium">
{round.projectCount}
</TableCell>
<TableCell className="text-right tabular-nums">
{round.evaluationCount > 0 ? round.evaluationCount : '—'}
</TableCell>
<TableCell>
{round.evaluationCount > 0 ? (
<div className="flex items-center gap-2">
<Progress value={round.completionRate} className="w-16 h-2" />
<span className="text-xs tabular-nums text-muted-foreground">
{round.completionRate}%
</span>
</div>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</TableCell>
<TableCell className="text-right">
{round.averageScore !== null ? (
<span
className="inline-flex items-center justify-center rounded-md px-2 py-0.5 text-xs font-semibold tabular-nums min-w-[36px]"
style={{
backgroundColor: scoreGradient(round.averageScore),
color: '#ffffff',
}}
>
{round.averageScore.toFixed(1)}
</span>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</CardContent>
</Card>

View File

@@ -20,7 +20,7 @@ function getScoreColor(score: number | null): string {
function getTextColor(score: number | null): string {
if (score === null) return 'inherit'
return score >= 6 ? '#ffffff' : '#1a1a1a'
return '#ffffff'
}
function ScoreBadge({ score }: { score: number }) {
@@ -73,7 +73,6 @@ function JurorSummaryRow({
</td>
<td className="py-3 px-4 text-center tabular-nums text-sm">
{scored.length}
<span className="text-muted-foreground">/{projectCount}</span>
</td>
<td className="py-3 px-4 text-center">
{averageScore !== null ? (

View File

@@ -36,14 +36,16 @@ export function StatusBreakdownChart({ data }: StatusBreakdownProps) {
</CardTitle>
</CardHeader>
<CardContent>
<DonutChart
data={chartData}
category="value"
index="name"
colors={colors}
showLabel={true}
className="h-[300px]"
/>
<div className="flex items-center justify-center">
<DonutChart
data={chartData}
category="value"
index="name"
colors={colors}
showLabel={true}
className="h-[250px] w-[250px]"
/>
</div>
</CardContent>
</Card>
)