Platform-wide visual overhaul, team invites, analytics improvements, and deployment hardening

UI overhaul applying jury dashboard design patterns across all pages:
- Stat cards with border-l-4 accent + icon pills on admin, observer, mentor, applicant dashboards and reports
- Card section headers with color-coded icon pills throughout
- Hover lift effects (translate-y + shadow) on cards and list items
- Gradient progress bars (brand-teal to brand-blue) platform-wide
- AnimatedCard stagger animations on all dashboard sections
- Auth pages with gradient accent strip and polished icon containers
- EmptyState component upgraded with rounded icon pill containers
- Replaced AI-looking icons (Brain/Sparkles/Bot/Wand2/Cpu) with descriptive alternatives across 12 files
- Removed gradient overlay from jury dashboard header
- Quick actions restyled as card links with group hover effects

Backend improvements:
- Team member invite emails with account setup flow and notification logging
- Analytics routers accept edition-wide queries (programId) in addition to roundId
- Round detail endpoint returns inline progress data (eliminates extra getProgress call)
- Award voting endpoints parallelized with Promise.all
- Bulk invite supports optional sendInvitation flag
- AwardVote composite index migration for query performance

Infrastructure:
- Docker entrypoint with migration retry loop (configurable retries/delay)
- docker-compose pull_policy: always for automatic image refresh
- Simplified deploy/update scripts using docker compose up -d --pull always
- Updated deployment documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 13:20:52 +01:00
parent 98f4a957cc
commit ce4069bf92
59 changed files with 1949 additions and 913 deletions

View File

@@ -64,14 +64,14 @@ import {
CheckCircle2,
Clock,
AlertCircle,
Sparkles,
Shuffle,
Loader2,
Plus,
Trash2,
RefreshCw,
UserPlus,
Cpu,
Brain,
Calculator,
Workflow,
Search,
ChevronsUpDown,
Check,
@@ -829,7 +829,7 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) {
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Sparkles className="h-5 w-5 text-amber-500" />
<Shuffle className="h-5 w-5 text-amber-500" />
Smart Assignment Suggestions
</CardTitle>
<CardDescription>
@@ -844,7 +844,7 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) {
<div className="flex items-center justify-between mb-4">
<TabsList>
<TabsTrigger value="algorithm" className="gap-2">
<Cpu className="h-4 w-4" />
<Calculator className="h-4 w-4" />
Algorithm
{algorithmicSuggestions && algorithmicSuggestions.length > 0 && (
<Badge variant="secondary" className="ml-1 text-xs">
@@ -853,7 +853,7 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) {
)}
</TabsTrigger>
<TabsTrigger value="ai" className="gap-2" disabled={!isAIAvailable && !hasStoredAISuggestions}>
<Brain className="h-4 w-4" />
<Workflow className="h-4 w-4" />
AI Powered
{aiSuggestions.length > 0 && (
<Badge variant="secondary" className="ml-1 text-xs">
@@ -983,7 +983,7 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) {
/>
) : !hasStoredAISuggestions ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Brain className="h-12 w-12 text-muted-foreground/50" />
<Workflow className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No AI analysis yet</p>
<p className="text-sm text-muted-foreground mb-4">
Click &quot;Start Analysis&quot; to generate AI-powered suggestions
@@ -995,7 +995,7 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) {
{startAIJob.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Brain className="mr-2 h-4 w-4" />
<Workflow className="mr-2 h-4 w-4" />
)}
Start AI Analysis
</Button>

View File

@@ -41,7 +41,7 @@ import {
GripVertical,
Loader2,
FileCheck,
Brain,
SlidersHorizontal,
Filter,
} from 'lucide-react'
@@ -56,7 +56,7 @@ const RULE_TYPE_LABELS: Record<RuleType, string> = {
const RULE_TYPE_ICONS: Record<RuleType, React.ReactNode> = {
FIELD_BASED: <Filter className="h-4 w-4" />,
DOCUMENT_CHECK: <FileCheck className="h-4 w-4" />,
AI_SCREENING: <Brain className="h-4 w-4" />,
AI_SCREENING: <SlidersHorizontal className="h-4 w-4" />,
}
const FIELD_OPTIONS = [

View File

@@ -81,13 +81,17 @@ import {
AlertTriangle,
ListChecks,
ClipboardCheck,
Sparkles,
FileSearch,
LayoutTemplate,
ShieldCheck,
Download,
RotateCcw,
Zap,
QrCode,
ExternalLink,
} from 'lucide-react'
import { toast } from 'sonner'
import { AnimatedCard } from '@/components/shared/animated-container'
import { AssignProjectsDialog } from '@/components/admin/assign-projects-dialog'
import { AdvanceProjectsDialog } from '@/components/admin/advance-projects-dialog'
import { RemoveProjectsDialog } from '@/components/admin/remove-projects-dialog'
@@ -126,6 +130,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
const [advanceOpen, setAdvanceOpen] = useState(false)
const [removeOpen, setRemoveOpen] = useState(false)
const [activeJobId, setActiveJobId] = useState<string | null>(null)
const [jobPollInterval, setJobPollInterval] = useState(2000)
// Inline filtering results state
const [outcomeFilter, setOutcomeFilter] = useState<string>('')
@@ -140,7 +145,8 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
const [showExportDialog, setShowExportDialog] = useState(false)
const { data: round, isLoading, refetch: refetchRound } = trpc.round.get.useQuery({ id: roundId })
const { data: progress } = trpc.round.getProgress.useQuery({ id: roundId })
// Progress data is now included in round.get response (eliminates duplicate evaluation.groupBy)
const progress = round?.progress
// Check if this is a filtering round - roundType is stored directly on the round
const isFilteringRound = round?.roundType === 'FILTERING'
@@ -149,7 +155,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
const { data: filteringStats, isLoading: isLoadingFilteringStats, refetch: refetchFilteringStats } =
trpc.filtering.getResultStats.useQuery(
{ roundId },
{ enabled: isFilteringRound, staleTime: 0 }
{ enabled: isFilteringRound, staleTime: 30_000 }
)
const { data: filteringRules } = trpc.filtering.getRules.useQuery(
{ roundId },
@@ -162,31 +168,41 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
const { data: latestJob, refetch: refetchLatestJob } =
trpc.filtering.getLatestJob.useQuery(
{ roundId },
{ enabled: isFilteringRound, staleTime: 0 }
{ enabled: isFilteringRound, staleTime: 30_000 }
)
// Poll for job status when there's an active job
// Poll for job status with exponential backoff (2s → 4s → 8s → 15s cap)
const { data: jobStatus } = trpc.filtering.getJobStatus.useQuery(
{ jobId: activeJobId! },
{
enabled: !!activeJobId,
refetchInterval: activeJobId ? 2000 : false,
refetchInterval: activeJobId ? jobPollInterval : false,
refetchIntervalInBackground: false,
staleTime: 0,
}
)
// Increase polling interval over time (exponential backoff)
useEffect(() => {
if (!activeJobId) {
setJobPollInterval(2000)
return
}
const timer = setTimeout(() => {
setJobPollInterval((prev) => Math.min(prev * 2, 15000))
}, jobPollInterval)
return () => clearTimeout(timer)
}, [activeJobId, jobPollInterval])
const utils = trpc.useUtils()
const updateStatus = trpc.round.updateStatus.useMutation({
onSuccess: () => {
utils.round.get.invalidate({ id: roundId })
utils.round.list.invalidate()
utils.program.list.invalidate({ includeRounds: true })
},
})
const deleteRound = trpc.round.delete.useMutation({
onSuccess: () => {
toast.success('Round deleted')
utils.program.list.invalidate()
utils.round.list.invalidate()
router.push('/admin/rounds')
},
@@ -200,7 +216,6 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
const finalizeResults = trpc.filtering.finalizeResults.useMutation({
onSuccess: () => {
utils.round.get.invalidate({ id: roundId })
utils.project.list.invalidate()
},
})
@@ -218,7 +233,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
},
{
enabled: isFilteringRound && (filteringStats?.total ?? 0) > 0,
staleTime: 0,
staleTime: 30_000,
}
)
const overrideResult = trpc.filtering.overrideResult.useMutation()
@@ -286,6 +301,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
const handleStartFiltering = async () => {
try {
const result = await startJob.mutateAsync({ roundId })
setJobPollInterval(2000)
setActiveJobId(result.jobId)
toast.info('Filtering job started. Progress will update automatically.')
} catch (error) {
@@ -309,8 +325,6 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
}
refetchFilteringStats()
refetchRound()
utils.project.list.invalidate()
utils.program.list.invalidate({ includeRounds: true })
utils.round.get.invalidate({ id: roundId })
} catch (error) {
toast.error(
@@ -340,7 +354,6 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
setOverrideReason('')
refetchResults()
refetchFilteringStats()
utils.project.list.invalidate()
} catch {
toast.error('Failed to override result')
}
@@ -352,7 +365,6 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
toast.success('Project reinstated')
refetchResults()
refetchFilteringStats()
utils.project.list.invalidate()
} catch {
toast.error('Failed to reinstate project')
}
@@ -548,11 +560,14 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
<Separator />
{/* Stats Grid */}
<AnimatedCard index={0}>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Card>
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Projects</CardTitle>
<FileText className="h-4 w-4 text-muted-foreground" />
<div className="rounded-lg bg-emerald-500/10 p-1.5">
<FileText className="h-4 w-4 text-emerald-500" />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{round._count.projects}</div>
@@ -562,10 +577,12 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
</CardContent>
</Card>
<Card>
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Judge Assignments</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
<div className="rounded-lg bg-violet-500/10 p-1.5">
<Users className="h-4 w-4 text-violet-500" />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{round._count.assignments}</div>
@@ -577,10 +594,12 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
</CardContent>
</Card>
<Card>
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Required Reviews</CardTitle>
<BarChart3 className="h-4 w-4 text-muted-foreground" />
<div className="rounded-lg bg-blue-500/10 p-1.5">
<BarChart3 className="h-4 w-4 text-blue-500" />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{round.requiredReviews}</div>
@@ -588,10 +607,12 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
</CardContent>
</Card>
<Card>
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Completion</CardTitle>
<CheckCircle2 className="h-4 w-4 text-muted-foreground" />
<div className="rounded-lg bg-brand-teal/10 p-1.5">
<CheckCircle2 className="h-4 w-4 text-brand-teal" />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
@@ -603,12 +624,19 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
</CardContent>
</Card>
</div>
</AnimatedCard>
{/* Progress */}
{progress && progress.totalAssignments > 0 && (
<AnimatedCard index={1}>
<Card>
<CardHeader>
<CardTitle className="text-lg">Evaluation Progress</CardTitle>
<CardTitle className="flex items-center gap-2.5 text-lg">
<div className="rounded-lg bg-brand-teal/10 p-1.5">
<BarChart3 className="h-4 w-4 text-brand-teal" />
</div>
Evaluation Progress
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
@@ -616,7 +644,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
<span>Overall Completion</span>
<span>{progress.completionPercentage}%</span>
</div>
<Progress value={progress.completionPercentage} />
<Progress value={progress.completionPercentage} gradient />
</div>
<div className="grid gap-4 sm:grid-cols-4">
@@ -631,12 +659,19 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
</div>
</CardContent>
</Card>
</AnimatedCard>
)}
{/* Voting Window */}
<AnimatedCard index={2}>
<Card>
<CardHeader>
<CardTitle className="text-lg">Voting Window</CardTitle>
<CardTitle className="flex items-center gap-2.5 text-lg">
<div className="rounded-lg bg-blue-500/10 p-1.5">
<Clock className="h-4 w-4 text-blue-500" />
</div>
Voting Window
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
@@ -723,15 +758,19 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
)}
</CardContent>
</Card>
</AnimatedCard>
{/* Filtering Section (for FILTERING rounds) */}
{isFilteringRound && (
<AnimatedCard index={3}>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-lg flex items-center gap-2">
<Filter className="h-5 w-5" />
<CardTitle className="flex items-center gap-2.5 text-lg">
<div className="rounded-lg bg-amber-500/10 p-1.5">
<Filter className="h-4 w-4 text-amber-500" />
</div>
Project Filtering
</CardTitle>
<CardDescription>
@@ -782,7 +821,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
{progressPercent}%
</span>
</div>
<Progress value={progressPercent} className="h-2" />
<Progress value={progressPercent} className="h-2" gradient />
</div>
</div>
</div>
@@ -1226,12 +1265,19 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
</div>
</CardContent>
</Card>
</AnimatedCard>
)}
{/* Quick Actions */}
<AnimatedCard index={4}>
<Card>
<CardHeader>
<CardTitle className="text-lg">Quick Actions</CardTitle>
<CardTitle className="flex items-center gap-2.5 text-lg">
<div className="rounded-lg bg-amber-500/10 p-1.5">
<Zap className="h-4 w-4 text-amber-500" />
</div>
Quick Actions
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Project Management */}
@@ -1275,6 +1321,12 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
Jury Assignments
</Link>
</Button>
<Button variant="outline" size="sm" asChild>
<Link href={`/admin/rounds/${round.id}/live-voting`}>
<Zap className="mr-2 h-4 w-4" />
Live Voting
</Link>
</Button>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
@@ -1287,7 +1339,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
{bulkSummaries.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
<FileSearch className="mr-2 h-4 w-4" />
)}
{bulkSummaries.isPending ? 'Generating...' : 'Generate AI Summaries'}
</Button>
@@ -1319,6 +1371,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
</div>
</CardContent>
</Card>
</AnimatedCard>
{/* Dialogs */}
<AssignProjectsDialog

View File

@@ -66,6 +66,7 @@ import {
} from 'lucide-react'
import { format, isPast, isFuture } from 'date-fns'
import { cn } from '@/lib/utils'
import { AnimatedCard } from '@/components/shared/animated-container'
type RoundData = {
id: string
@@ -108,8 +109,10 @@ function RoundsContent() {
return (
<div className="space-y-6">
{programs.map((program) => (
<ProgramRounds key={program.id} program={program} />
{programs.map((program, index) => (
<AnimatedCard key={program.id} index={index}>
<ProgramRounds program={program} />
</AnimatedCard>
))}
</div>
)
@@ -485,7 +488,7 @@ function SortableRoundRow({
ref={setNodeRef}
style={style}
className={cn(
'rounded-lg border bg-card transition-all',
'rounded-lg border bg-card transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
isDragging && 'shadow-lg ring-2 ring-primary/20 z-50 opacity-90',
isReordering && !isDragging && 'opacity-50'
)}