Observer: fix round history, match admin project info, add AI rejection reason
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m30s

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-02-20 23:30:14 +01:00
parent 9f7b76b3cb
commit d717040f03
3 changed files with 443 additions and 159 deletions

View File

@@ -28,6 +28,7 @@ import {
Calendar,
CheckCircle2,
Circle,
XCircle,
BarChart3,
ThumbsUp,
ThumbsDown,
@@ -36,7 +37,6 @@ import {
GraduationCap,
Heart,
Clock,
Sparkles,
MessageSquare,
} from 'lucide-react'
import { cn, formatDate, formatDateOnly } from '@/lib/utils'
@@ -85,7 +85,7 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
)
}
const { project, assignments, stats, competitionRounds, projectRoundStates, allRequirements } =
const { project, assignments, stats, competitionRounds, projectRoundStates, allRequirements, filteringResult } =
data
const roundStateMap = new Map(
@@ -301,19 +301,50 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
</AnimatedCard>
)}
{/* AI Synthesis placeholder */}
<AnimatedCard index={1}>
<Card className="border-dashed">
<CardContent className="flex flex-col items-center justify-center py-10 text-center">
<Sparkles className="h-8 w-8 text-muted-foreground/40" />
<p className="mt-3 text-sm font-medium text-muted-foreground">
AI synthesis will appear here when available
</p>
</CardContent>
</Card>
</AnimatedCard>
{/* AI Rejection Reason */}
{project.status === 'REJECTED' && filteringResult?.aiScreeningJson && (() => {
const screening = filteringResult.aiScreeningJson as Record<string, Record<string, unknown>>
// Extract reasoning from the first rule's result
const firstRule = Object.values(screening)[0]
const reasoning = firstRule?.reasoning as string | undefined
const confidence = firstRule?.confidence as number | undefined
if (!reasoning) return null
return (
<AnimatedCard index={1}>
<Card className="border-red-200 bg-red-50/50">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2.5 text-lg text-red-700">
<div className="rounded-lg bg-red-100 p-1.5">
<AlertCircle className="h-4 w-4 text-red-600" />
</div>
AI Screening Rejected
{filteringResult.round && (
<span className="text-sm font-normal text-red-500 ml-auto">
at {filteringResult.round.name}
</span>
)}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-red-800 whitespace-pre-wrap">{reasoning}</p>
{confidence != null && (
<p className="mt-2 text-xs text-red-500">
AI Confidence: {Math.round(confidence * 100)}%
</p>
)}
{filteringResult.overrideReason && (
<div className="mt-3 border-t border-red-200 pt-3">
<p className="text-xs font-medium text-red-600">Override Reason</p>
<p className="text-sm text-red-800">{filteringResult.overrideReason}</p>
</div>
)}
</CardContent>
</Card>
</AnimatedCard>
)
})()}
{/* Project Info */}
{/* Project Info — matches admin layout */}
<AnimatedCard index={2}>
<Card>
<CardHeader>
@@ -324,76 +355,101 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
Project Information
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-x-6 gap-y-4 sm:grid-cols-2">
<div>
<p className="text-xs text-muted-foreground">Submitted</p>
<p className="text-sm font-medium">
{formatDateOnly(project.createdAt)}
</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Category</p>
<p className="text-sm font-medium">
{project.competitionCategory
? project.competitionCategory === 'STARTUP'
? 'Start-up'
: 'Business Concept'
: '-'}
</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Country</p>
<p className="text-sm font-medium">
{project.country || project.geographicZone || '-'}
</p>
</div>
<div>
<p className="text-xs text-muted-foreground">AI Score</p>
<p className="text-sm font-medium">-</p>
</div>
<div>
<p className="text-xs text-muted-foreground">
Last Updated
</p>
<p className="text-sm font-medium">
{formatDateOnly(project.updatedAt)}
</p>
</div>
</div>
{project.wantsMentorship && (
<div className="mt-4">
<Badge
variant="outline"
className="gap-1 border-pink-200 bg-pink-50 text-pink-600"
>
<CardContent className="space-y-4">
{/* Category & Ocean Issue badges */}
<div className="flex flex-wrap gap-2">
{project.competitionCategory && (
<Badge variant="outline" className="gap-1">
<GraduationCap className="h-3 w-3" />
{project.competitionCategory === 'STARTUP' ? 'Start-up' : 'Business Concept'}
</Badge>
)}
{project.oceanIssue && (
<Badge variant="outline" className="gap-1">
<Waves className="h-3 w-3" />
{project.oceanIssue.replace(/_/g, ' ')}
</Badge>
)}
{project.wantsMentorship && (
<Badge variant="outline" className="gap-1 text-pink-600 border-pink-200 bg-pink-50">
<Heart className="h-3 w-3" />
Wants Mentorship
</Badge>
)}
</div>
{project.description && (
<div>
<p className="text-sm font-medium text-muted-foreground mb-1">Description</p>
<p className="text-sm whitespace-pre-wrap">{project.description}</p>
</div>
)}
{/* Expertise Tags */}
{/* Location, Institution, Founded */}
<div className="grid gap-4 sm:grid-cols-2">
{(project.country || project.geographicZone) && (
<div className="flex items-start gap-2">
<MapPin className="h-4 w-4 text-muted-foreground mt-0.5" />
<div>
<p className="text-sm font-medium text-muted-foreground">Location</p>
<p className="text-sm">{project.geographicZone || project.country}</p>
</div>
</div>
)}
{project.institution && (
<div className="flex items-start gap-2">
<GraduationCap className="h-4 w-4 text-muted-foreground mt-0.5" />
<div>
<p className="text-sm font-medium text-muted-foreground">Institution</p>
<p className="text-sm">{project.institution}</p>
</div>
</div>
)}
{project.foundedAt && (
<div className="flex items-start gap-2">
<Calendar className="h-4 w-4 text-muted-foreground mt-0.5" />
<div>
<p className="text-sm font-medium text-muted-foreground">Founded</p>
<p className="text-sm">{formatDateOnly(project.foundedAt)}</p>
</div>
</div>
)}
</div>
{/* Submission URLs */}
{(project.phase1SubmissionUrl || project.phase2SubmissionUrl) && (
<div className="space-y-2">
<p className="text-sm font-medium text-muted-foreground">Submission Links</p>
<div className="flex flex-wrap gap-2">
{project.phase1SubmissionUrl && (
<Button variant="outline" size="sm" asChild>
<a href={project.phase1SubmissionUrl} target="_blank" rel="noopener noreferrer">
Phase 1 Submission
</a>
</Button>
)}
{project.phase2SubmissionUrl && (
<Button variant="outline" size="sm" asChild>
<a href={project.phase2SubmissionUrl} target="_blank" rel="noopener noreferrer">
Phase 2 Submission
</a>
</Button>
)}
</div>
</div>
)}
{/* AI-Assigned Expertise Tags */}
{project.projectTags && project.projectTags.length > 0 && (
<div className="mt-4">
<p className="mb-2 text-xs text-muted-foreground">
Expertise Tags
</p>
<div>
<p className="text-sm font-medium text-muted-foreground mb-2">Expertise Tags</p>
<div className="flex flex-wrap gap-2">
{project.projectTags.map((pt) => (
<Badge
key={pt.tag.id}
variant="secondary"
className="flex items-center gap-1"
style={
pt.tag.color
? {
backgroundColor: `${pt.tag.color}20`,
borderColor: pt.tag.color,
}
: undefined
}
style={pt.tag.color ? { backgroundColor: `${pt.tag.color}20`, borderColor: pt.tag.color } : undefined}
>
{pt.tag.name}
{pt.confidence < 1 && (
@@ -406,6 +462,56 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
</div>
</div>
)}
{/* Simple Tags (legacy) */}
{project.tags && project.tags.length > 0 && (
<div>
<p className="text-sm font-medium text-muted-foreground mb-2">Tags</p>
<div className="flex flex-wrap gap-2">
{project.tags.map((tag: string) => (
<Badge key={tag} variant="secondary">{tag}</Badge>
))}
</div>
</div>
)}
{/* Internal Info */}
{(project.internalComments || project.applicationStatus || project.referralSource) && (
<div className="border-t pt-4 mt-4">
<p className="text-sm font-medium text-muted-foreground mb-3">Internal Notes</p>
<div className="grid gap-3 sm:grid-cols-2">
{project.applicationStatus && (
<div>
<p className="text-xs text-muted-foreground">Application Status</p>
<p className="text-sm">{project.applicationStatus}</p>
</div>
)}
{project.referralSource && (
<div>
<p className="text-xs text-muted-foreground">Referral Source</p>
<p className="text-sm">{project.referralSource}</p>
</div>
)}
</div>
{project.internalComments && (
<div className="mt-3">
<p className="text-xs text-muted-foreground">Comments</p>
<p className="text-sm whitespace-pre-wrap">{project.internalComments}</p>
</div>
)}
</div>
)}
<div className="flex flex-wrap gap-6 text-sm pt-2">
<div>
<span className="text-muted-foreground">Created:</span>{' '}
{formatDateOnly(project.createdAt)}
</div>
<div>
<span className="text-muted-foreground">Updated:</span>{' '}
{formatDateOnly(project.updatedAt)}
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
@@ -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 (
<AnimatedCard index={3}>
<Card>
@@ -438,9 +580,18 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
</CardHeader>
<CardContent>
<ol className="space-y-4">
{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 = <CheckCircle2 className="mt-0.5 h-5 w-5 shrink-0 text-emerald-500" />
statusLabel = 'Passed'
} else if (state === 'REJECTED') {
icon = <AlertCircle className="mt-0.5 h-5 w-5 shrink-0 text-destructive" />
} else if (effectiveState === 'REJECTED') {
icon = <XCircle className="mt-0.5 h-5 w-5 shrink-0 text-red-500" />
statusLabel = 'Rejected at this round'
} else if (state === 'IN_PROGRESS') {
labelClass = 'text-red-600 font-medium'
} else if (effectiveState === 'IN_PROGRESS') {
icon = (
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center">
<span className="relative flex h-3 w-3">
@@ -464,7 +618,11 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
</span>
)
statusLabel = 'Active'
} else if (state === 'PENDING') {
} else if (effectiveState === 'NOT_REACHED') {
icon = <Circle className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground/15" />
statusLabel = 'Not reached'
labelClass = 'text-muted-foreground/50 italic'
} else if (effectiveState === 'PENDING') {
icon = <Circle className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground/40" />
statusLabel = 'Pending'
} else {
@@ -472,15 +630,18 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
}
return (
<li key={round.id} className="flex items-start gap-3">
<li key={round.id} className={cn(
'flex items-start gap-3',
effectiveState === 'NOT_REACHED' && 'opacity-50',
)}>
{icon}
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{round.name}</p>
<p className={cn(
'text-sm font-medium',
effectiveState === 'NOT_REACHED' && 'text-muted-foreground',
)}>{round.name}</p>
{statusLabel && (
<p className={cn(
'text-xs',
state === 'REJECTED' ? 'text-destructive' : 'text-muted-foreground',
)}>
<p className={cn('text-xs', labelClass)}>
{statusLabel}
</p>
)}
@@ -490,7 +651,7 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
</p>
)}
</div>
{state === 'IN_PROGRESS' && (
{effectiveState === 'IN_PROGRESS' && (
<Badge
variant="outline"
className="ml-auto shrink-0 border-blue-200 bg-blue-50 text-blue-600 text-xs"
@@ -498,6 +659,14 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
Active
</Badge>
)}
{effectiveState === 'REJECTED' && (
<Badge
variant="destructive"
className="ml-auto shrink-0 text-xs"
>
Rejected
</Badge>
)}
</li>
)
})}