Filtering UX: overview results, auto-clear on re-run, config save fix
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m25s

- Add filtering results summary card on round Overview tab with pass/fail/flag
  counts and color-coded progress bar (polls every 5s)
- Auto-delete previous filtering results when re-running so new ones stream in
- Rename BUSINESS_CONCEPT to "Concept" in filtering results to prevent overflow
- Fix config save race condition where toggling switches (aiParseFiles, advance
  counts) would revert: pendingSaveRef cleared before refetch completed

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt
2026-02-17 16:25:59 +01:00
parent a02ed59158
commit 1c6961355b
3 changed files with 103 additions and 3 deletions

View File

@@ -233,6 +233,12 @@ export default function RoundDetailPage() {
)
const roundAwards = awards?.filter((a) => a.evaluationRoundId === roundId) ?? []
// Filtering results stats (only for FILTERING rounds)
const { data: filteringStats } = trpc.filtering.getResultStats.useQuery(
{ roundId },
{ enabled: round?.roundType === 'FILTERING', refetchInterval: 5_000 },
)
// Sync config from server when no pending save
if (round && !pendingSaveRef.current) {
const roundConfig = (round.configJson as Record<string, unknown>) ?? {}
@@ -245,8 +251,12 @@ export default function RoundDetailPage() {
const updateMutation = trpc.round.update.useMutation({
onSuccess: () => {
utils.round.getById.invalidate({ id: roundId })
pendingSaveRef.current = false
setAutosaveStatus('saved')
// Keep pendingSaveRef locked briefly so the sync block doesn't
// overwrite local config with stale cache before refetch completes
setTimeout(() => {
pendingSaveRef.current = false
}, 500)
setTimeout(() => setAutosaveStatus('idle'), 2000)
},
onError: (err) => {
@@ -866,8 +876,81 @@ export default function RoundDetailPage() {
</Card>
</AnimatedCard>
{/* Filtering Results Summary — only for FILTERING rounds with results */}
{isFiltering && filteringStats && filteringStats.total > 0 && (
<AnimatedCard index={1}>
<Card className="border-l-4 border-l-purple-500">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="rounded-full bg-purple-50 p-2">
<Shield className="h-5 w-5 text-purple-600" />
</div>
<div>
<CardTitle className="text-base">Filtering Results</CardTitle>
<CardDescription>
{filteringStats.total} projects evaluated
</CardDescription>
</div>
</div>
<Button
size="sm"
variant="outline"
onClick={() => setActiveTab('filtering')}
>
View Details
<ArrowRight className="h-3.5 w-3.5 ml-1.5" />
</Button>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 gap-4 mb-4">
<div className="text-center p-3 rounded-lg bg-emerald-50">
<p className="text-2xl font-bold text-emerald-700">{filteringStats.passed}</p>
<p className="text-xs text-emerald-600 font-medium">Passed</p>
</div>
<div className="text-center p-3 rounded-lg bg-red-50">
<p className="text-2xl font-bold text-red-700">{filteringStats.filteredOut}</p>
<p className="text-xs text-red-600 font-medium">Filtered Out</p>
</div>
<div className="text-center p-3 rounded-lg bg-amber-50">
<p className="text-2xl font-bold text-amber-700">{filteringStats.flagged}</p>
<p className="text-xs text-amber-600 font-medium">Flagged</p>
</div>
</div>
{/* Progress bar showing pass rate */}
<div className="space-y-1.5">
<div className="flex justify-between text-xs text-muted-foreground">
<span>Pass rate</span>
<span>{Math.round((filteringStats.passed / filteringStats.total) * 100)}%</span>
</div>
<div className="h-2.5 rounded-full bg-muted overflow-hidden flex">
<div
className="bg-emerald-500 transition-all duration-500"
style={{ width: `${(filteringStats.passed / filteringStats.total) * 100}%` }}
/>
<div
className="bg-red-400 transition-all duration-500"
style={{ width: `${(filteringStats.filteredOut / filteringStats.total) * 100}%` }}
/>
<div
className="bg-amber-400 transition-all duration-500"
style={{ width: `${(filteringStats.flagged / filteringStats.total) * 100}%` }}
/>
</div>
{filteringStats.overridden > 0 && (
<p className="text-xs text-muted-foreground">
{filteringStats.overridden} result(s) manually overridden
</p>
)}
</div>
</CardContent>
</Card>
</AnimatedCard>
)}
{/* Quick Actions — Grouped & styled */}
<AnimatedCard index={1}>
<AnimatedCard index={2}>
<Card>
<CardHeader>
<CardTitle className="text-base">Quick Actions</CardTitle>

View File

@@ -774,7 +774,7 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
</div>
<div>
<Badge variant="outline" className="text-xs">
{result.project?.competitionCategory || '\u2014'}
{formatCategory(result.project?.competitionCategory) || '\u2014'}
</Badge>
</div>
<div>
@@ -1092,6 +1092,18 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
)
}
// -- Helpers --
const categoryLabels: Record<string, string> = {
BUSINESS_CONCEPT: 'Concept',
STARTUP: 'Startup',
}
function formatCategory(cat: string | null | undefined): string {
if (!cat) return ''
return categoryLabels[cat] ?? cat.charAt(0) + cat.slice(1).toLowerCase().replace(/_/g, ' ')
}
// -- Sub-components --
function OutcomeBadge({ outcome, overridden }: { outcome: string; overridden: boolean }) {

View File

@@ -493,6 +493,11 @@ export const filteringRouter = router({
})
}
// Clear previous filtering results so new ones stream in fresh
await ctx.prisma.filteringResult.deleteMany({
where: { roundId: input.roundId },
})
const job = await ctx.prisma.filteringJob.create({
data: {
roundId: input.roundId,