Fix reports: status breakdown uses round states, filter boolean criteria, replace insight tiles with country chart
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m47s

- getStatusBreakdown now uses ProjectRoundState when a specific round is selected
  (fixes donut showing all "Eligible")
- Filter out boolean/section_header criteria from getCriteriaScores
  (removes "Move to the Next Stage?" from bar chart)
- Replace 6 insight tiles with Top Countries horizontal bar chart
- Add round-level state labels/colors to chart-theme

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-21 00:00:55 +01:00
parent 26e8830df2
commit 4f73ba5a0e
3 changed files with 77 additions and 177 deletions

View File

@@ -45,6 +45,7 @@ import {
StatusBreakdownChart, StatusBreakdownChart,
CriteriaScoresChart, CriteriaScoresChart,
} from '@/components/charts' } from '@/components/charts'
import { BarChart } from '@tremor/react'
import { CsvExportDialog } from '@/components/shared/csv-export-dialog' import { CsvExportDialog } from '@/components/shared/csv-export-dialog'
import { ExportPdfButton } from '@/components/shared/export-pdf-button' import { ExportPdfButton } from '@/components/shared/export-pdf-button'
import { AnimatedCard } from '@/components/shared/animated-container' import { AnimatedCard } from '@/components/shared/animated-container'
@@ -605,7 +606,7 @@ function JurorsTab({ selectedValue }: { selectedValue: string }) {
) )
} }
function ScoresTab({ selectedValue }: { selectedValue: string }) { function ScoresTab({ selectedValue, programId }: { selectedValue: string; programId: string | undefined }) {
const queryInput = parseSelection(selectedValue) const queryInput = parseSelection(selectedValue)
const hasSelection = !!queryInput.roundId || !!queryInput.programId const hasSelection = !!queryInput.roundId || !!queryInput.programId
@@ -618,8 +619,12 @@ function ScoresTab({ selectedValue }: { selectedValue: string }) {
const { data: criteriaScores, isLoading: criteriaLoading } = const { data: criteriaScores, isLoading: criteriaLoading } =
trpc.analytics.getCriteriaScores.useQuery(queryInput, { enabled: hasSelection }) trpc.analytics.getCriteriaScores.useQuery(queryInput, { enabled: hasSelection })
const { data: overviewStats } = const geoProgramId = queryInput.programId || programId
trpc.analytics.getOverviewStats.useQuery(queryInput, { enabled: hasSelection }) const { data: geoData, isLoading: geoLoading } =
trpc.analytics.getGeographicDistribution.useQuery(
{ programId: geoProgramId!, roundId: queryInput.roundId },
{ enabled: !!geoProgramId }
)
const [csvOpen, setCsvOpen] = useState(false) const [csvOpen, setCsvOpen] = useState(false)
const [csvData, setCsvData] = useState<{ data: Record<string, unknown>[]; columns: string[] } | undefined>() const [csvData, setCsvData] = useState<{ data: Record<string, unknown>[]; columns: string[] } | undefined>()
@@ -641,55 +646,18 @@ function ScoresTab({ selectedValue }: { selectedValue: string }) {
return result return result
}, [criteriaScores]) }, [criteriaScores])
// Derived scoring insights // Country chart data
const scoringInsights = (() => { const countryChartData = (() => {
if (!scoreDistribution?.distribution?.length) return null if (!geoData?.length) return []
const dist = scoreDistribution.distribution const sorted = [...geoData].sort((a, b) => b.count - a.count)
const totalScores = dist.reduce((sum, d) => sum + d.count, 0) return sorted.slice(0, 15).map((d) => {
if (totalScores === 0) return null let name = d.countryCode
try {
// Find score with highest count const displayNames = new Intl.DisplayNames(['en'], { type: 'region' })
const peakBucket = dist.reduce((a, b) => (b.count > a.count ? b : a), dist[0]) name = displayNames.of(d.countryCode.toUpperCase()) || d.countryCode
// Find highest and lowest scores that have counts } catch { /* keep code */ }
const scoredBuckets = dist.filter(d => d.count > 0) return { country: name, Projects: d.count }
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 ( return (
@@ -697,7 +665,7 @@ function ScoresTab({ selectedValue }: { selectedValue: string }) {
<div className="flex items-center justify-between flex-wrap gap-3"> <div className="flex items-center justify-between flex-wrap gap-3">
<div> <div>
<h2 className="text-base font-semibold">Scores & Analytics</h2> <h2 className="text-base font-semibold">Scores & Analytics</h2>
<p className="text-sm text-muted-foreground">Score distributions, criteria breakdown and insights</p> <p className="text-sm text-muted-foreground">Score distributions, criteria breakdown and geographic data</p>
</div> </div>
<Button <Button
variant="outline" variant="outline"
@@ -719,48 +687,6 @@ function ScoresTab({ selectedValue }: { selectedValue: string }) {
onRequestData={handleRequestCsvData} 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 */} {/* Score Distribution & Status Breakdown */}
<div className="grid gap-6 lg:grid-cols-2"> <div className="grid gap-6 lg:grid-cols-2">
{scoreLoading ? ( {scoreLoading ? (
@@ -805,85 +731,33 @@ function ScoresTab({ selectedValue }: { selectedValue: string }) {
</Card> </Card>
) : null} ) : null}
{/* Criteria & Coverage Insights */} {/* Country Distribution */}
{hasSelection && (criteriaInsights || statusInsights || overviewStats) && ( {geoLoading ? (
<div className="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3"> <Skeleton className="h-[400px]" />
{criteriaInsights && ( ) : countryChartData.length > 0 ? (
<> <Card>
<AnimatedCard index={4}> <CardHeader>
<Card className="border-l-4 border-l-emerald-500"> <CardTitle className="flex items-center justify-between">
<CardContent className="p-4"> <span>Top Countries</span>
<p className="text-xs font-medium text-muted-foreground">Strongest Criterion</p> <span className="text-sm font-normal text-muted-foreground">
<p className="font-semibold mt-1 leading-tight">{criteriaInsights.strongest.name}</p> {geoData?.length ?? 0} countries represented
<p className="text-lg font-bold tabular-nums text-emerald-600 mt-1"> </span>
{criteriaInsights.strongest.averageScore.toFixed(2)} </CardTitle>
</p> </CardHeader>
<CardContent>
<BarChart
data={countryChartData}
index="country"
categories={['Projects']}
colors={['blue']}
layout="vertical"
yAxisWidth={140}
showLegend={false}
className="h-[400px]"
/>
</CardContent> </CardContent>
</Card> </Card>
</AnimatedCard> ) : null}
<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>
</>
)}
{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>
</>
)}
{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> </div>
) )
} }
@@ -989,7 +863,7 @@ function ReportsPageContent() {
<TabsContent value="scores"> <TabsContent value="scores">
{selectedValue ? ( {selectedValue ? (
<ScoresTab selectedValue={selectedValue} /> <ScoresTab selectedValue={selectedValue} programId={selectedRound?.programId ?? programs?.[0]?.id} />
) : ( ) : (
<Card> <Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center"> <CardContent className="flex flex-col items-center justify-center py-12 text-center">

View File

@@ -36,7 +36,9 @@ export const TREMOR_CHART_COLORS = [
] as const ] as const
// Donut / status chart colors (mapped to Tremor names) // Donut / status chart colors (mapped to Tremor names)
// Covers both global ProjectStatus and round-level ProjectRoundState values
export const TREMOR_STATUS_COLORS: Record<string, string> = { export const TREMOR_STATUS_COLORS: Record<string, string> = {
// Global project statuses
SUBMITTED: 'sky', SUBMITTED: 'sky',
ELIGIBLE: 'blue', ELIGIBLE: 'blue',
ASSIGNED: 'violet', ASSIGNED: 'violet',
@@ -45,6 +47,11 @@ export const TREMOR_STATUS_COLORS: Record<string, string> = {
REJECTED: 'rose', REJECTED: 'rose',
DRAFT: 'gray', DRAFT: 'gray',
WITHDRAWN: 'slate', WITHDRAWN: 'slate',
// Round-level states (ProjectRoundState)
PENDING: 'sky',
IN_PROGRESS: 'blue',
PASSED: 'emerald',
COMPLETED: 'indigo',
} }
// Project status colors — mapped to actual ProjectStatus enum values // Project status colors — mapped to actual ProjectStatus enum values
@@ -69,6 +76,11 @@ export const STATUS_LABELS: Record<string, string> = {
REJECTED: 'Rejected', REJECTED: 'Rejected',
DRAFT: 'Draft', DRAFT: 'Draft',
WITHDRAWN: 'Withdrawn', WITHDRAWN: 'Withdrawn',
// Round-level states
PENDING: 'Pending',
IN_PROGRESS: 'In Progress',
PASSED: 'Passed',
COMPLETED: 'Completed',
} }
/** /**

View File

@@ -249,12 +249,24 @@ export const analyticsRouter = router({
getStatusBreakdown: observerProcedure getStatusBreakdown: observerProcedure
.input(editionOrRoundInput) .input(editionOrRoundInput)
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
if (input.roundId) {
// Round-level: use ProjectRoundState for accurate per-round breakdown
const states = await ctx.prisma.projectRoundState.groupBy({
by: ['state'],
where: { roundId: input.roundId },
_count: true,
})
return states.map((s) => ({
status: s.state,
count: s._count,
}))
}
// Edition-level: use global project status
const projects = await ctx.prisma.project.groupBy({ const projects = await ctx.prisma.project.groupBy({
by: ['status'], by: ['status'],
where: projectWhere(input), where: projectWhere(input),
_count: true, _count: true,
}) })
return projects.map((p) => ({ return projects.map((p) => ({
status: p.status, status: p.status,
count: p._count, count: p._count,
@@ -327,12 +339,14 @@ export const analyticsRouter = router({
} }
// Build label → Set<id> map so program-level queries match all IDs for the same criterion label // Build label → Set<id> map so program-level queries match all IDs for the same criterion label
// Skip boolean and section_header criteria — they don't have numeric scores
const labelToIds = new Map<string, Set<string>>() const labelToIds = new Map<string, Set<string>>()
const labelToFirst = new Map<string, { id: string; label: string }>() const labelToFirst = new Map<string, { id: string; label: string }>()
evaluationForms.forEach((form) => { evaluationForms.forEach((form) => {
const criteria = form.criteriaJson as Array<{ id: string; label: string }> | null const criteria = form.criteriaJson as Array<{ id: string; label: string; type?: string }> | null
if (criteria) { if (criteria) {
criteria.forEach((c) => { criteria.forEach((c) => {
if (c.type === 'boolean' || c.type === 'section_header') return
if (!labelToIds.has(c.label)) { if (!labelToIds.has(c.label)) {
labelToIds.set(c.label, new Set()) labelToIds.set(c.label, new Set())
labelToFirst.set(c.label, c) labelToFirst.set(c.label, c)