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
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:
@@ -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<string, unknown>[]; 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 }) {
|
||||
<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 insights</p>
|
||||
<p className="text-sm text-muted-foreground">Score distributions, criteria breakdown and geographic data</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -719,48 +687,6 @@ 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 ? (
|
||||
@@ -805,85 +731,33 @@ function ScoresTab({ selectedValue }: { selectedValue: string }) {
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{/* 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>
|
||||
</>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
{/* Country Distribution */}
|
||||
{geoLoading ? (
|
||||
<Skeleton className="h-[400px]" />
|
||||
) : countryChartData.length > 0 ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span>Top Countries</span>
|
||||
<span className="text-sm font-normal text-muted-foreground">
|
||||
{geoData?.length ?? 0} countries represented
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<BarChart
|
||||
data={countryChartData}
|
||||
index="country"
|
||||
categories={['Projects']}
|
||||
colors={['blue']}
|
||||
layout="vertical"
|
||||
yAxisWidth={140}
|
||||
showLegend={false}
|
||||
className="h-[400px]"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -989,7 +863,7 @@ function ReportsPageContent() {
|
||||
|
||||
<TabsContent value="scores">
|
||||
{selectedValue ? (
|
||||
<ScoresTab selectedValue={selectedValue} />
|
||||
<ScoresTab selectedValue={selectedValue} programId={selectedRound?.programId ?? programs?.[0]?.id} />
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
|
||||
Reference in New Issue
Block a user