diff --git a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/judging/page.tsx b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/judging/page.tsx index a14da04e..ead8a7f6 100644 --- a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/judging/page.tsx +++ b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/judging/page.tsx @@ -53,7 +53,14 @@ import { reportError, reportMessage } from '@/lib/error-reporting'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { JudgingCriteriaList } from '@/components/organization/hackathons/judging/JudgingCriteriaList'; import JudgingResultsTable from '@/components/organization/hackathons/judging/JudgingResultsTable'; +import TrackResultsSection from '@/components/organization/hackathons/judging/TrackResultsSection'; +import AllocationPreviewCard from '@/components/organization/hackathons/judging/AllocationPreviewCard'; +import CoverageMatrix from '@/components/organization/hackathons/judging/CoverageMatrix'; import { OrganizerJudgesPanel } from '@/components/organization/hackathons/judging/OrganizerJudgesPanel'; +import { + useHackathon, + useHackathonTracks, +} from '@/hooks/hackathon/use-hackathon-queries'; import { Input } from '@/components/ui/input'; import { AlertDialog, @@ -79,6 +86,38 @@ export default function JudgingPage() { const hackathonId = params.hackathonId as string; const { activeOrgId, activeOrg } = useOrganization(); + + // Hackathon + tracks for the per-track Results section. Both are + // best-effort — a 404 or empty array degrades gracefully (the + // per-track UI just won't render). + const { data: hackathonDetail } = useHackathon(hackathonId); + const { data: tracksData } = useHackathonTracks(hackathonId); + const tracks = tracksData ?? []; + + // Map trackId → human prize string ("$2,000 USDC") sourced from the + // hackathon's bound prize tiers. Used as a chip on each track section + // header so organizers know which prize a track is paying out. + const prizeByTrackId = useMemo>(() => { + const tiers = + ( + hackathonDetail as + | { prizeTiers?: Array> } + | undefined + )?.prizeTiers ?? []; + const out: Record = {}; + for (const t of tiers) { + if (t?.kind !== 'TRACK' || !t?.trackId) continue; + const amount = String(t.prizeAmount ?? '').trim(); + const currency = String(t.currency ?? 'USDC').trim(); + if (!amount) continue; + out[t.trackId as string] = + currency.length === 1 + ? `${currency}${amount}` + : `${amount} ${currency}`; + } + return out; + }, [hackathonDetail]); + const [submissions, setSubmissions] = useState([]); const [criteria, setCriteria] = useState([]); const [isLoading, setIsLoading] = useState(true); @@ -710,6 +749,14 @@ export default function JudgingPage() { + {/* Coverage heatmap. Surfaces idle judges + orphan + submissions before they become a publish blocker. */} + + {isLoading && submissions.length === 0 ? (
@@ -1019,6 +1066,14 @@ export default function JudgingPage() {
)} + {!resultsPublished && ( + + )} + {winners.length > 0 && (

@@ -1037,6 +1092,20 @@ export default function JudgingPage() {

)} + {tracks.length > 0 && judgingResults.length > 0 && ( + + )} +

Current Standings diff --git a/components/organization/hackathons/judging/AllocationPreviewCard.tsx b/components/organization/hackathons/judging/AllocationPreviewCard.tsx new file mode 100644 index 00000000..402eeb88 --- /dev/null +++ b/components/organization/hackathons/judging/AllocationPreviewCard.tsx @@ -0,0 +1,327 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { + Trophy, + Layers, + AlertTriangle, + CheckCircle2, + Loader2, + RefreshCw, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { + getAllocationPreview, + type AllocationPreview, +} from '@/lib/api/hackathons/judging'; +import { reportError } from '@/lib/error-reporting'; +import { extractApiErrorMessage } from '@/lib/api/api'; + +interface AllocationPreviewCardProps { + organizationId: string; + hackathonId: string; + /** + * Bumps when judging results refresh. The preview re-fetches when this + * changes so the organizer always sees an up-to-date allocation. + */ + refreshKey?: number; +} + +const formatPrize = (amount?: string, currency?: string): string | null => { + if (!amount) return null; + const c = currency || 'USDC'; + return c.length === 1 ? `${c}${amount}` : `${amount} ${c}`; +}; + +/** + * Renders the read-only allocator dry-run for the organizer dashboard. + * Sits above the Publish button on the Results tab and lets the + * organizer see *exactly* what publish-results would commit, including + * EXCLUSIVE stacking effects (a track leader can lose if they also win + * overall). Also surfaces the publish gates (deadline, completeness, + * partner-allocation) so blockers are visible without an attempted + * publish. + */ +export default function AllocationPreviewCard({ + organizationId, + hackathonId, + refreshKey = 0, +}: AllocationPreviewCardProps) { + const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + const fetchPreview = async () => { + setIsLoading(true); + setError(null); + try { + const res = await getAllocationPreview(organizationId, hackathonId); + if (cancelled) return; + if (res.success && res.data) { + setData(res.data); + } else { + setError(res.message || 'Failed to load allocation preview'); + } + } catch (err) { + if (cancelled) return; + const msg = extractApiErrorMessage( + err, + 'Failed to load allocation preview' + ); + setError(msg); + reportError(err, { + context: 'judging-allocation-preview', + organizationId, + hackathonId, + }); + } finally { + if (!cancelled) setIsLoading(false); + } + }; + fetchPreview(); + return () => { + cancelled = true; + }; + }, [organizationId, hackathonId, refreshKey]); + + if (isLoading && !data) { + return ( +
+ + + Computing allocation preview… + +
+ ); + } + + if (error) { + return ( +
+ + {error} +
+ ); + } + + if (!data) return null; + + const { overall, tracks, gates } = data; + + // Hide when there's nothing to preview yet — no overall placements + // configured AND no track tiers. Avoids rendering an empty card on + // hackathons that haven't set up prize tiers. + if (overall.length === 0 && tracks.length === 0) return null; + + const blockers: string[] = []; + if (!gates.submissionDeadlinePassed) { + blockers.push('Submission deadline has not passed yet.'); + } + if (!gates.complete) { + blockers.push( + `${gates.incompleteSubmissionCount} submission${ + gates.incompleteSubmissionCount === 1 ? '' : 's' + } missing at least one judge's score.` + ); + } + if (gates.reviewedCount === 0) { + blockers.push('No submissions have been reviewed yet.'); + } + if (gates.unallocatedPartnerContributionAmount > 0.0000001) { + blockers.push( + `${gates.unallocatedPartnerContributionAmount.toFixed(2)} ${ + gates.currency + } of partner contributions are unallocated.` + ); + } + + const canPublish = blockers.length === 0; + + return ( +
+
+
+

+ + Allocator preview +

+

+ This is exactly what will be stamped on publish. EXCLUSIVE stacking + applied — one award per submission. Refreshes when scores change. +

+
+ + {canPublish ? ( + <> + + Ready to publish + + ) : ( + <> + + {blockers.length} blocker{blockers.length === 1 ? '' : 's'} + + )} + +
+ + {blockers.length > 0 && ( +
+
+ + Cannot publish yet +
+
    + {blockers.map((b, i) => ( +
  • + {b} +
  • + ))} +
+
+ )} + + {/* Overall placements */} + {overall.length > 0 && ( +
+
+ + Overall placements +
+
+ + + + + + + + + + + + {overall.map(o => ( + + + + + + + + ))} + +
RankProjectScorePrizeSource
#{o.rank}{o.projectName} + {o.averageScore.toFixed(2)} + + {formatPrize(o.prizeAmount, o.currency) ?? '—'} + + {o.isOverride ? ( + Override + ) : ( + Computed + )} +
+
+
+ )} + + {/* Track winners */} + {tracks.length > 0 && ( +
+
+ + Track winners +
+
+ {tracks.map(t => ( +
+
+
+ + {t.trackName} + + {formatPrize(t.prizeAmount, t.currency) && ( + + {formatPrize(t.prizeAmount, t.currency)} + + )} +
+ {t.skippedReason && ( + + + {t.skippedReason === 'NO_ENTRIES' + ? 'No opt-ins' + : 'No scored entries'} + + )} +
+ {t.winner ? ( +
+ + Winner:{' '} + + {t.winner.projectName} + + + + score {t.winner.averageScore.toFixed(2)} + +
+ ) : ( +

+ This track will not pay out — fix before publish. +

+ )} + {t.runnersUp.length > 0 && ( +
+ Runners-up:{' '} + {t.runnersUp.map((r, i) => ( + + {r.projectName} ({r.averageScore.toFixed(2)}) + {i < t.runnersUp.length - 1 ? ', ' : ''} + + ))} +
+ )} +
+ ))} +
+
+ )} + + {isLoading && ( +
+ + Refreshing… +
+ )} +
+ ); +} + +// `Button` import retained for future actions (e.g. an inline "refresh" +// button) without forcing a follow-up import edit. Strip if unused at +// the next polish pass. +void Button; diff --git a/components/organization/hackathons/judging/CoverageMatrix.tsx b/components/organization/hackathons/judging/CoverageMatrix.tsx new file mode 100644 index 00000000..a145088f --- /dev/null +++ b/components/organization/hackathons/judging/CoverageMatrix.tsx @@ -0,0 +1,261 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import { + AlertTriangle, + CheckCircle2, + Loader2, + Users, + ChevronDown, + ChevronUp, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { + getJudgingCoverage, + type JudgingCoverage, +} from '@/lib/api/hackathons/judging'; +import { reportError } from '@/lib/error-reporting'; +import { extractApiErrorMessage } from '@/lib/api/api'; + +interface CoverageMatrixProps { + organizationId: string; + hackathonId: string; + /** Bumps when something on the page should trigger a re-fetch. */ + refreshKey?: number; +} + +const initialOf = (name: string): string => { + const parts = name.trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) return '?'; + if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); + return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); +}; + +/** + * Judges × submissions coverage heatmap for the organizer Overview + * tab. Rows are submissions, columns are judges, cell colour signals + * whether that judge has scored that submission. Designed to surface + * two failure modes at a glance: + * + * • Idle judges — a column of mostly grey cells means a judge hasn't + * started or is far behind. + * • Orphan submissions — a row with 0-1 scored cells can't be ranked + * safely (no allocator decision will be defensible). + * + * Collapsed by default; the organizer opens it when they want to triage + * judging progress. No backend writes — pure read of the new coverage + * endpoint plus a thin summary header. + */ +export default function CoverageMatrix({ + organizationId, + hackathonId, + refreshKey = 0, +}: CoverageMatrixProps) { + const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [open, setOpen] = useState(false); + + useEffect(() => { + let cancelled = false; + const fetchCoverage = async () => { + setIsLoading(true); + setError(null); + try { + const res = await getJudgingCoverage(organizationId, hackathonId); + if (cancelled) return; + if (res.success && res.data) { + setData(res.data); + } else { + setError(res.message || 'Failed to load coverage matrix'); + } + } catch (err) { + if (cancelled) return; + const msg = extractApiErrorMessage( + err, + 'Failed to load coverage matrix' + ); + setError(msg); + reportError(err, { + context: 'judging-coverage-matrix', + organizationId, + hackathonId, + }); + } finally { + if (!cancelled) setIsLoading(false); + } + }; + fetchCoverage(); + return () => { + cancelled = true; + }; + }, [organizationId, hackathonId, refreshKey]); + + const completionPct = useMemo(() => { + if (!data) return 0; + const { expectedScores, actualScores } = data.summary; + if (expectedScores === 0) return 0; + return Math.round((actualScores / expectedScores) * 100); + }, [data]); + + // The submission view-model. Pre-computes the scored-set as a Set so + // every cell render is an O(1) lookup instead of an array search. + const submissionRows = useMemo(() => { + if (!data) return []; + return data.submissions.map(s => ({ + ...s, + scoredSet: new Set(s.scoredBy), + })); + }, [data]); + + if (isLoading && !data) { + return ( +
+ + Loading coverage… +
+ ); + } + + if (error) { + return ( +
+ + {error} +
+ ); + } + + if (!data) return null; + if (data.summary.totalJudges === 0 || data.summary.totalSubmissions === 0) { + // Nothing to display yet — judging hasn't started. Avoid an empty + // skeleton that adds noise to the dashboard. + return null; + } + + const { summary, judges } = data; + + return ( +
+ + + + {open && ( +
+ + + + + {judges.map(j => ( + + ))} + + + + + {submissionRows.map(sub => ( + + + {judges.map(j => { + const scored = sub.scoredSet.has(j.userId); + return ( + + ); + })} + + + ))} + +
+ Submission + +
+ + {initialOf(j.name)} + + + {j.scoredCount}/{summary.totalSubmissions} + +
+
+ Status +
+ {sub.projectName} + +
+
+ {sub.isCovered ? ( + + ) : sub.scoredCount === 0 ? ( + + Orphan + + ) : ( + + {sub.scoredCount}/{summary.totalJudges} + + )} +
+
+ )} +
+ ); +} diff --git a/components/organization/hackathons/judging/TrackResultsSection.tsx b/components/organization/hackathons/judging/TrackResultsSection.tsx new file mode 100644 index 00000000..45c6f6f9 --- /dev/null +++ b/components/organization/hackathons/judging/TrackResultsSection.tsx @@ -0,0 +1,226 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { + ChevronDown, + ChevronUp, + Trophy, + AlertTriangle, + Layers, +} from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import type { HackathonTrack } from '@/lib/api/hackathons/tracks'; +import type { + JudgingCriterion, + JudgingResult, +} from '@/lib/api/hackathons/judging'; +import JudgingResultsTable from './JudgingResultsTable'; + +interface TrackResultsSectionProps { + /** All non-archived tracks for the hackathon. */ + tracks: HackathonTrack[]; + /** Full aggregated results list. Filtered per-track inside. */ + results: JudgingResult[]; + /** Total active judges, used by the inner table to compute coverage. */ + totalJudges?: number; + /** Configured judging criteria for per-criterion drill-down. */ + criteria?: JudgingCriterion[]; + /** Organizer override capability — controlled by parent. */ + canManage?: boolean; + /** Existing winner overrides, threaded through to the inner table. */ + winnerOverrides?: Record; + /** + * Map of trackId → bound prize tier (e.g. "$2,000"). When present, the + * section header shows the prize amount next to the track name. Empty + * map is fine — tracks without a bound prize just don't show one. + */ + prizeByTrackId?: Record; + organizationId: string; + hackathonId: string; +} + +/** + * Per-track standings for the organizer dashboard. Renders one + * collapsible section per non-archived track, scoped to submissions + * opted into that track (`result.trackIds`). The leading submission is + * highlighted as the current allocator pick — but it's a soft preview, + * not authoritative. EXCLUSIVE stacking applied at publish time may + * promote a runner-up if the current leader wins an overall placement + * or another track. The Phase 2 allocator preview surfaces that. + */ +export default function TrackResultsSection({ + tracks, + results, + totalJudges, + criteria, + canManage, + winnerOverrides, + prizeByTrackId, + organizationId, + hackathonId, +}: TrackResultsSectionProps) { + // Default-collapsed; the organizer expands the tracks they care about. + // Tracks with no opted-in submissions are still rendered so the + // organizer notices the gap before publish. + const [expanded, setExpanded] = useState>({}); + + const resultsByTrack = useMemo(() => { + const map = new Map(); + for (const r of results) { + const trackIds = r.trackIds ?? []; + for (const trackId of trackIds) { + const list = map.get(trackId) ?? []; + list.push(r); + map.set(trackId, list); + } + } + // Sort each list by averageScore descending so the leader is index 0. + for (const [k, v] of map.entries()) { + map.set( + k, + [...v].sort((a, b) => (b.averageScore ?? 0) - (a.averageScore ?? 0)) + ); + } + return map; + }, [results]); + + const visibleTracks = useMemo( + () => + [...tracks] + .filter(t => !t.isArchived) + .sort((a, b) => a.displayOrder - b.displayOrder), + [tracks] + ); + + if (visibleTracks.length === 0) return null; + + return ( +
+
+ +

Per-Track Standings

+ + ({visibleTracks.length} track{visibleTracks.length === 1 ? '' : 's'}) + +
+ +

+ Each section shows submissions opted into that track, sorted by their + average score across all judges. The highlighted row is the current + leader — at publish time, EXCLUSIVE stacking may promote a runner-up if + the leader wins an overall placement or another track. +

+ +
+ {visibleTracks.map(track => { + const trackResults = resultsByTrack.get(track.id) ?? []; + const isOpen = !!expanded[track.id]; + const leader = trackResults[0]; + const prize = prizeByTrackId?.[track.id]; + const noEntries = trackResults.length === 0; + + return ( +
+ + + + {isOpen && ( +
+ {noEntries ? ( +
+ No submissions have opted into this track yet. Use the{' '} + + Settings → Tracks → Opt in all + {' '} + action if you want to retrofit existing submissions. +
+ ) : ( + + )} +
+ )} +
+ ); + })} +
+
+ ); +} diff --git a/lib/api/hackathons/judging.ts b/lib/api/hackathons/judging.ts index f2c9ec8a..0b65e407 100644 --- a/lib/api/hackathons/judging.ts +++ b/lib/api/hackathons/judging.ts @@ -87,6 +87,10 @@ export interface JudgingResult { hasDisagreement: boolean; prize?: string; overriddenRank?: number; // Added to track manual overrides + /** Track opt-ins for this submission. Empty for OVERALL_ONLY hackathons + * or submissions that didn't pick any track. Used to group results + * per-track in the organizer dashboard. */ + trackIds?: string[]; } export interface AggregatedJudgingResults { @@ -646,6 +650,118 @@ export interface JudgingCompletenessPreview { }>; } +// ── Coverage matrix (Phase 3: dashboard) ──────────────────────────── + +export interface JudgingCoverageJudge { + userId: string; + name: string; + scoredCount: number; + missingCount: number; + lastScoredAt: string | null; +} + +export interface JudgingCoverageSubmission { + submissionId: string; + projectName: string; + /** User IDs of judges who scored this submission. */ + scoredBy: string[]; + scoredCount: number; + missingCount: number; + isCovered: boolean; +} + +export interface JudgingCoverage { + hackathonId: string; + judges: JudgingCoverageJudge[]; + submissions: JudgingCoverageSubmission[]; + summary: { + totalSubmissions: number; + totalJudges: number; + expectedScores: number; + actualScores: number; + submissionsFullyCovered: number; + submissionsPartiallyCovered: number; + submissionsUncovered: number; + }; +} + +/** + * Full judges × submissions coverage matrix for the organizer + * dashboard. Used to render the heatmap that exposes idle judges and + * orphan submissions. + */ +export const getJudgingCoverage = async ( + organizationId: string, + hackathonId: string +): Promise> => { + const res = await api.get( + `/organizations/${organizationId}/hackathons/${hackathonId}/judging/coverage` + ); + return res.data; +}; + +// ── Allocator preview (Phase 2: dashboard) ────────────────────────── + +export interface AllocationPreviewOverallEntry { + rank: number; + submissionId: string; + projectName: string; + averageScore: number; + prizeAmount?: string; + currency?: string; + isOverride: boolean; +} + +export interface AllocationPreviewTrackEntry { + trackId: string; + trackName: string; + trackSlug: string; + prizeAmount?: string; + currency?: string; + winner: { + submissionId: string; + projectName: string; + averageScore: number; + } | null; + runnersUp: Array<{ + submissionId: string; + projectName: string; + averageScore: number; + }>; + /** Why a track has no winner. NO_ENTRIES = no submissions opted in; + * NO_SCORED_ENTRIES = opted in but no judge scored them. */ + skippedReason: 'NO_ENTRIES' | 'NO_SCORED_ENTRIES' | null; +} + +export interface AllocationPreview { + hackathonId: string; + overall: AllocationPreviewOverallEntry[]; + tracks: AllocationPreviewTrackEntry[]; + gates: { + submissionDeadlinePassed: boolean; + complete: boolean; + incompleteSubmissionCount: number; + reviewedCount: number; + unallocatedPartnerContributionAmount: number; + currency: string; + }; +} + +/** + * Read-only allocator dry-run. Returns the overall + per-track outcome + * `publishJudgingResults` would produce, plus the publish-gate flags so + * the UI can render a "what's blocking publish?" panel. + */ +export const getAllocationPreview = async ( + organizationId: string, + hackathonId: string +): Promise> => { + const res = await api.get( + `/organizations/${organizationId}/hackathons/${hackathonId}/judging/preview-allocation` + ); + return res.data; +}; + export const getJudgingCompleteness = async ( organizationId: string, hackathonId: string