Observer: fix round history, match admin project info, add AI rejection reason
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m30s

- Round history infers rejection round when ProjectRoundState lacks explicit
  REJECTED state; shows red XCircle + badge, dims unreached rounds
- Project info section now matches admin: description, location, founded,
  submission links, expertise tags, internal notes, created/updated dates
- Fetch FilteringResult for rejected projects; display AI reasoning + confidence
- Remove cross-round comparison from reports, replace with scoring/criteria insights
- Remove unused AI synthesis placeholder

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-20 23:30:14 +01:00
parent 9f7b76b3cb
commit d717040f03
3 changed files with 443 additions and 159 deletions

View File

@@ -11,7 +11,6 @@ import {
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Toggle } from '@/components/ui/toggle'
import { Progress } from '@/components/ui/progress'
import { Skeleton } from '@/components/ui/skeleton'
import {
@@ -36,7 +35,6 @@ import {
BarChart3,
Users,
TrendingUp,
GitCompare,
Download,
Clock,
} from 'lucide-react'
@@ -46,7 +44,6 @@ import {
EvaluationTimelineChart,
StatusBreakdownChart,
CriteriaScoresChart,
CrossStageComparisonChart,
} from '@/components/charts'
import { CsvExportDialog } from '@/components/shared/csv-export-dialog'
import { ExportPdfButton } from '@/components/shared/export-pdf-button'
@@ -621,29 +618,8 @@ function ScoresTab({ selectedValue }: { selectedValue: string }) {
const { data: criteriaScores, isLoading: criteriaLoading } =
trpc.analytics.getCriteriaScores.useQuery(queryInput, { enabled: hasSelection })
const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeStages: true })
const crossStageRounds = programs?.flatMap(p =>
((p.stages || []) as Array<{ id: string; name: string }>).map(s => ({
id: s.id,
name: s.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]
)
}
const { data: overviewStats } =
trpc.analytics.getOverviewStats.useQuery(queryInput, { enabled: hasSelection })
const [csvOpen, setCsvOpen] = useState(false)
const [csvData, setCsvData] = useState<{ data: Record<string, unknown>[]; columns: string[] } | undefined>()
@@ -665,12 +641,63 @@ function ScoresTab({ selectedValue }: { selectedValue: string }) {
return result
}, [criteriaScores])
// Derived scoring insights
const scoringInsights = (() => {
if (!scoreDistribution?.distribution?.length) return null
const dist = scoreDistribution.distribution
const totalScores = dist.reduce((sum, d) => sum + d.count, 0)
if (totalScores === 0) return null
// Find score with highest count
const peakBucket = dist.reduce((a, b) => (b.count > a.count ? b : a), dist[0])
// Find highest and lowest scores that have counts
const scoredBuckets = dist.filter(d => d.count > 0)
const highScore = scoredBuckets.length > 0 ? Math.max(...scoredBuckets.map(d => d.score)) : 0
const lowScore = scoredBuckets.length > 0 ? Math.min(...scoredBuckets.map(d => d.score)) : 0
// Median: walk through distribution to find the middle
let cumulative = 0
let median = 0
for (const d of dist) {
cumulative += d.count
if (cumulative >= totalScores / 2) {
median = d.score
break
}
}
// Scores ≥ 7 count
const highScoreCount = dist.filter(d => d.score >= 7).reduce((sum, d) => sum + d.count, 0)
const highScorePct = Math.round((highScoreCount / totalScores) * 100)
return { peakScore: peakBucket.score, highScore, lowScore, median, totalScores, highScorePct }
})()
// Criteria insights
const criteriaInsights = (() => {
if (!criteriaScores?.length) return null
const sorted = [...criteriaScores].sort((a, b) => b.averageScore - a.averageScore)
return {
strongest: sorted[0],
weakest: sorted[sorted.length - 1],
spread: sorted[0].averageScore - sorted[sorted.length - 1].averageScore,
}
})()
// Status insights
const statusInsights = (() => {
if (!statusBreakdown?.length) return null
const total = statusBreakdown.reduce((sum, s) => sum + s.count, 0)
const reviewed = statusBreakdown
.filter(s => !['SUBMITTED', 'ELIGIBLE', 'DRAFT'].includes(s.status))
.reduce((sum, s) => sum + s.count, 0)
return { total, reviewed, reviewedPct: total > 0 ? Math.round((reviewed / total) * 100) : 0 }
})()
return (
<div className="space-y-6">
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<h2 className="text-base font-semibold">Scores & Analytics</h2>
<p className="text-sm text-muted-foreground">Score distributions, criteria breakdown and cross-round comparison</p>
<p className="text-sm text-muted-foreground">Score distributions, criteria breakdown and insights</p>
</div>
<Button
variant="outline"
@@ -692,6 +719,48 @@ function ScoresTab({ selectedValue }: { selectedValue: string }) {
onRequestData={handleRequestCsvData}
/>
{/* Scoring Insight Tiles */}
{hasSelection && scoringInsights && (
<div className="grid gap-4 grid-cols-2 lg:grid-cols-4">
<AnimatedCard index={0}>
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-4">
<p className="text-xs font-medium text-muted-foreground">Median Score</p>
<p className="text-2xl font-bold mt-1 tabular-nums">{scoringInsights.median}</p>
<p className="text-xs text-muted-foreground mt-0.5">of 10</p>
</CardContent>
</Card>
</AnimatedCard>
<AnimatedCard index={1}>
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-4">
<p className="text-xs font-medium text-muted-foreground">Score Range</p>
<p className="text-2xl font-bold mt-1 tabular-nums">{scoringInsights.lowScore}{scoringInsights.highScore}</p>
<p className="text-xs text-muted-foreground mt-0.5">lowest to highest</p>
</CardContent>
</Card>
</AnimatedCard>
<AnimatedCard index={2}>
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-4">
<p className="text-xs font-medium text-muted-foreground">Most Common</p>
<p className="text-2xl font-bold mt-1 tabular-nums">{scoringInsights.peakScore}</p>
<p className="text-xs text-muted-foreground mt-0.5">peak score</p>
</CardContent>
</Card>
</AnimatedCard>
<AnimatedCard index={3}>
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-4">
<p className="text-xs font-medium text-muted-foreground">High Scores (7+)</p>
<p className="text-2xl font-bold mt-1 tabular-nums">{scoringInsights.highScorePct}%</p>
<p className="text-xs text-muted-foreground mt-0.5">of {scoringInsights.totalScores} scores</p>
</CardContent>
</Card>
</AnimatedCard>
</div>
)}
{/* Score Distribution & Status Breakdown */}
<div className="grid gap-6 lg:grid-cols-2">
{scoreLoading ? (
@@ -736,54 +805,84 @@ function ScoresTab({ selectedValue }: { selectedValue: string }) {
</Card>
) : null}
{/* Cross-Round Comparison */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<GitCompare className="h-4 w-4" />
Cross-Round Comparison
</CardTitle>
<CardDescription>Select at least 2 rounds to compare metrics</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{programsLoading ? (
<Skeleton className="h-10" />
) : (
<div className="flex flex-wrap gap-2" role="group" aria-label="Select rounds to compare">
{crossStageRounds.map((stage) => (
<Toggle
key={stage.id}
variant="outline"
size="sm"
pressed={selectedRoundIds.includes(stage.id)}
onPressedChange={() => toggleRound(stage.id)}
aria-label={stage.name}
>
{stage.name}
</Toggle>
))}
</div>
{/* Criteria & Coverage Insights */}
{hasSelection && (criteriaInsights || statusInsights || overviewStats) && (
<div className="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
{criteriaInsights && (
<>
<AnimatedCard index={4}>
<Card className="border-l-4 border-l-emerald-500">
<CardContent className="p-4">
<p className="text-xs font-medium text-muted-foreground">Strongest Criterion</p>
<p className="font-semibold mt-1 leading-tight">{criteriaInsights.strongest.name}</p>
<p className="text-lg font-bold tabular-nums text-emerald-600 mt-1">
{criteriaInsights.strongest.averageScore.toFixed(2)}
</p>
</CardContent>
</Card>
</AnimatedCard>
<AnimatedCard index={5}>
<Card className="border-l-4 border-l-amber-500">
<CardContent className="p-4">
<p className="text-xs font-medium text-muted-foreground">Weakest Criterion</p>
<p className="font-semibold mt-1 leading-tight">{criteriaInsights.weakest.name}</p>
<p className="text-lg font-bold tabular-nums text-amber-600 mt-1">
{criteriaInsights.weakest.averageScore.toFixed(2)}
</p>
</CardContent>
</Card>
</AnimatedCard>
<AnimatedCard index={6}>
<Card className="border-l-4 border-l-blue-500">
<CardContent className="p-4">
<p className="text-xs font-medium text-muted-foreground">Criteria Spread</p>
<p className="text-2xl font-bold tabular-nums mt-1">{criteriaInsights.spread.toFixed(2)}</p>
<p className="text-xs text-muted-foreground mt-0.5">points between strongest and weakest</p>
</CardContent>
</Card>
</AnimatedCard>
</>
)}
{selectedRoundIds.length < 2 && (
<p className="text-sm text-muted-foreground">
Select at least 2 rounds to enable comparison
</p>
{overviewStats && (
<>
<AnimatedCard index={7}>
<Card className="border-l-4 border-l-violet-500">
<CardContent className="p-4">
<p className="text-xs font-medium text-muted-foreground">Total Evaluations</p>
<p className="text-2xl font-bold tabular-nums mt-1">{overviewStats.evaluationCount}</p>
<p className="text-xs text-muted-foreground mt-0.5">
{overviewStats.projectCount > 0
? `~${(overviewStats.evaluationCount / overviewStats.projectCount).toFixed(1)} per project`
: 'submitted'}
</p>
</CardContent>
</Card>
</AnimatedCard>
<AnimatedCard index={8}>
<Card className="border-l-4 border-l-teal-500">
<CardContent className="p-4">
<p className="text-xs font-medium text-muted-foreground">Completion Rate</p>
<p className="text-2xl font-bold tabular-nums mt-1">{overviewStats.completionRate}%</p>
<Progress value={overviewStats.completionRate} className="mt-2 h-1.5" />
</CardContent>
</Card>
</AnimatedCard>
</>
)}
</CardContent>
</Card>
{comparisonLoading && selectedRoundIds.length >= 2 && <Skeleton className="h-[350px]" />}
{comparison && (
<CrossStageComparisonChart data={comparison as Array<{
roundId: string
roundName: string
projectCount: number
evaluationCount: number
completionRate: number
averageScore: number | null
scoreDistribution: { score: number; count: number }[]
}>} />
{statusInsights && (
<AnimatedCard index={9}>
<Card className="border-l-4 border-l-cyan-500">
<CardContent className="p-4">
<p className="text-xs font-medium text-muted-foreground">Review Progress</p>
<p className="text-2xl font-bold tabular-nums mt-1">{statusInsights.reviewed}/{statusInsights.total}</p>
<p className="text-xs text-muted-foreground mt-0.5">
{statusInsights.reviewedPct}% of projects reviewed
</p>
</CardContent>
</Card>
</AnimatedCard>
)}
</div>
)}
</div>
)