feat: round finalization with ranking-based outcomes + award pool notifications
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m0s

- processRoundClose EVALUATION uses ranking scores + advanceMode config
  (threshold vs count) to auto-set proposedOutcome instead of defaulting all to PASSED
- Advancement emails generate invite tokens for passwordless users with
  "Create Your Account" CTA; rejection emails have no link
- Finalization UI shows account stats (invite vs dashboard link counts)
- Fixed getFinalizationSummary ranking query (was using non-existent rankingsJson)
- New award pool notification system: getAwardSelectionNotificationTemplate email,
  notifyEligibleProjects mutation with invite token generation,
  "Notify Pool" button on award detail page with custom message dialog

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 19:14:41 +01:00
parent 7735f3ecdf
commit cfee3bc8a9
48 changed files with 5294 additions and 676 deletions

View File

@@ -4,7 +4,8 @@ import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { CheckCircle2, Circle, Clock, XCircle, Trophy } from 'lucide-react'
import { CheckCircle2, Circle, Clock, XCircle, Trophy, Check } from 'lucide-react'
import { cn } from '@/lib/utils'
const roundStatusDisplay: Record<string, { label: string; variant: 'default' | 'secondary' }> = {
ROUND_DRAFT: { label: 'Upcoming', variant: 'secondary' },
@@ -166,7 +167,7 @@ export function ApplicantCompetitionTimeline() {
/**
* Compact sidebar variant for the dashboard.
* Shows dots + labels, no date details.
* Animated timeline with connector indicators between dots.
*/
export function CompetitionTimelineSidebar() {
const { data, isLoading } = trpc.applicant.getMyCompetitionTimeline.useQuery()
@@ -185,54 +186,123 @@ export function CompetitionTimelineSidebar() {
return <p className="text-sm text-muted-foreground">No rounds available</p>
}
// Find the index where elimination happened (first REJECTED entry)
const eliminationIndex = data.entries.findIndex((e) => e.projectState === 'REJECTED')
return (
<div className="space-y-0">
{data.entries.map((entry, index) => {
const isCompleted = entry.status === 'ROUND_CLOSED' || entry.status === 'ROUND_ARCHIVED'
const isActive = entry.status === 'ROUND_ACTIVE'
const isRejected = entry.projectState === 'REJECTED'
const isGrandFinale = entry.roundType === 'GRAND_FINALE'
const isLast = index === data.entries.length - 1
<div className="relative">
<div className="space-y-0">
{data.entries.map((entry, index) => {
const isCompleted = entry.status === 'ROUND_CLOSED' || entry.status === 'ROUND_ARCHIVED'
const isActive = entry.status === 'ROUND_ACTIVE'
const isRejected = entry.projectState === 'REJECTED'
const isGrandFinale = entry.roundType === 'GRAND_FINALE'
const isPassed = entry.projectState === 'PASSED' || entry.projectState === 'COMPLETED'
const isLast = index === data.entries.length - 1
// Is this entry after the elimination point?
const isAfterElimination = eliminationIndex >= 0 && index > eliminationIndex
let dotColor = 'border-2 border-muted bg-background'
if (isRejected) dotColor = 'bg-destructive'
else if (isGrandFinale && isCompleted) dotColor = 'bg-yellow-500'
else if (isCompleted) dotColor = 'bg-primary'
else if (isActive) dotColor = 'bg-primary ring-2 ring-primary/30'
// Is this the current round the project is in (regardless of round status)?
const isCurrent = !!entry.projectState && entry.projectState !== 'PASSED' && entry.projectState !== 'COMPLETED' && entry.projectState !== 'REJECTED'
return (
<div key={entry.id} className="relative flex gap-3">
{/* Connecting line */}
{!isLast && (
<div className="absolute left-[7px] top-[20px] h-full w-0.5 bg-muted" />
)}
// Determine connector segment color (no icons, just colored lines)
let connectorColor = 'bg-border'
if ((isPassed || isCompleted) && !isAfterElimination) connectorColor = 'bg-emerald-400'
else if (isRejected) connectorColor = 'bg-destructive/30'
{/* Dot */}
<div className={`relative z-10 mt-1.5 h-4 w-4 rounded-full shrink-0 ${dotColor}`} />
// Dot inner content
let dotInner: React.ReactNode = null
let dotClasses = 'border-2 border-muted-foreground/20 bg-background'
{/* Label */}
<div className="flex-1 pb-4">
<p
className={`text-sm font-medium ${
isRejected
? 'text-destructive'
: isCompleted || isActive
? 'text-foreground'
: 'text-muted-foreground'
}`}
>
{entry.label}
</p>
{isRejected && (
<p className="text-xs text-destructive">Not Selected</p>
)}
{isActive && (
<p className="text-xs text-primary">In Progress</p>
if (isAfterElimination) {
dotClasses = 'border-2 border-muted/60 bg-muted/30'
} else if (isRejected) {
dotClasses = 'bg-destructive border-2 border-destructive'
dotInner = <XCircle className="h-3.5 w-3.5 text-white" />
} else if (isGrandFinale && (isCompleted || isPassed)) {
dotClasses = 'bg-yellow-500 border-2 border-yellow-500'
dotInner = <Trophy className="h-3.5 w-3.5 text-white" />
} else if (isCompleted || isPassed) {
dotClasses = 'bg-emerald-500 border-2 border-emerald-500'
dotInner = <Check className="h-3.5 w-3.5 text-white" />
} else if (isCurrent) {
dotClasses = 'bg-amber-400 border-2 border-amber-400'
dotInner = <span className="h-2.5 w-2.5 rounded-full bg-white animate-ping" style={{ animationDuration: '2s' }} />
}
// Status sub-label
let statusLabel: string | null = null
let statusColor = 'text-muted-foreground'
if (isRejected) {
statusLabel = 'Eliminated'
statusColor = 'text-destructive'
} else if (isAfterElimination) {
statusLabel = null
} else if (isPassed) {
statusLabel = 'Advanced'
statusColor = 'text-emerald-600'
} else if (isCurrent) {
statusLabel = 'You are here'
statusColor = 'text-amber-600'
}
return (
<div
key={entry.id}
className="animate-in fade-in slide-in-from-left-2 fill-mode-both"
style={{ animationDelay: `${index * 100}ms`, animationDuration: '400ms' }}
>
{/* Row: dot + label */}
<div className={cn(
'group relative flex items-center gap-3.5 rounded-lg px-1.5 py-1.5 -mx-1.5 transition-colors duration-200 hover:bg-muted/40',
isCurrent && 'bg-amber-50/60 hover:bg-amber-50/80',
)}>
{/* Dot */}
<div
className={cn(
'relative z-10 h-7 w-7 rounded-full shrink-0 flex items-center justify-center shadow-sm transition-all duration-300',
isCurrent && 'ring-4 ring-amber-400/25',
isPassed && !isRejected && !isAfterElimination && 'ring-[3px] ring-emerald-500/15',
dotClasses,
)}
>
{dotInner}
</div>
{/* Label */}
<div className="flex-1 min-w-0">
<p
className={cn('text-sm leading-5 font-medium transition-colors duration-200', {
'text-destructive line-through decoration-destructive/40': isRejected,
'text-muted-foreground/40': isAfterElimination,
'text-foreground font-semibold': isCurrent,
'text-foreground': !isCurrent && !isRejected && !isAfterElimination && (isCompleted || isActive || isPassed),
'text-muted-foreground': !isRejected && !isAfterElimination && !isCompleted && !isActive && !isPassed,
})}
>
{entry.label}
</p>
{statusLabel && (
<p className={cn('text-xs mt-0.5 font-semibold tracking-wide uppercase', statusColor)}>{statusLabel}</p>
)}
</div>
</div>
{/* Connector line between dots */}
{!isLast && (
<div className="flex items-center ml-[13px] h-5">
<div
className={cn(
'w-[2px] h-full rounded-full transition-all duration-500',
connectorColor,
)}
/>
</div>
)}
</div>
</div>
)
})}
)
})}
</div>
</div>
)
}