brackt/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx
Chris Parsons beab6400b2
Fix partial bracket scoring, rank labels, and Discord notification bugs (#158)
- NCAAM R64/R32 winners no longer receive T5 floor points; only Sweet
  Sixteen winners entering the first scoring round get a floor
- AFL T5-T6 and T7-T8 score as separate tiers (avg([5,6]) and avg([7,8]))
  instead of one shared avg([5-8]) pool
- Eliminated teams now always show their correct final rank (e.g. T33 for
  NCAAM R64 losers) even during partial round scoring, by deriving the
  rank label from the total match count per round rather than the dynamic
  still-alive count
- Discord notifications now fire when a drafted participant is eliminated
  with 0 points (e.g. R64 losers whose team score doesn't change)
- Discord notifications no longer include all prior matches — scoped to
  the current batch via matchIds
- Discord section headers have spacing; rank medals replaced with numbers
- Rounds auto-complete when all matches are marked done; manual Complete
  Round dropdown removed
- Fixed double Discord notification when the last match in a round
  triggers auto-complete: processPlayoffEvent now accepts skipRecalculate
  so the caller controls when the notification fires
- Fixed falsy check on finalPosition=0 in calculateTeamScore
- Undrafted 0-point participants filtered from Eliminated Teams display

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 12:34:10 -07:00

1156 lines
57 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Form, Link } from "react-router";
import { Fragment, useState, useEffect, useMemo } from "react";
import { localDateTimeToUtcIso } from "~/lib/date-utils";
import type { Route } from "./+types/admin.sports-seasons.$id.events.$eventId.bracket";
import { loader, action } from "./admin.sports-seasons.$id.events.$eventId.bracket.server";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { ArrowLeft, Plus, Trophy, X, Check, ChevronDown, ChevronRight, Calendar, TrendingUp, Trash2 } from "lucide-react";
import { getAllBracketTemplates, getBracketTemplate, getOrderedRoundsFromMatches, buildNCAA68SlotMap, ALL_16_SEEDS, type BracketRegion } from "~/lib/bracket-templates";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table";
import { Badge } from "~/components/ui/badge";
import { ParticipantSelector } from "~/components/ParticipantSelector";
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
return [{ title: `Bracket — ${data?.event?.name ?? "Event"} - Brackt Admin` }];
}
export { loader, action };
export default function EventBracket({
loaderData,
actionData,
}: Route.ComponentProps) {
const { sportsSeason, event, participants, matches, tournamentGroups } = loaderData;
const [selectedTemplate, setSelectedTemplate] = useState("simple_8");
const [selectedParticipants, setSelectedParticipants] = useState<Record<number, string>>({});
const [selectedWinners, setSelectedWinners] = useState<Record<string, string>>({});
const [knockoutAssignments, setKnockoutAssignments] = useState<Record<string, string>>({});
const [expandedMatches, setExpandedMatches] = useState<Set<string>>(new Set());
const localTzAbbr = new Intl.DateTimeFormat("en-US", { timeZoneName: "short" })
.formatToParts(new Date())
.find(p => p.type === "timeZoneName")?.value ?? "local";
const toggleMatchExpanded = (matchId: string) => {
setExpandedMatches(prev => {
const next = new Set(prev);
if (next.has(matchId)) next.delete(matchId);
else next.add(matchId);
return next;
});
};
const templates = getAllBracketTemplates();
const template = templates.find((t) => t.id === selectedTemplate);
// Determine if this is a group-stage event
const hasGroupStage = template?.groupStage != null;
// Determine if this template uses named regions (e.g. NCAA 68)
const hasRegions = (template?.regions?.length ?? 0) > 0;
// Per-region configuration state: name + which seed slots are play-ins
// Initialized from: stored event config → template defaults → empty
const buildDefaultRegionConfig = (tmpl: typeof template) => {
const source =
(event.bracketRegionConfig as unknown as BracketRegion[] | null) ?? tmpl?.regions ?? [];
return source.map((r) => ({
name: r.name,
playInSeeds: r.playIns.map((pi) => pi.seedSlot),
}));
};
const [regionConfig, setRegionConfig] = useState(() => buildDefaultRegionConfig(template));
// Derive effective BracketRegion[] from regionConfig state
const effectiveRegions: BracketRegion[] = useMemo(() => {
return regionConfig.map((cfg) => {
const playInSet = new Set(cfg.playInSeeds);
return {
name: cfg.name,
directSeeds: ALL_16_SEEDS.filter((s) => !playInSet.has(s)),
playIns: cfg.playInSeeds.map((s) => ({ seedSlot: s, teams: 2 as const })),
};
});
}, [regionConfig]);
const regionSlotMap = useMemo(
() => (effectiveRegions.length > 0 ? buildNCAA68SlotMap(effectiveRegions) : null),
[effectiveRegions]
);
// Region config handlers
const updateRegionName = (regionIdx: number, name: string) => {
setRegionConfig((prev) =>
prev.map((r, i) => (i === regionIdx ? { ...r, name } : r))
);
};
const addPlayIn = (regionIdx: number) => {
setRegionConfig((prev) =>
prev.map((r, i) => {
if (i !== regionIdx || r.playInSeeds.length >= 2) return r;
// Default to seed 16 if no play-ins yet, otherwise seed 11
const defaultSeed = r.playInSeeds.length === 0 ? 16 : 11;
const seed = r.playInSeeds.includes(defaultSeed)
? ([11, 16].find((s) => !r.playInSeeds.includes(s)) ?? 11)
: defaultSeed;
return { ...r, playInSeeds: [...r.playInSeeds, seed] };
})
);
// Reset participant selections since slot indices change
setSelectedParticipants({});
};
const removePlayIn = (regionIdx: number, piIdx: number) => {
setRegionConfig((prev) =>
prev.map((r, i) =>
i === regionIdx
? { ...r, playInSeeds: r.playInSeeds.filter((_, j) => j !== piIdx) }
: r
)
);
setSelectedParticipants({});
};
const updatePlayInSeed = (regionIdx: number, piIdx: number, seed: number) => {
setRegionConfig((prev) =>
prev.map((r, i) =>
i === regionIdx
? {
...r,
playInSeeds: r.playInSeeds.map((s, j) => (j === piIdx ? seed : s)),
}
: r
)
);
setSelectedParticipants({});
};
const isGroupStageEvent = tournamentGroups.length > 0;
// Determine the phase for group-stage events
const knockoutPopulated = useMemo(() => {
if (!isGroupStageEvent) return false;
// Check if any Round of 32 match has participants assigned
const r32Matches = matches.filter((m: any) => m.round === "Round of 32");
return r32Matches.some((m: any) => m.participant1Id || m.participant2Id);
}, [isGroupStageEvent, matches]);
// Group stage stats
const groupStageStats = useMemo(() => {
if (!isGroupStageEvent) return null;
let eliminated = 0;
let total = 0;
for (const group of tournamentGroups) {
for (const member of (group as any).members || []) {
total++;
if (member.eliminated) eliminated++;
}
}
return { eliminated, advancing: total - eliminated, total };
}, [isGroupStageEvent, tournamentGroups]);
// Advancing participants (non-eliminated) for knockout assignment
const advancingParticipants = useMemo(() => {
if (!isGroupStageEvent) return [];
const advancing: Array<{ id: string; name: string }> = [];
for (const group of tournamentGroups) {
for (const member of (group as any).members || []) {
if (!member.eliminated && member.participant) {
advancing.push({
id: member.participant.id,
name: member.participant.name,
});
}
}
}
return advancing;
}, [isGroupStageEvent, tournamentGroups]);
// Available advancing participants for knockout assignment (exclude already assigned)
const availableAdvancingMap = useMemo(() => {
const assignedIds = new Set(Object.values(knockoutAssignments).filter(Boolean));
const map: Record<string, typeof advancingParticipants> = {};
// Create 32 slots (16 matches × 2 slots)
for (let m = 1; m <= 16; m++) {
for (const slot of ["participant1Id", "participant2Id"]) {
const key = `match-${m}-${slot}`;
const currentSelection = knockoutAssignments[key];
map[key] = advancingParticipants.filter(
(p) => !assignedIds.has(p.id) || p.id === currentSelection
);
}
}
return map;
}, [knockoutAssignments, advancingParticipants]);
// Clear selected winners after successful batch submission
useEffect(() => {
if (actionData?.success) {
setSelectedWinners({});
}
}, [actionData?.success]);
// Reset selected participants and region config when template changes
const handleTemplateChange = (newTemplateId: string) => {
setSelectedTemplate(newTemplateId);
setSelectedParticipants({});
const newTemplate = templates.find((t) => t.id === newTemplateId);
setRegionConfig(buildDefaultRegionConfig(newTemplate));
};
// Update selected participant for a seed
const handleParticipantChange = (seedIndex: number, participantId: string) => {
setSelectedParticipants(prev => ({
...prev,
[seedIndex]: participantId
}));
};
// Memoized available participants calculation
const availableParticipantsMap = useMemo(() => {
const selectedIdsSet = new Set(Object.values(selectedParticipants));
const map: Record<number, typeof participants> = {};
const totalSeeds = template?.totalTeams || 8;
for (let i = 0; i < totalSeeds; i++) {
const currentSelection = selectedParticipants[i];
map[i] = participants.filter((p: { id: string; name: string }) => {
return !selectedIdsSet.has(p.id) || p.id === currentSelection;
});
}
return map;
}, [selectedParticipants, participants, template?.totalTeams]);
const getAvailableParticipants = (seedIndex: number) => {
return availableParticipantsMap[seedIndex] || participants;
};
// Update selected winner for a match
const handleWinnerChange = (matchId: string, winnerId: string) => {
setSelectedWinners(prev => ({
...prev,
[matchId]: winnerId
}));
};
// Get matches with pending winner selections in a round
const getPendingWinnersInRound = (round: string) => {
return Object.entries(selectedWinners)
.filter(([matchId]) => {
const match = matches.find((m: any) => m.id === matchId);
return match && match.round === round && !match.isComplete;
});
};
// Memoized: Group matches by round
const matchesByRound = useMemo(() => {
const grouped: Record<string, typeof matches> = {};
for (const match of matches) {
if (!grouped[match.round]) {
grouped[match.round] = [];
}
grouped[match.round].push(match);
}
return grouped;
}, [matches]);
// Memoized: Get available rounds in chronological order
const availableRounds = useMemo(() => {
const template = event.bracketTemplateId ? getBracketTemplate(event.bracketTemplateId) : undefined;
return getOrderedRoundsFromMatches(matches, template);
}, [matches, event.bracketTemplateId]);
// Check if all matches are complete
const allMatchesComplete = useMemo(() => {
return matches.length > 0 && matches.every((m: any) => m.isComplete);
}, [matches]);
// No bracket and no groups yet = setup phase
const showSetup = matches.length === 0 && !isGroupStageEvent;
return (
<div className="p-8">
<div className="max-w-6xl mx-auto">
<div className="mb-6">
<Button variant="ghost" size="sm" asChild className="mb-2">
<Link to={`/admin/sports-seasons/${sportsSeason.id}/events/${event.id}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Event
</Link>
</Button>
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Playoff Bracket</h1>
<p className="text-muted-foreground mt-1">
{event.name} - {sportsSeason.sport.name} {sportsSeason.name}
</p>
</div>
</div>
</div>
<div className="space-y-6">
{actionData?.error && (
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
{actionData.error}
</div>
)}
{actionData?.success && (
<div className="bg-emerald-500/15 text-emerald-400 px-4 py-3 rounded-md text-sm">
{actionData.success}
</div>
)}
{/* Reprocess Eliminations - For existing brackets (non-group-stage) */}
{matches.length > 0 && !isGroupStageEvent && (
<Card>
<CardHeader>
<CardTitle>Reprocess Eliminations</CardTitle>
<CardDescription>
Mark participants not in this bracket as eliminated (for brackets created before automatic elimination tracking)
</CardDescription>
</CardHeader>
<CardContent>
<Form method="post">
<input type="hidden" name="intent" value="reprocess-eliminations" />
<div className="space-y-4">
<div className="text-sm text-muted-foreground">
This will set finalPosition = 0 for all participants who are not in any playoff match,
allowing probability recalculation to set their odds to 0%.
</div>
<Button type="submit" variant="outline">
Reprocess Eliminations
</Button>
</div>
</Form>
</CardContent>
</Card>
)}
{/* ====== SETUP PHASE ====== */}
{showSetup && (
<Card>
<CardHeader>
<CardTitle>Generate Bracket</CardTitle>
<CardDescription>
Create the bracket structure for this playoff event
</CardDescription>
</CardHeader>
<CardContent>
<Form method="post" className="space-y-4">
{/* Intent depends on whether this is a group-stage template */}
<input
type="hidden"
name="intent"
value={hasGroupStage ? "generate-groups" : "generate-bracket"}
/>
<div className="space-y-2">
<Label htmlFor="templateId">Bracket Template</Label>
<Select
name="templateId"
value={selectedTemplate}
onValueChange={handleTemplateChange}
required
>
<SelectTrigger id="templateId">
<SelectValue />
</SelectTrigger>
<SelectContent>
{templates.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name} - {t.totalTeams} teams
</SelectItem>
))}
</SelectContent>
</Select>
{template && (
<p className="text-sm text-muted-foreground">
{template.groupStage ? (
<>
Groups: {template.groupStage.groupCount} groups of {template.groupStage.teamsPerGroup}
{" | "}Knockout: {template.rounds.map(r => r.name).join(" \u2192 ")}
</>
) : (
<>Rounds: {template.rounds.map(r => r.name).join(" \u2192 ")}</>
)}
</p>
)}
</div>
{/* Group-stage template: show group assignments */}
{hasGroupStage && template?.groupStage && (
<div className="space-y-4">
<Label>Assign Participants to Groups</Label>
<p className="text-sm text-muted-foreground">
Assign {template.totalTeams} participants across {template.groupStage.groupCount} groups
({template.groupStage.teamsPerGroup} per group).
</p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{template.groupStage.groupLabels.map((label, groupIndex) => (
<Card key={label} className="border-dashed">
<CardHeader className="py-3 px-4">
<CardTitle className="text-base">Group {label}</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4 space-y-2">
{[...Array(template.groupStage!.teamsPerGroup)].map((_, teamIndex) => {
const seedIndex = groupIndex * template.groupStage!.teamsPerGroup + teamIndex;
const availableParticipants = getAvailableParticipants(seedIndex);
return (
<div key={seedIndex}>
<input
type="hidden"
name={`participant${seedIndex}`}
value={selectedParticipants[seedIndex] || ""}
required
/>
<ParticipantSelector
participants={availableParticipants}
value={selectedParticipants[seedIndex] || ""}
onValueChange={(value) => handleParticipantChange(seedIndex, value)}
placeholder={`Team ${teamIndex + 1}...`}
/>
</div>
);
})}
</CardContent>
</Card>
))}
</div>
</div>
)}
{/* Non-group-stage template: region-grouped or flat seeded list */}
{!hasGroupStage && hasRegions && regionSlotMap && (
<div className="space-y-6">
{/* ── Region configuration ─────────────────────────────── */}
<div className="border rounded-lg p-4 space-y-3">
<div>
<h4 className="font-semibold text-sm">Configure Regions</h4>
<p className="text-xs text-muted-foreground mt-0.5">
Set each region's name and which seed slots are play-in games.
</p>
</div>
{regionConfig.map((region, r) => (
<div key={r} className="flex flex-wrap items-center gap-2">
<Input
className="w-36 h-8 text-sm"
value={region.name}
onChange={(e) => updateRegionName(r, e.target.value)}
placeholder={`Region ${r + 1}`}
/>
{region.playInSeeds.map((seed, pi) => (
<div key={pi} className="flex items-center gap-1">
<span className="text-xs text-muted-foreground">play-in seed:</span>
<Select
value={String(seed)}
onValueChange={(val) => updatePlayInSeed(r, pi, parseInt(val, 10))}
>
<SelectTrigger className="h-8 w-16 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ALL_16_SEEDS.map((s) => (
<SelectItem key={s} value={String(s)}>{s}</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground"
onClick={() => removePlayIn(r, pi)}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
{region.playInSeeds.length < 2 && (
<Button
type="button"
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => addPlayIn(r)}
>
<Plus className="h-3 w-3 mr-1" />
Add play-in
</Button>
)}
</div>
))}
{/* Hidden fields so server receives the region config */}
{regionConfig.map((region, r) => (
<Fragment key={r}>
<input type="hidden" name={`regionName${r}`} value={region.name} />
<input type="hidden" name={`regionPlayInSeeds${r}`} value={region.playInSeeds.join(",")} />
</Fragment>
))}
</div>
{/* ── Seed entry per region ─────────────────────────────── */}
<p className="text-sm text-muted-foreground">
Select participants by region. Use search to find teams quickly.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{effectiveRegions.map((region, r) => {
const directOffset = regionSlotMap.directOffsets[r];
return (
<div key={r} className="border rounded-lg p-4 space-y-2">
<h4 className="font-semibold text-sm">{region.name || `Region ${r + 1}`}</h4>
{region.directSeeds.map((seedNum, pos) => {
const globalIdx = directOffset + pos;
const opponentSeed = 17 - seedNum;
const opponentIsPlayIn = region.playIns.some((pi) => pi.seedSlot === opponentSeed);
return (
<div key={seedNum} className="flex items-center gap-2">
<Label className="w-16 text-xs text-muted-foreground shrink-0">
Seed {seedNum}
</Label>
<div className="flex-1 min-w-0">
<input
type="hidden"
name={`participant${globalIdx}`}
value={selectedParticipants[globalIdx] || ""}
required
/>
<ParticipantSelector
participants={getAvailableParticipants(globalIdx)}
value={selectedParticipants[globalIdx] || ""}
onValueChange={(value) => handleParticipantChange(globalIdx, value)}
placeholder={`Seed ${seedNum}…`}
/>
</div>
{opponentIsPlayIn && (
<span className="text-xs text-muted-foreground shrink-0">← faces play-in</span>
)}
</div>
);
})}
{region.playIns.length > 0 && (
<p className="text-xs text-muted-foreground pt-1 border-t">
{region.playIns.map((pi) => `Seed ${pi.seedSlot}`).join(" & ")} via play-in ↓
</p>
)}
</div>
);
})}
</div>
{/* ── Play-in team entry ─────────────────────────────────── */}
{regionSlotMap.playInOffsets.length > 0 && (
<div className="border rounded-lg p-4 space-y-4">
<h4 className="font-semibold text-sm">Play-In Teams</h4>
<p className="text-xs text-muted-foreground">
Enter both teams for each play-in game. The winner advances to the main bracket.
</p>
{regionSlotMap.playInOffsets.map((pi, ffIdx) => {
const regionName = effectiveRegions[pi.regionIndex]?.name || `Region ${pi.regionIndex + 1}`;
const idx1 = pi.startIndex;
const idx2 = pi.startIndex + 1;
return (
<div key={ffIdx} className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">
Play-In {ffIdx + 1} — {regionName} {pi.seedSlot}-seed
</p>
{[idx1, idx2].map((globalIdx, slot) => (
<div key={globalIdx} className="flex items-center gap-2">
<Label className="w-16 text-xs text-muted-foreground shrink-0">
Team {slot + 1}
</Label>
<div className="flex-1 min-w-0">
<input
type="hidden"
name={`participant${globalIdx}`}
value={selectedParticipants[globalIdx] || ""}
required
/>
<ParticipantSelector
participants={getAvailableParticipants(globalIdx)}
value={selectedParticipants[globalIdx] || ""}
onValueChange={(value) => handleParticipantChange(globalIdx, value)}
placeholder={`${regionName} ${pi.seedSlot}-seed play-in team ${slot + 1}…`}
/>
</div>
</div>
))}
</div>
);
})}
</div>
)}
</div>
)}
{/* Non-group-stage, non-region template: flat seeded list */}
{!hasGroupStage && !hasRegions && (
<div className="space-y-2">
<Label>Select Participants (in order)</Label>
<p className="text-sm text-muted-foreground">
Select {template?.totalTeams || 8} participants for the bracket. Use search to quickly find participants.
</p>
{[...Array(template?.totalTeams || 8)].map((_, i) => {
const availableParticipants = getAvailableParticipants(i);
return (
<div key={i} className="flex items-center gap-2">
<Label className="w-20 text-sm text-muted-foreground shrink-0">
Seed {i + 1}
</Label>
<div className="flex-1 min-w-0">
<input
type="hidden"
name={`participant${i}`}
value={selectedParticipants[i] || ""}
required
/>
<ParticipantSelector
participants={availableParticipants}
value={selectedParticipants[i] || ""}
onValueChange={(value) => handleParticipantChange(i, value)}
placeholder={`Select seed ${i + 1}...`}
/>
</div>
</div>
);
})}
</div>
)}
<Button type="submit" className="w-full">
<Plus className="mr-2 h-4 w-4" />
{hasGroupStage ? "Generate Groups & Bracket" : "Generate Bracket"}
</Button>
</Form>
</CardContent>
</Card>
)}
{/* ====== GROUP STAGE MANAGEMENT (Phase 2) ====== */}
{isGroupStageEvent && !knockoutPopulated && (
<>
{/* Group Stage Summary */}
{groupStageStats && (
<Card>
<CardHeader>
<CardTitle>Group Stage</CardTitle>
<CardDescription>
Manage group stage eliminations. Mark teams as eliminated, then assign advancing teams to the knockout bracket.
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex gap-4 text-sm">
<Badge variant="secondary">
{groupStageStats.total} total teams
</Badge>
<Badge variant="destructive">
{groupStageStats.eliminated} eliminated
</Badge>
<Badge variant="default">
{groupStageStats.advancing} advancing
</Badge>
</div>
</CardContent>
</Card>
)}
{/* Group Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{tournamentGroups.map((group: any) => (
<Card key={group.id}>
<CardHeader className="py-3 px-4">
<CardTitle className="text-base">Group {group.groupName}</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4 space-y-2">
{(group.members || []).map((member: any) => (
<div
key={member.id}
className={`flex items-center justify-between gap-2 p-2 rounded-md border ${
member.eliminated
? "bg-destructive/10 border-destructive/20"
: "bg-background border-border"
}`}
>
<span
className={`text-sm truncate ${
member.eliminated ? "line-through text-muted-foreground" : ""
}`}
>
{member.participant?.name || "Unknown"}
</span>
<Form method="post" className="shrink-0">
<input type="hidden" name="intent" value="toggle-elimination" />
<input type="hidden" name="memberId" value={member.id} />
<Button
type="submit"
size="sm"
variant={member.eliminated ? "outline" : "destructive"}
className="h-7 text-xs"
>
{member.eliminated ? (
<>
<Check className="h-3 w-3 mr-1" />
Reinstate
</>
) : (
<>
<X className="h-3 w-3 mr-1" />
Eliminate
</>
)}
</Button>
</Form>
</div>
))}
</CardContent>
</Card>
))}
</div>
{/* Knockout Assignment Section */}
{groupStageStats && groupStageStats.advancing === 32 && (
<Card className="border-blue-500">
<CardHeader>
<CardTitle>Assign Knockout Bracket</CardTitle>
<CardDescription>
32 teams are advancing. Assign them to the Round of 32 knockout bracket slots.
</CardDescription>
</CardHeader>
<CardContent>
<Form method="post" className="space-y-4">
<input type="hidden" name="intent" value="populate-knockout" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{[...Array(16)].map((_, matchIndex) => {
const matchNum = matchIndex + 1;
const key1 = `match-${matchNum}-participant1Id`;
const key2 = `match-${matchNum}-participant2Id`;
return (
<Card key={matchNum} className="border-dashed">
<CardContent className="p-4 space-y-2">
<div className="text-sm font-semibold text-muted-foreground">
Match {matchNum}
</div>
<input
type="hidden"
name={key1}
value={knockoutAssignments[key1] || ""}
required
/>
<ParticipantSelector
participants={availableAdvancingMap[key1] || []}
value={knockoutAssignments[key1] || ""}
onValueChange={(value) =>
setKnockoutAssignments((prev) => ({ ...prev, [key1]: value }))
}
placeholder="Team 1..."
/>
<div className="text-center text-xs text-muted-foreground">vs</div>
<input
type="hidden"
name={key2}
value={knockoutAssignments[key2] || ""}
required
/>
<ParticipantSelector
participants={availableAdvancingMap[key2] || []}
value={knockoutAssignments[key2] || ""}
onValueChange={(value) =>
setKnockoutAssignments((prev) => ({ ...prev, [key2]: value }))
}
placeholder="Team 2..."
/>
</CardContent>
</Card>
);
})}
</div>
<Button type="submit" className="w-full">
Populate Knockout Bracket
</Button>
</Form>
</CardContent>
</Card>
)}
{groupStageStats && groupStageStats.advancing !== 32 && groupStageStats.eliminated > 0 && (
<Card>
<CardContent className="pt-6">
<p className="text-sm text-muted-foreground">
{groupStageStats.advancing} teams advancing (need exactly 32 to populate knockout bracket).
{groupStageStats.advancing > 32 && ` Eliminate ${groupStageStats.advancing - 32} more.`}
{groupStageStats.advancing < 32 && ` Reinstate ${32 - groupStageStats.advancing} teams or adjust eliminations.`}
</p>
</CardContent>
</Card>
)}
</>
)}
{/* ====== KNOCKOUT BRACKET DISPLAY (Phase 3) ====== */}
{/* Show bracket rounds when: non-group event with matches, OR group event with knockout populated */}
{((matches.length > 0 && !isGroupStageEvent) || knockoutPopulated) && (
<>
{availableRounds.map((round: string) => (
<Card key={round}>
<CardHeader>
<CardTitle>{round}</CardTitle>
<CardDescription>
{matchesByRound[round].length} {matchesByRound[round].length === 1 ? "match" : "matches"}
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-8"></TableHead>
<TableHead className="w-16">Match</TableHead>
<TableHead>Participant 1</TableHead>
<TableHead>Score</TableHead>
<TableHead className="w-24 text-center">vs</TableHead>
<TableHead>Score</TableHead>
<TableHead>Participant 2</TableHead>
<TableHead>Winner</TableHead>
<TableHead className="w-32">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{matchesByRound[round].map((match) => (
<Fragment key={match.id}>
<TableRow>
<TableCell className="w-8 p-1">
<button
type="button"
onClick={() => toggleMatchExpanded(match.id)}
className="text-muted-foreground hover:text-foreground"
aria-label="Toggle games and odds"
>
{expandedMatches.has(match.id)
? <ChevronDown className="h-4 w-4" />
: <ChevronRight className="h-4 w-4" />}
</button>
</TableCell>
<TableCell className="font-semibold">
{match.matchNumber}
</TableCell>
<TableCell>
{match.participant1?.name || "TBD"}
</TableCell>
<TableCell>
{match.participant1Score ? parseFloat(match.participant1Score) : "-"}
</TableCell>
<TableCell className="text-center text-muted-foreground">
vs
</TableCell>
<TableCell>
{match.participant2Score ? parseFloat(match.participant2Score) : "-"}
</TableCell>
<TableCell>
{match.participant2?.name || "TBD"}
</TableCell>
<TableCell>
{match.winner?.name ? (
<div className="flex items-center gap-1">
<Trophy className="h-4 w-4 text-yellow-500" />
{match.winner.name}
</div>
) : (
"-"
)}
</TableCell>
<TableCell>
{!match.isComplete && match.participant1Id && match.participant2Id ? (
<Select
value={selectedWinners[match.id] || ""}
onValueChange={(value) => handleWinnerChange(match.id, value)}
>
<SelectTrigger className="h-8 w-full">
<SelectValue placeholder="Select winner" />
</SelectTrigger>
<SelectContent>
<SelectItem value={match.participant1Id}>
{match.participant1?.name}
</SelectItem>
<SelectItem value={match.participant2Id}>
{match.participant2?.name}
</SelectItem>
</SelectContent>
</Select>
) : match.isComplete ? (
<span className="text-sm text-muted-foreground">Complete</span>
) : (
<span className="text-sm text-muted-foreground">Waiting</span>
)}
</TableCell>
</TableRow>
{/* Expanded: Games & Odds panel */}
{expandedMatches.has(match.id) && (
<TableRow className="bg-muted/30">
<TableCell colSpan={9} className="p-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Games section */}
<div>
<div className="flex items-center gap-2 mb-3">
<Calendar className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-semibold">Game Schedule</span>
</div>
{/* Existing games */}
{(match as any).games?.length > 0 ? (
<div className="space-y-2 mb-3">
{(match as any).games.map((game: any) => (
<div key={game.id} className="flex items-center gap-2 text-sm bg-background rounded p-2 border">
<span className="font-medium w-12 shrink-0">Game {game.gameNumber}</span>
<span className="text-muted-foreground flex-1">
{game.scheduledAt
? new Date(game.scheduledAt).toLocaleString()
: "No date set"}
</span>
<Badge variant={
game.status === "complete" ? "default"
: game.status === "postponed" ? "destructive"
: "secondary"
} className="text-xs">
{game.status}
</Badge>
{game.status === "complete" && game.winner && (
<span className="text-xs text-muted-foreground">
W: {game.winner.name}
</span>
)}
<Form method="post" className="shrink-0">
<input type="hidden" name="intent" value="delete-game" />
<input type="hidden" name="gameId" value={game.id} />
<button type="submit" className="text-muted-foreground hover:text-destructive">
<Trash2 className="h-3.5 w-3.5" />
</button>
</Form>
</div>
))}
</div>
) : (
<p className="text-xs text-muted-foreground mb-3">No games scheduled yet.</p>
)}
{/* Add game form */}
<Form method="post" className="flex gap-2 items-end" onSubmit={(e) => {
const form = e.currentTarget;
// Read from the datetime-local display input (not submitted directly)
const scheduledAtLocal = form.elements.namedItem("scheduledAtLocal") as HTMLInputElement;
// Write the UTC ISO string into the hidden input that IS submitted
const scheduledAtHidden = form.elements.namedItem("scheduledAt") as HTMLInputElement;
if (scheduledAtHidden) {
scheduledAtHidden.value = localDateTimeToUtcIso(scheduledAtLocal?.value) ?? "";
}
}}>
<input type="hidden" name="intent" value="add-game" />
<input type="hidden" name="matchId" value={match.id} />
{/* Hidden field holds the UTC ISO string written by onSubmit */}
<input type="hidden" name="scheduledAt" defaultValue="" />
<div className="flex-1">
<Label className="text-xs">Game #</Label>
<Input
name="gameNumber"
type="number"
min={1}
max={9}
defaultValue={((match as any).games?.length ?? 0) + 1}
className="h-8 w-20"
required
/>
</div>
<div className="flex-1">
<Label className="text-xs">Date &amp; Time ({localTzAbbr})</Label>
<Input
name="scheduledAtLocal"
type="datetime-local"
className="h-8"
/>
</div>
<Button type="submit" size="sm" variant="outline" className="h-8">
<Plus className="h-3.5 w-3.5 mr-1" />
Add
</Button>
</Form>
</div>
{/* Odds section */}
<div>
<div className="flex items-center gap-2 mb-3">
<TrendingUp className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-semibold">Moneyline Odds</span>
</div>
{(match as any).odds?.length > 0 ? (
<div className="space-y-2 mb-3">
{(match as any).odds.map((odd: any) => (
<div key={odd.id} className="flex items-center gap-2 text-sm bg-background rounded p-2 border">
<span className="flex-1 font-medium">{odd.participant?.name}</span>
<span className={`font-mono ${odd.moneylineOdds > 0 ? "text-emerald-500" : "text-muted-foreground"}`}>
{odd.moneylineOdds > 0 ? "+" : ""}{odd.moneylineOdds}
</span>
<span className="text-xs text-muted-foreground">
({(parseFloat(odd.impliedProbability) * 100).toFixed(1)}%)
</span>
{odd.oddsSource && (
<span className="text-xs text-muted-foreground">{odd.oddsSource}</span>
)}
<Form method="post" className="shrink-0">
<input type="hidden" name="intent" value="delete-odds" />
<input type="hidden" name="matchId" value={match.id} />
<input type="hidden" name="participantId" value={odd.participantId} />
<button type="submit" className="text-muted-foreground hover:text-destructive">
<Trash2 className="h-3.5 w-3.5" />
</button>
</Form>
</div>
))}
</div>
) : (
<p className="text-xs text-muted-foreground mb-3">No odds set.</p>
)}
{/* Set odds forms for each participant */}
{[
{ id: match.participant1Id, name: match.participant1?.name },
{ id: match.participant2Id, name: match.participant2?.name },
].filter(p => p.id).map(participant => (
<Form key={participant.id} method="post" className="flex gap-2 items-end mb-2">
<input type="hidden" name="intent" value="upsert-odds" />
<input type="hidden" name="matchId" value={match.id} />
<input type="hidden" name="participantId" value={participant.id!} />
<div className="flex-1">
<Label className="text-xs">{participant.name ?? "TBD"}</Label>
<Input
name="moneylineOdds"
type="number"
placeholder="-110"
className="h-8"
required
/>
</div>
<div className="flex-1">
<Label className="text-xs">Source</Label>
<Input
name="oddsSource"
placeholder="e.g. DraftKings"
className="h-8"
/>
</div>
<Button type="submit" size="sm" variant="outline" className="h-8">
Set
</Button>
</Form>
))}
</div>
</div>
</TableCell>
</TableRow>
)}
</Fragment>
))}
</TableBody>
</Table>
{/* Batch Submit Winners */}
{getPendingWinnersInRound(round).length > 0 && (
<Form method="post" className="mt-4">
<input type="hidden" name="intent" value="set-round-winners" />
<input type="hidden" name="round" value={round} />
{getPendingWinnersInRound(round).map(([matchId, winnerId]) => (
<input key={matchId} type="hidden" name={`winner-${matchId}`} value={winnerId} />
))}
<Button type="submit" className="w-full">
Save {getPendingWinnersInRound(round).length} Winner{getPendingWinnersInRound(round).length > 1 ? 's' : ''} for {round}
</Button>
</Form>
)}
</CardContent>
</Card>
))}
{/* Finalize Bracket Button */}
{allMatchesComplete && !event.isComplete && (
<Card className="border-emerald-500/30 bg-emerald-500/10">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Trophy className="h-5 w-5 text-emerald-400" />
Finalize Bracket
</CardTitle>
<CardDescription>
All matches are complete! Process all rounds and assign final placements.
</CardDescription>
</CardHeader>
<CardContent>
<Form method="post">
<input type="hidden" name="intent" value="finalize-bracket" />
<div className="space-y-4">
<div className="text-sm space-y-2">
<p className="font-medium">This will:</p>
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
<li>Process all playoff rounds in order</li>
<li>Assign fantasy placements (1st-8th) based on bracket results</li>
<li>Assign 0 points to participants not in the bracket</li>
<li>Mark the event as complete</li>
<li>Recalculate all team standings</li>
</ul>
</div>
<Button type="submit" className="w-full">
Finalize Bracket & Update Standings
</Button>
</div>
</Form>
</CardContent>
</Card>
)}
</>
)}
{/* Event Complete Badge */}
{event.isComplete && (
<Card className="border-electric/30 bg-electric/10">
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-electric">
<Trophy className="h-5 w-5" />
<span className="font-semibold">Event Complete</span>
<span className="text-sm text-muted-foreground ml-auto">
{event.completedAt && new Date(event.completedAt).toLocaleDateString()}
</span>
</div>
</CardContent>
</Card>
)}
</div>
</div>
</div>
);
}