feat: round user tracker + fix INVITED status not updating on login
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled

- Replace Semi-Finalist Tracker with Round User Tracker on dashboard
- New getRoundUserStats query: round-aware account activation stats
- Round selector dropdown to view any round's passed projects
- sendAccountReminders now accepts optional roundId for scoped reminders
- Fix: signIn callback now sets status=ACTIVE for INVITED users on login
- DB fix: 5 users who logged in via magic link but stayed INVITED → ACTIVE

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 14:00:19 +01:00
parent ee8e90132e
commit 8cdcc85555
4 changed files with 335 additions and 21 deletions

View File

@@ -0,0 +1,198 @@
'use client'
import { useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Users,
Send,
CheckCircle2,
AlertCircle,
Loader2,
} from 'lucide-react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
const categoryLabels: Record<string, string> = {
STARTUP: 'Startup',
BUSINESS_CONCEPT: 'Business Concept',
UNKNOWN: 'Unknown',
}
type RoundUserTrackerProps = {
editionId: string
}
export function RoundUserTracker({ editionId }: RoundUserTrackerProps) {
const [selectedRoundId, setSelectedRoundId] = useState<string | undefined>(undefined)
const { data, isLoading } = trpc.dashboard.getRoundUserStats.useQuery(
{ editionId, roundId: selectedRoundId },
{ enabled: !!editionId, refetchInterval: 120_000 }
)
const utils = trpc.useUtils()
const sendReminders = trpc.dashboard.sendAccountReminders.useMutation({
onSuccess: (result) => {
toast.success(`Sent ${result.sent} reminder${result.sent !== 1 ? 's' : ''}${result.failed > 0 ? `, ${result.failed} failed` : ''}`)
utils.dashboard.getRoundUserStats.invalidate()
},
onError: (err) => {
toast.error(`Failed to send reminders: ${err.message}`)
},
})
const [sendingTarget, setSendingTarget] = useState<string | null>(null)
if (isLoading || !data) return null
const { rounds, byCategory } = data
const effectiveRoundId = data.selectedRoundId
// Don't render if no rounds or no data
if (!effectiveRoundId || rounds.length === 0) return null
const totalProjects = byCategory.reduce((sum, c) => sum + c.total, 0)
const totalActivated = byCategory.reduce((sum, c) => sum + c.accountsSet, 0)
const totalPending = byCategory.reduce((sum, c) => sum + c.accountsNotSet, 0)
if (totalProjects === 0) return null
const selectedRound = rounds.find(r => r.id === effectiveRoundId)
const handleSendReminder = async (target: string, opts: { category?: 'STARTUP' | 'BUSINESS_CONCEPT' }) => {
setSendingTarget(target)
try {
await sendReminders.mutateAsync({
editionId,
roundId: effectiveRoundId,
category: opts.category,
})
} finally {
setSendingTarget(null)
}
}
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between gap-2">
<CardTitle className="flex items-center gap-2 text-base">
<Users className="h-4 w-4 text-brand-blue" />
Round User Tracker
</CardTitle>
<Badge variant="outline" className="text-xs shrink-0">
{totalActivated}/{totalProjects} activated
</Badge>
</div>
{/* Round selector */}
<Select
value={effectiveRoundId}
onValueChange={(val) => setSelectedRoundId(val)}
>
<SelectTrigger className="h-8 text-xs mt-2">
<SelectValue placeholder="Select round" />
</SelectTrigger>
<SelectContent>
{rounds.map((r) => (
<SelectItem key={r.id} value={r.id} className="text-xs">
{r.name}
{r.status === 'ROUND_ACTIVE' && (
<span className="ml-1.5 text-emerald-600">(active)</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
</CardHeader>
<CardContent className="space-y-4">
{/* Subtitle showing round context */}
<p className="text-xs text-muted-foreground">
Projects that passed <span className="font-medium">{selectedRound?.name ?? 'this round'}</span> account activation status
</p>
{/* Per-category rows */}
{byCategory.map((cat) => {
const pct = cat.total > 0 ? Math.round((cat.accountsSet / cat.total) * 100) : 0
const allSet = cat.accountsNotSet === 0
return (
<div key={cat.category} className="space-y-1.5">
<div className="flex items-center justify-between text-sm">
<span className="font-medium">
{categoryLabels[cat.category] || cat.category}
</span>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{cat.accountsSet} of {cat.total} activated
</span>
{allSet ? (
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-500" />
) : (
<Button
size="sm"
variant="ghost"
className="h-6 px-2 text-xs"
disabled={sendReminders.isPending}
onClick={() =>
handleSendReminder(`cat-${cat.category}`, {
category: cat.category as 'STARTUP' | 'BUSINESS_CONCEPT',
})
}
>
{sendingTarget === `cat-${cat.category}` ? (
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
) : (
<Send className="mr-1 h-3 w-3" />
)}
Remind
</Button>
)}
</div>
</div>
<Progress value={pct} className="h-1.5" />
{cat.accountsNotSet > 0 && (
<p className="text-[11px] text-amber-600">
<AlertCircle className="mr-0.5 inline h-3 w-3" />
{cat.accountsNotSet} pending account setup
</p>
)}
</div>
)
})}
{/* Summary */}
<div className="border-t pt-3">
<div className="flex items-center justify-between text-sm">
<span className="font-semibold">
Total: {totalActivated} of {totalProjects} activated
</span>
{totalPending > 0 && (
<Button
size="sm"
variant="outline"
className="h-7 text-xs"
disabled={sendReminders.isPending}
onClick={() => handleSendReminder('all', {})}
>
{sendingTarget === 'all' ? (
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
) : (
<Send className="mr-1.5 h-3 w-3" />
)}
Remind All ({totalPending})
</Button>
)}
</div>
</div>
</CardContent>
</Card>
)
}