From 4f73ba5a0eaf86e3463c174e04242bbcbc465261 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 21 Feb 2026 00:00:55 +0100 Subject: [PATCH] Fix reports: status breakdown uses round states, filter boolean criteria, replace insight tiles with country chart - 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 --- src/app/(observer)/observer/reports/page.tsx | 224 ++++--------------- src/components/charts/chart-theme.ts | 12 + src/server/routers/analytics.ts | 18 +- 3 files changed, 77 insertions(+), 177 deletions(-) diff --git a/src/app/(observer)/observer/reports/page.tsx b/src/app/(observer)/observer/reports/page.tsx index 1d1e5a5..a7a8aae 100644 --- a/src/app/(observer)/observer/reports/page.tsx +++ b/src/app/(observer)/observer/reports/page.tsx @@ -45,6 +45,7 @@ import { StatusBreakdownChart, CriteriaScoresChart, } from '@/components/charts' +import { BarChart } from '@tremor/react' import { CsvExportDialog } from '@/components/shared/csv-export-dialog' import { ExportPdfButton } from '@/components/shared/export-pdf-button' 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 hasSelection = !!queryInput.roundId || !!queryInput.programId @@ -618,8 +619,12 @@ function ScoresTab({ selectedValue }: { selectedValue: string }) { const { data: criteriaScores, isLoading: criteriaLoading } = trpc.analytics.getCriteriaScores.useQuery(queryInput, { enabled: hasSelection }) - const { data: overviewStats } = - trpc.analytics.getOverviewStats.useQuery(queryInput, { enabled: hasSelection }) + const geoProgramId = queryInput.programId || programId + const { data: geoData, isLoading: geoLoading } = + trpc.analytics.getGeographicDistribution.useQuery( + { programId: geoProgramId!, roundId: queryInput.roundId }, + { enabled: !!geoProgramId } + ) const [csvOpen, setCsvOpen] = useState(false) const [csvData, setCsvData] = useState<{ data: Record[]; columns: string[] } | undefined>() @@ -641,55 +646,18 @@ 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 } + // Country chart data + const countryChartData = (() => { + if (!geoData?.length) return [] + const sorted = [...geoData].sort((a, b) => b.count - a.count) + return sorted.slice(0, 15).map((d) => { + let name = d.countryCode + try { + const displayNames = new Intl.DisplayNames(['en'], { type: 'region' }) + name = displayNames.of(d.countryCode.toUpperCase()) || d.countryCode + } catch { /* keep code */ } + return { country: name, Projects: d.count } + }) })() return ( @@ -697,7 +665,7 @@ function ScoresTab({ selectedValue }: { selectedValue: string }) {

Scores & Analytics

-

Score distributions, criteria breakdown and insights

+

Score distributions, criteria breakdown and geographic data