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
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:
@@ -233,6 +233,12 @@ export default function RoundDetailPage() {
|
|||||||
)
|
)
|
||||||
const roundAwards = awards?.filter((a) => a.evaluationRoundId === roundId) ?? []
|
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
|
// Sync config from server when no pending save
|
||||||
if (round && !pendingSaveRef.current) {
|
if (round && !pendingSaveRef.current) {
|
||||||
const roundConfig = (round.configJson as Record<string, unknown>) ?? {}
|
const roundConfig = (round.configJson as Record<string, unknown>) ?? {}
|
||||||
@@ -245,8 +251,12 @@ export default function RoundDetailPage() {
|
|||||||
const updateMutation = trpc.round.update.useMutation({
|
const updateMutation = trpc.round.update.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
utils.round.getById.invalidate({ id: roundId })
|
utils.round.getById.invalidate({ id: roundId })
|
||||||
pendingSaveRef.current = false
|
|
||||||
setAutosaveStatus('saved')
|
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)
|
setTimeout(() => setAutosaveStatus('idle'), 2000)
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
@@ -866,8 +876,81 @@ export default function RoundDetailPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
|
|
||||||
{/* Quick Actions — Grouped & styled */}
|
{/* Filtering Results Summary — only for FILTERING rounds with results */}
|
||||||
|
{isFiltering && filteringStats && filteringStats.total > 0 && (
|
||||||
<AnimatedCard index={1}>
|
<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={2}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">Quick Actions</CardTitle>
|
<CardTitle className="text-base">Quick Actions</CardTitle>
|
||||||
|
|||||||
@@ -774,7 +774,7 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Badge variant="outline" className="text-xs">
|
<Badge variant="outline" className="text-xs">
|
||||||
{result.project?.competitionCategory || '\u2014'}
|
{formatCategory(result.project?.competitionCategory) || '\u2014'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<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 --
|
// -- Sub-components --
|
||||||
|
|
||||||
function OutcomeBadge({ outcome, overridden }: { outcome: string; overridden: boolean }) {
|
function OutcomeBadge({ outcome, overridden }: { outcome: string; overridden: boolean }) {
|
||||||
|
|||||||
@@ -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({
|
const job = await ctx.prisma.filteringJob.create({
|
||||||
data: {
|
data: {
|
||||||
roundId: input.roundId,
|
roundId: input.roundId,
|
||||||
|
|||||||
Reference in New Issue
Block a user