From d717040f0315b28aeb7d847c7e6ce730b4043f9e Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 20 Feb 2026 23:30:14 +0100 Subject: [PATCH] Observer: fix round history, match admin project info, add AI rejection reason - Round history infers rejection round when ProjectRoundState lacks explicit REJECTED state; shows red XCircle + badge, dims unreached rounds - Project info section now matches admin: description, location, founded, submission links, expertise tags, internal notes, created/updated dates - Fetch FilteringResult for rejected projects; display AI reasoning + confidence - Remove cross-round comparison from reports, replace with scoring/criteria insights - Remove unused AI synthesis placeholder Co-Authored-By: Claude Opus 4.6 --- src/app/(observer)/observer/reports/page.tsx | 245 +++++++++---- .../observer/observer-project-detail.tsx | 341 +++++++++++++----- src/server/routers/analytics.ts | 16 + 3 files changed, 443 insertions(+), 159 deletions(-) diff --git a/src/app/(observer)/observer/reports/page.tsx b/src/app/(observer)/observer/reports/page.tsx index 83a026e..1d1e5a5 100644 --- a/src/app/(observer)/observer/reports/page.tsx +++ b/src/app/(observer)/observer/reports/page.tsx @@ -11,7 +11,6 @@ import { CardTitle, } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' -import { Toggle } from '@/components/ui/toggle' import { Progress } from '@/components/ui/progress' import { Skeleton } from '@/components/ui/skeleton' import { @@ -36,7 +35,6 @@ import { BarChart3, Users, TrendingUp, - GitCompare, Download, Clock, } from 'lucide-react' @@ -46,7 +44,6 @@ import { EvaluationTimelineChart, StatusBreakdownChart, CriteriaScoresChart, - CrossStageComparisonChart, } from '@/components/charts' import { CsvExportDialog } from '@/components/shared/csv-export-dialog' import { ExportPdfButton } from '@/components/shared/export-pdf-button' @@ -621,29 +618,8 @@ function ScoresTab({ selectedValue }: { selectedValue: string }) { const { data: criteriaScores, isLoading: criteriaLoading } = trpc.analytics.getCriteriaScores.useQuery(queryInput, { enabled: hasSelection }) - const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeStages: true }) - - const crossStageRounds = programs?.flatMap(p => - ((p.stages || []) as Array<{ id: string; name: string }>).map(s => ({ - id: s.id, - name: s.name, - programName: `${p.year} Edition`, - })) - ) ?? [] - - const [selectedRoundIds, setSelectedRoundIds] = useState([]) - - const { data: comparison, isLoading: comparisonLoading } = - trpc.analytics.getCrossRoundComparison.useQuery( - { roundIds: selectedRoundIds }, - { enabled: selectedRoundIds.length >= 2 } - ) - - const toggleRound = (roundId: string) => { - setSelectedRoundIds((prev) => - prev.includes(roundId) ? prev.filter((id) => id !== roundId) : [...prev, roundId] - ) - } + const { data: overviewStats } = + trpc.analytics.getOverviewStats.useQuery(queryInput, { enabled: hasSelection }) const [csvOpen, setCsvOpen] = useState(false) const [csvData, setCsvData] = useState<{ data: Record[]; columns: string[] } | undefined>() @@ -665,12 +641,63 @@ 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 } + })() + return (

Scores & Analytics

-

Score distributions, criteria breakdown and cross-round comparison

+

Score distributions, criteria breakdown and insights

+ )} + {project.phase2SubmissionUrl && ( + + )} +
+
+ )} + + {/* AI-Assigned Expertise Tags */} {project.projectTags && project.projectTags.length > 0 && ( -
-

- Expertise Tags -

+
+

Expertise Tags

{project.projectTags.map((pt) => ( {pt.tag.name} {pt.confidence < 1 && ( @@ -406,6 +462,56 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
)} + + {/* Simple Tags (legacy) */} + {project.tags && project.tags.length > 0 && ( +
+

Tags

+
+ {project.tags.map((tag: string) => ( + {tag} + ))} +
+
+ )} + + {/* Internal Info */} + {(project.internalComments || project.applicationStatus || project.referralSource) && ( +
+

Internal Notes

+
+ {project.applicationStatus && ( +
+

Application Status

+

{project.applicationStatus}

+
+ )} + {project.referralSource && ( +
+

Referral Source

+

{project.referralSource}

+
+ )} +
+ {project.internalComments && ( +
+

Comments

+

{project.internalComments}

+
+ )} +
+ )} + +
+
+ Created:{' '} + {formatDateOnly(project.createdAt)} +
+
+ Updated:{' '} + {formatDateOnly(project.updatedAt)} +
+
@@ -416,10 +522,46 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) { const s = roundStateMap.get(r.id) return s && (s.state === 'PASSED' || s.state === 'COMPLETED') }).length - const rejectedRound = competitionRounds.find((r) => { + + // Find the rejection round — either explicit REJECTED state or inferred + const isProjectRejected = project.status === 'REJECTED' + const explicitRejectedRound = competitionRounds.find((r) => { const s = roundStateMap.get(r.id) return s?.state === 'REJECTED' }) + + // If project is globally rejected but no round has explicit REJECTED state, + // infer the rejection round as the furthest round the project reached + let inferredRejectionRoundId: string | null = null + if (isProjectRejected && !explicitRejectedRound) { + // Find the last round that has any state record (furthest the project got) + for (let i = competitionRounds.length - 1; i >= 0; i--) { + const s = roundStateMap.get(competitionRounds[i].id) + if (s) { + // If it's PASSED/COMPLETED, rejection happened at the next round + if (s.state === 'PASSED' || s.state === 'COMPLETED') { + if (i + 1 < competitionRounds.length) { + inferredRejectionRoundId = competitionRounds[i + 1].id + } + } else { + // PENDING/IN_PROGRESS in this round means rejected here + inferredRejectionRoundId = competitionRounds[i].id + } + break + } + } + } + + const rejectedRound = explicitRejectedRound + ?? (inferredRejectionRoundId + ? competitionRounds.find((r) => r.id === inferredRejectionRoundId) + : null) + + // Determine which rounds are "not reached" (after rejection point) + const rejectedRoundIdx = rejectedRound + ? competitionRounds.findIndex((r) => r.id === rejectedRound.id) + : -1 + return ( @@ -438,9 +580,18 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
    - {competitionRounds.map((round) => { + {competitionRounds.map((round, idx) => { const roundState = roundStateMap.get(round.id) - const state = roundState?.state + const rawState = roundState?.state + + // Override state for inferred rejection + const isRejectionRound = round.id === rejectedRound?.id + const isNotReached = rejectedRoundIdx >= 0 && idx > rejectedRoundIdx + const effectiveState = isRejectionRound && !explicitRejectedRound + ? 'REJECTED' + : isNotReached + ? 'NOT_REACHED' + : rawState const roundAssignments = assignments.filter( (a) => a.roundId === round.id, @@ -448,13 +599,16 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) { let icon: React.ReactNode let statusLabel: string | null = null - if (state === 'PASSED' || state === 'COMPLETED') { + let labelClass = 'text-muted-foreground' + + if (effectiveState === 'PASSED' || effectiveState === 'COMPLETED') { icon = statusLabel = 'Passed' - } else if (state === 'REJECTED') { - icon = + } else if (effectiveState === 'REJECTED') { + icon = statusLabel = 'Rejected at this round' - } else if (state === 'IN_PROGRESS') { + labelClass = 'text-red-600 font-medium' + } else if (effectiveState === 'IN_PROGRESS') { icon = ( @@ -464,7 +618,11 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) { ) statusLabel = 'Active' - } else if (state === 'PENDING') { + } else if (effectiveState === 'NOT_REACHED') { + icon = + statusLabel = 'Not reached' + labelClass = 'text-muted-foreground/50 italic' + } else if (effectiveState === 'PENDING') { icon = statusLabel = 'Pending' } else { @@ -472,15 +630,18 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) { } return ( -
  1. +
  2. {icon}
    -

    {round.name}

    +

    {round.name}

    {statusLabel && ( -

    +

    {statusLabel}

    )} @@ -490,7 +651,7 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {

    )}
    - {state === 'IN_PROGRESS' && ( + {effectiveState === 'IN_PROGRESS' && ( )} + {effectiveState === 'REJECTED' && ( + + Rejected + + )}
  3. ) })} diff --git a/src/server/routers/analytics.ts b/src/server/routers/analytics.ts index 3503d26..4bbbed1 100644 --- a/src/server/routers/analytics.ts +++ b/src/server/routers/analytics.ts @@ -1317,6 +1317,21 @@ export const analyticsRouter = router({ select: { roundId: true, state: true, enteredAt: true, exitedAt: true }, }) + // Get filtering result (AI screening) for rejected projects + const filteringResult = projectRaw.status === 'REJECTED' + ? await ctx.prisma.filteringResult.findFirst({ + where: { projectId: input.id }, + select: { + outcome: true, + finalOutcome: true, + aiScreeningJson: true, + overrideReason: true, + round: { select: { id: true, name: true } }, + }, + orderBy: { createdAt: 'desc' }, + }) + : null + // Get file requirements for all rounds let allRequirements: { id: string; roundId: string; name: string; description: string | null; isRequired: boolean; acceptedMimeTypes: string[]; maxSizeMB: number | null }[] = [] if (competitionRounds.length > 0) { @@ -1360,6 +1375,7 @@ export const analyticsRouter = router({ competitionRounds, projectRoundStates, allRequirements, + filteringResult, } }),