{trackWinner.projectName}
diff --git a/components/organization/cards/JudgingParticipant.tsx b/components/organization/cards/JudgingParticipant.tsx
index 2e709e53..4008a5d6 100644
--- a/components/organization/cards/JudgingParticipant.tsx
+++ b/components/organization/cards/JudgingParticipant.tsx
@@ -258,6 +258,8 @@ const JudgingParticipant = ({
View Details
@@ -433,6 +435,8 @@ const JudgingParticipant = ({
)}
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 (
+
+ );
+ }
+
+ 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 && (
+
+
+
+ {blockers.map((b, i) => (
+
+ {b}
+
+ ))}
+
+
+ )}
+
+ {/* Overall placements */}
+ {overall.length > 0 && (
+
+
+
+ Overall placements
+
+
+
+
+
+ Rank
+ Project
+ Score
+ Prize
+ Source
+
+
+
+ {overall.map(o => (
+
+ #{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 (
+
+ );
+ }
+
+ 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 (
+
+
setOpen(v => !v)}
+ className='flex w-full items-start justify-between gap-3 text-left'
+ >
+
+
+
+ Coverage
+ = 50
+ ? 'border-blue-500/40 bg-blue-500/10 text-blue-300'
+ : 'border-amber-500/40 bg-amber-500/10 text-amber-300'
+ }
+ >
+ {completionPct}% scored
+
+
+
+ {summary.actualScores} of {summary.expectedScores} scores in.{' '}
+
+ {summary.submissionsFullyCovered} fully scored,{' '}
+ {summary.submissionsPartiallyCovered} partial,{' '}
+ {summary.submissionsUncovered} unscored.
+
+
+
+
+
+ {open ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {open && (
+
+
+
+
+
+ Submission
+
+ {judges.map(j => (
+
+
+
+ {initialOf(j.name)}
+
+
+ {j.scoredCount}/{summary.totalSubmissions}
+
+
+
+ ))}
+
+ Status
+
+
+
+
+ {submissionRows.map(sub => (
+
+
+ {sub.projectName}
+
+ {judges.map(j => {
+ const scored = sub.scoredSet.has(j.userId);
+ return (
+
+
+
+ );
+ })}
+
+ {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 (
+
+
+ setExpanded(prev => ({
+ ...prev,
+ [track.id]: !prev[track.id],
+ }))
+ }
+ className='flex w-full items-center justify-between gap-3 px-4 py-3 text-left hover:bg-white/[0.02]'
+ >
+
+
+
+
+
+ {track.name}
+
+ {prize && (
+
+ {prize}
+
+ )}
+
+ {trackResults.length} entr
+ {trackResults.length === 1 ? 'y' : 'ies'}
+
+ {noEntries && (
+
+
+ No opted-in submissions
+
+ )}
+
+ {leader && (
+
+ Currently leading:{' '}
+
+ {leader.projectName}
+ {' '}
+ ({leader.averageScore?.toFixed(2)})
+
+ )}
+
+
+
+
+ {isOpen ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {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/components/organization/hackathons/submissions/SubmissionsList.tsx b/components/organization/hackathons/submissions/SubmissionsList.tsx
index 30029d9a..927e3805 100644
--- a/components/organization/hackathons/submissions/SubmissionsList.tsx
+++ b/components/organization/hackathons/submissions/SubmissionsList.tsx
@@ -1,5 +1,4 @@
import { useState } from 'react';
-import { useRouter } from 'next/navigation';
import {
Users,
User,
@@ -56,7 +55,6 @@ export function SubmissionsList({
onSelectionChange,
hackathon,
}: SubmissionsListProps) {
- const router = useRouter();
const [reviewingId, setReviewingId] = useState(null);
const [disqualifyingId, setDisqualifyingId] = useState(null);
const [isDisqualifying, setIsDisqualifying] = useState(false);
@@ -155,7 +153,11 @@ export function SubmissionsList({
};
const handleSubmissionClick = (submissionId: string) => {
- router.push(`/projects/${submissionId}?type=submission`);
+ window.open(
+ `/projects/${submissionId}?type=submission`,
+ '_blank',
+ 'noopener,noreferrer'
+ );
};
if (submissions.length === 0 && !loading) {
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