feat: add FIFA World Cup 48-team bracket template with group stage and projected scoring
- Add FIFA_48 bracket template with 12 groups of 4, knockout stage of 32 - Add tournament groups schema, model, and GroupStageDisplay component - Add projected/expected value scoring to standings and team breakdowns - Add docker-compose for local PostgreSQL development - Move DRAFT_ORDER_IMPLEMENTATION.md to plans directory Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
840212c4f8
commit
7970cb6a9c
19 changed files with 1590 additions and 303 deletions
155
app/components/scoring/GroupStageDisplay.tsx
Normal file
155
app/components/scoring/GroupStageDisplay.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "~/components/ui/card";
|
||||||
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
import { Users } from "lucide-react";
|
||||||
|
|
||||||
|
interface GroupMember {
|
||||||
|
id: string;
|
||||||
|
eliminated: boolean;
|
||||||
|
participant: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TournamentGroup {
|
||||||
|
id: string;
|
||||||
|
groupName: string;
|
||||||
|
members: GroupMember[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TeamOwnership {
|
||||||
|
participantId: string;
|
||||||
|
teamName: string;
|
||||||
|
teamId: string;
|
||||||
|
ownerName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GroupStageDisplayProps {
|
||||||
|
groups: TournamentGroup[];
|
||||||
|
teamOwnerships?: TeamOwnership[];
|
||||||
|
showOwnership?: boolean;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only display of tournament group stage for league-facing pages.
|
||||||
|
* Shows groups in a responsive card grid with eliminated status indicators.
|
||||||
|
*/
|
||||||
|
export function GroupStageDisplay({
|
||||||
|
groups,
|
||||||
|
teamOwnerships = [],
|
||||||
|
showOwnership = true,
|
||||||
|
title = "Group Stage",
|
||||||
|
description,
|
||||||
|
}: GroupStageDisplayProps) {
|
||||||
|
const ownershipMap = new Map<string, TeamOwnership>();
|
||||||
|
teamOwnerships.forEach((o) => ownershipMap.set(o.participantId, o));
|
||||||
|
|
||||||
|
const getTeamAvatar = (teamName: string) => {
|
||||||
|
const initial = teamName.charAt(0).toUpperCase();
|
||||||
|
const hash = teamName.split("").reduce((acc, char) => acc + char.charCodeAt(0), 0);
|
||||||
|
const colors = [
|
||||||
|
"bg-blue-500",
|
||||||
|
"bg-green-500",
|
||||||
|
"bg-purple-500",
|
||||||
|
"bg-pink-500",
|
||||||
|
"bg-amber-500",
|
||||||
|
"bg-red-500",
|
||||||
|
"bg-indigo-500",
|
||||||
|
"bg-teal-500",
|
||||||
|
];
|
||||||
|
const colorClass = colors[hash % colors.length];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`h-6 w-6 rounded-full ${colorClass} text-white text-xs font-bold flex items-center justify-center`}
|
||||||
|
>
|
||||||
|
{initial}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalTeams = groups.reduce((sum, g) => sum + g.members.length, 0);
|
||||||
|
const eliminatedCount = groups.reduce(
|
||||||
|
(sum, g) => sum + g.members.filter((m) => m.eliminated).length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Users className="h-5 w-5" />
|
||||||
|
{title}
|
||||||
|
</CardTitle>
|
||||||
|
{description && <CardDescription>{description}</CardDescription>}
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
<Badge variant="secondary">{totalTeams} teams</Badge>
|
||||||
|
{eliminatedCount > 0 && (
|
||||||
|
<Badge variant="destructive">{eliminatedCount} eliminated</Badge>
|
||||||
|
)}
|
||||||
|
<Badge variant="default">{totalTeams - eliminatedCount} advancing</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{groups.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
<p className="text-sm">No group stage data available yet.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
|
{groups.map((group) => (
|
||||||
|
<Card key={group.id} className="border-dashed">
|
||||||
|
<CardHeader className="py-3 px-4">
|
||||||
|
<CardTitle className="text-base">Group {group.groupName}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-4 pb-4 space-y-1.5">
|
||||||
|
{group.members.map((member) => {
|
||||||
|
const ownership =
|
||||||
|
showOwnership && member.participant
|
||||||
|
? ownershipMap.get(member.participant.id)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={member.id}
|
||||||
|
className={`flex items-center justify-between gap-2 px-2 py-1.5 rounded-md ${
|
||||||
|
member.eliminated
|
||||||
|
? "bg-destructive/10 text-muted-foreground"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`text-sm truncate ${
|
||||||
|
member.eliminated ? "line-through" : "font-medium"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{member.participant?.name || "Unknown"}
|
||||||
|
</span>
|
||||||
|
{ownership && (
|
||||||
|
<div
|
||||||
|
className="cursor-help shrink-0"
|
||||||
|
title={`${ownership.teamName}${ownership.ownerName ? ` (${ownership.ownerName})` : ""}`}
|
||||||
|
>
|
||||||
|
{getTeamAvatar(ownership.teamName)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -29,6 +29,7 @@ export function StandingsTable({
|
||||||
<TableHead className="w-[80px]">Rank</TableHead>
|
<TableHead className="w-[80px]">Rank</TableHead>
|
||||||
<TableHead>Team</TableHead>
|
<TableHead>Team</TableHead>
|
||||||
<TableHead className="text-right">Points</TableHead>
|
<TableHead className="text-right">Points</TableHead>
|
||||||
|
<TableHead className="text-right">Projected</TableHead>
|
||||||
{showPlacementBreakdown && (
|
{showPlacementBreakdown && (
|
||||||
<TableHead className="text-center">Placements</TableHead>
|
<TableHead className="text-center">Placements</TableHead>
|
||||||
)}
|
)}
|
||||||
|
|
@ -38,7 +39,7 @@ export function StandingsTable({
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{standings.length === 0 ? (
|
{standings.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={showPlacementBreakdown ? 5 : 4} className="text-center text-muted-foreground">
|
<TableCell colSpan={showPlacementBreakdown ? 6 : 5} className="text-center text-muted-foreground">
|
||||||
No standings data available
|
No standings data available
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|
@ -62,7 +63,25 @@ export function StandingsTable({
|
||||||
</Link>
|
</Link>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right font-semibold">
|
<TableCell className="text-right font-semibold">
|
||||||
{standing.totalPoints.toFixed(1)}
|
{standing.actualPoints !== null && standing.actualPoints !== undefined
|
||||||
|
? standing.actualPoints.toFixed(1)
|
||||||
|
: standing.totalPoints.toFixed(1)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{standing.projectedPoints !== null && standing.projectedPoints !== undefined ? (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-semibold text-primary">
|
||||||
|
{standing.projectedPoints.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
{standing.participantsRemaining > 0 && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
+{(standing.projectedPoints - (standing.actualPoints ?? standing.totalPoints)).toFixed(1)} EV
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
{showPlacementBreakdown && (
|
{showPlacementBreakdown && (
|
||||||
<TableCell>
|
<TableCell>
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ interface TeamScoreBreakdownProps {
|
||||||
};
|
};
|
||||||
finalPosition: number | null;
|
finalPosition: number | null;
|
||||||
points: number;
|
points: number;
|
||||||
|
projectedPoints: number | null;
|
||||||
isComplete: boolean;
|
isComplete: boolean;
|
||||||
}>;
|
}>;
|
||||||
bySport: Record<string, { sportsSeasonId: string; picks: Array<{
|
bySport: Record<string, { sportsSeasonId: string; picks: Array<{
|
||||||
|
|
@ -36,9 +37,12 @@ interface TeamScoreBreakdownProps {
|
||||||
};
|
};
|
||||||
finalPosition: number | null;
|
finalPosition: number | null;
|
||||||
points: number;
|
points: number;
|
||||||
|
projectedPoints: number | null;
|
||||||
isComplete: boolean;
|
isComplete: boolean;
|
||||||
}> }>;
|
}> }>;
|
||||||
totalPoints: number;
|
totalPoints: number;
|
||||||
|
actualPoints: number;
|
||||||
|
projectedPoints: number;
|
||||||
completedCount: number;
|
completedCount: number;
|
||||||
totalCount: number;
|
totalCount: number;
|
||||||
};
|
};
|
||||||
|
|
@ -89,11 +93,17 @@ export function TeamScoreBreakdown({
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="text-4xl font-bold text-primary">
|
<div className="text-4xl font-bold text-primary">
|
||||||
{breakdown.totalPoints.toFixed(1)}
|
{breakdown.actualPoints.toFixed(1)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
Total Points
|
Actual Points
|
||||||
</div>
|
</div>
|
||||||
|
{breakdown.projectedPoints > breakdown.actualPoints && (
|
||||||
|
<div className="text-xl font-semibold text-blue-600 mt-1">
|
||||||
|
{breakdown.projectedPoints.toFixed(1)}
|
||||||
|
<span className="text-xs text-muted-foreground ml-1">projected</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{standing && (
|
{standing && (
|
||||||
<Badge className="mt-2" variant={standing.currentRank <= 3 ? "default" : "outline"}>
|
<Badge className="mt-2" variant={standing.currentRank <= 3 ? "default" : "outline"}>
|
||||||
Rank #{standing.currentRank}
|
Rank #{standing.currentRank}
|
||||||
|
|
@ -103,7 +113,39 @@ export function TeamScoreBreakdown({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Summary stats */}
|
{/* Summary stats */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
|
Actual Points
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{breakdown.actualPoints.toFixed(1)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
From {breakdown.completedCount} finished
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
|
Projected Points
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold text-primary">
|
||||||
|
{breakdown.projectedPoints.toFixed(1)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
+{(breakdown.projectedPoints - breakdown.actualPoints).toFixed(1)} expected value
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
|
|
@ -246,10 +288,21 @@ export function TeamScoreBreakdown({
|
||||||
<Badge variant="outline">Pending</Badge>
|
<Badge variant="outline">Pending</Badge>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right font-semibold">
|
<TableCell className="text-right">
|
||||||
{pick.isComplete ? (
|
{pick.isComplete ? (
|
||||||
pick.points > 0 ? pick.points.toFixed(1) : '0.0'
|
<span className="font-semibold">
|
||||||
) : '-'}
|
{pick.points > 0 ? pick.points.toFixed(1) : '0.0'}
|
||||||
|
</span>
|
||||||
|
) : pick.projectedPoints !== null ? (
|
||||||
|
<div className="flex flex-col items-end">
|
||||||
|
<span className="text-xs text-muted-foreground">Projected:</span>
|
||||||
|
<span className="font-semibold text-primary">
|
||||||
|
{pick.projectedPoints.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,15 @@ export interface BracketRound {
|
||||||
isScoring: boolean;
|
isScoring: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GroupStageConfig {
|
||||||
|
/** Number of groups */
|
||||||
|
groupCount: number;
|
||||||
|
/** Teams per group */
|
||||||
|
teamsPerGroup: number;
|
||||||
|
/** Group labels (e.g., ["A", "B", ..., "L"]) */
|
||||||
|
groupLabels: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface BracketTemplate {
|
export interface BracketTemplate {
|
||||||
/** Unique identifier for this template */
|
/** Unique identifier for this template */
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -27,6 +36,10 @@ export interface BracketTemplate {
|
||||||
rounds: BracketRound[];
|
rounds: BracketRound[];
|
||||||
/** Round name where fantasy scoring begins */
|
/** Round name where fantasy scoring begins */
|
||||||
scoringStartsAtRound: string;
|
scoringStartsAtRound: string;
|
||||||
|
/** Optional group stage configuration (e.g., FIFA World Cup) */
|
||||||
|
groupStage?: GroupStageConfig;
|
||||||
|
/** Number of teams advancing to knockout stage (when groupStage is defined) */
|
||||||
|
knockoutTeams?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -324,6 +337,63 @@ export const AFL_10: BracketTemplate = {
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FIFA World Cup 48-team tournament (2026+)
|
||||||
|
* Group stage: 12 groups of 4 teams play round-robin
|
||||||
|
* Knockout stage: 32 teams advance to single-elimination bracket
|
||||||
|
*
|
||||||
|
* Group-to-knockout advancement is fully manual.
|
||||||
|
* Admin marks teams as eliminated from groups, then assigns 32 advancing
|
||||||
|
* teams into knockout bracket slots.
|
||||||
|
*
|
||||||
|
* Only knockout stage awards fantasy points.
|
||||||
|
* Teams eliminated in groups get finalPosition = 0.
|
||||||
|
*/
|
||||||
|
export const FIFA_48: BracketTemplate = {
|
||||||
|
id: "fifa_48",
|
||||||
|
name: "FIFA World Cup (48 teams)",
|
||||||
|
totalTeams: 48,
|
||||||
|
knockoutTeams: 32,
|
||||||
|
scoringStartsAtRound: "Quarterfinals",
|
||||||
|
groupStage: {
|
||||||
|
groupCount: 12,
|
||||||
|
teamsPerGroup: 4,
|
||||||
|
groupLabels: ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L"],
|
||||||
|
},
|
||||||
|
rounds: [
|
||||||
|
{
|
||||||
|
name: "Round of 32",
|
||||||
|
matchCount: 16,
|
||||||
|
feedsInto: "Round of 16",
|
||||||
|
isScoring: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Round of 16",
|
||||||
|
matchCount: 8,
|
||||||
|
feedsInto: "Quarterfinals",
|
||||||
|
isScoring: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Quarterfinals",
|
||||||
|
matchCount: 4,
|
||||||
|
feedsInto: "Semifinals",
|
||||||
|
isScoring: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Semifinals",
|
||||||
|
matchCount: 2,
|
||||||
|
feedsInto: "Finals",
|
||||||
|
isScoring: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Finals",
|
||||||
|
matchCount: 1,
|
||||||
|
feedsInto: null,
|
||||||
|
isScoring: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* All available bracket templates
|
* All available bracket templates
|
||||||
*/
|
*/
|
||||||
|
|
@ -335,6 +405,7 @@ export const BRACKET_TEMPLATES: Record<string, BracketTemplate> = {
|
||||||
ncaa_68: NCAA_68,
|
ncaa_68: NCAA_68,
|
||||||
nfl_14: NFL_14,
|
nfl_14: NFL_14,
|
||||||
afl_10: AFL_10,
|
afl_10: AFL_10,
|
||||||
|
fifa_48: FIFA_48,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -405,6 +405,11 @@ export async function generateBracketFromTemplate(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FIFA 48 generates an empty knockout bracket (participants assigned later via groups)
|
||||||
|
if (templateId === "fifa_48") {
|
||||||
|
return await generateFIFA48Bracket(eventId, template);
|
||||||
|
}
|
||||||
|
|
||||||
// NCAA 68 requires special handling for First Four and Round of 64
|
// NCAA 68 requires special handling for First Four and Round of 64
|
||||||
if (templateId === "ncaa_68") {
|
if (templateId === "ncaa_68") {
|
||||||
return await generateNCAA68Bracket(eventId, template, participantIds);
|
return await generateNCAA68Bracket(eventId, template, participantIds);
|
||||||
|
|
@ -1022,3 +1027,69 @@ async function advanceFirstFourWinner(
|
||||||
// Fill the slot
|
// Fill the slot
|
||||||
await updatePlayoffMatch(targetMatch.id, { participant2Id: winnerId });
|
await updatePlayoffMatch(targetMatch.id, { participant2Id: winnerId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate FIFA 48 knockout bracket (empty, no participants assigned)
|
||||||
|
* Creates 31 matches across 5 rounds identical to simple_32 structure.
|
||||||
|
* Participants are assigned later via assignParticipantsToKnockout after group stage.
|
||||||
|
*/
|
||||||
|
async function generateFIFA48Bracket(
|
||||||
|
eventId: string,
|
||||||
|
template: BracketTemplate
|
||||||
|
): Promise<PlayoffMatch[]> {
|
||||||
|
const matches: NewPlayoffMatch[] = [];
|
||||||
|
|
||||||
|
for (const round of template.rounds) {
|
||||||
|
for (let i = 0; i < round.matchCount; i++) {
|
||||||
|
matches.push({
|
||||||
|
scoringEventId: eventId,
|
||||||
|
round: round.name,
|
||||||
|
matchNumber: i + 1,
|
||||||
|
participant1Id: null,
|
||||||
|
participant2Id: null,
|
||||||
|
isComplete: false,
|
||||||
|
isScoring: round.isScoring,
|
||||||
|
templateRound: round.name,
|
||||||
|
seedInfo: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return await createManyPlayoffMatches(matches);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assign participants to Round of 32 knockout matches
|
||||||
|
* Used after group stage to populate the empty knockout bracket
|
||||||
|
*
|
||||||
|
* @param eventId - The scoring event ID
|
||||||
|
* @param assignments - Array of { matchNumber, slot, participantId }
|
||||||
|
*/
|
||||||
|
export async function assignParticipantsToKnockout(
|
||||||
|
eventId: string,
|
||||||
|
assignments: Array<{
|
||||||
|
matchNumber: number;
|
||||||
|
slot: "participant1Id" | "participant2Id";
|
||||||
|
participantId: string;
|
||||||
|
}>
|
||||||
|
): Promise<void> {
|
||||||
|
// Get Round of 32 matches
|
||||||
|
const r32Matches = await findPlayoffMatchesByEventIdAndRound(eventId, "Round of 32");
|
||||||
|
|
||||||
|
for (const assignment of assignments) {
|
||||||
|
const match = r32Matches.find((m) => m.matchNumber === assignment.matchNumber);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error(`Round of 32 match ${assignment.matchNumber} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match[assignment.slot]) {
|
||||||
|
throw new Error(
|
||||||
|
`Round of 32 match ${assignment.matchNumber} ${assignment.slot} is already filled`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await updatePlayoffMatch(match.id, {
|
||||||
|
[assignment.slot]: assignment.participantId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -683,6 +683,107 @@ export async function calculateTeamScore(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate projected total points for a team
|
||||||
|
* Phase 5.4: Includes actual points from finished participants + EVs from unfinished
|
||||||
|
*/
|
||||||
|
export async function calculateTeamProjectedScore(
|
||||||
|
teamId: string,
|
||||||
|
seasonId: string,
|
||||||
|
providedDb?: ReturnType<typeof database>
|
||||||
|
): Promise<{
|
||||||
|
actualPoints: number;
|
||||||
|
projectedPoints: number;
|
||||||
|
participantsFinished: number;
|
||||||
|
participantsRemaining: number;
|
||||||
|
}> {
|
||||||
|
const db = providedDb || database();
|
||||||
|
|
||||||
|
// Get season scoring rules
|
||||||
|
const scoringRules = await getScoringRules(seasonId, db);
|
||||||
|
if (!scoringRules) {
|
||||||
|
throw new Error(`Season ${seasonId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all draft picks for this team with their results and EVs
|
||||||
|
const picks = await db.query.draftPicks.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(schema.draftPicks.teamId, teamId),
|
||||||
|
eq(schema.draftPicks.seasonId, seasonId)
|
||||||
|
),
|
||||||
|
with: {
|
||||||
|
participant: {
|
||||||
|
with: {
|
||||||
|
results: true,
|
||||||
|
sportsSeason: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let actualPoints = 0;
|
||||||
|
let participantsFinished = 0;
|
||||||
|
const unfinishedParticipants: Array<{ participantId: string; sportsSeasonId: string }> = [];
|
||||||
|
|
||||||
|
// Separate finished vs unfinished participants
|
||||||
|
for (const pick of picks) {
|
||||||
|
const result = pick.participant.results[0];
|
||||||
|
|
||||||
|
if (result && result.finalPosition !== null) {
|
||||||
|
// Participant has finished - use actual points
|
||||||
|
const points = calculateFantasyPoints(result.finalPosition, scoringRules);
|
||||||
|
actualPoints += points;
|
||||||
|
participantsFinished++;
|
||||||
|
} else {
|
||||||
|
// Participant is unfinished - will need EV
|
||||||
|
unfinishedParticipants.push({
|
||||||
|
participantId: pick.participant.id,
|
||||||
|
sportsSeasonId: pick.participant.sportsSeasonId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get EVs for unfinished participants
|
||||||
|
let evSum = 0;
|
||||||
|
if (unfinishedParticipants.length > 0) {
|
||||||
|
// Import the participant EV model
|
||||||
|
const { getParticipantEV } = await import("./participant-expected-value");
|
||||||
|
const { calculateEV } = await import("~/services/ev-calculator");
|
||||||
|
|
||||||
|
for (const { participantId, sportsSeasonId } of unfinishedParticipants) {
|
||||||
|
const ev = await getParticipantEV(participantId, sportsSeasonId);
|
||||||
|
|
||||||
|
if (ev) {
|
||||||
|
// EV is already calculated with default scoring (100/70/50/40/25/25/15/15)
|
||||||
|
// We need to recalculate with THIS league's scoring rules
|
||||||
|
const probabilities = {
|
||||||
|
probFirst: parseFloat(ev.probFirst),
|
||||||
|
probSecond: parseFloat(ev.probSecond),
|
||||||
|
probThird: parseFloat(ev.probThird),
|
||||||
|
probFourth: parseFloat(ev.probFourth),
|
||||||
|
probFifth: parseFloat(ev.probFifth),
|
||||||
|
probSixth: parseFloat(ev.probSixth),
|
||||||
|
probSeventh: parseFloat(ev.probSeventh),
|
||||||
|
probEighth: parseFloat(ev.probEighth),
|
||||||
|
};
|
||||||
|
|
||||||
|
const leagueSpecificEV = calculateEV(probabilities, scoringRules);
|
||||||
|
evSum += leagueSpecificEV;
|
||||||
|
}
|
||||||
|
// If no EV exists, assume 0 (participant hasn't been evaluated yet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectedPoints = actualPoints + evSum;
|
||||||
|
|
||||||
|
return {
|
||||||
|
actualPoints: Math.round(actualPoints * 100) / 100,
|
||||||
|
projectedPoints: Math.round(projectedPoints * 100) / 100,
|
||||||
|
participantsFinished,
|
||||||
|
participantsRemaining: unfinishedParticipants.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compare two teams for ranking purposes using tiebreaker logic
|
* Compare two teams for ranking purposes using tiebreaker logic
|
||||||
* Returns: negative if teamA ranks higher, positive if teamB ranks higher, 0 if tied
|
* Returns: negative if teamA ranks higher, positive if teamB ranks higher, 0 if tied
|
||||||
|
|
@ -780,12 +881,16 @@ export async function recalculateStandings(
|
||||||
const teamScores = await Promise.all(
|
const teamScores = await Promise.all(
|
||||||
teams.map(async (team) => {
|
teams.map(async (team) => {
|
||||||
const score = await calculateTeamScore(team.id, seasonId, db);
|
const score = await calculateTeamScore(team.id, seasonId, db);
|
||||||
|
const projected = await calculateTeamProjectedScore(team.id, seasonId, db);
|
||||||
return {
|
return {
|
||||||
teamId: team.id,
|
teamId: team.id,
|
||||||
totalPoints: score.totalPoints,
|
totalPoints: score.totalPoints,
|
||||||
placementCounts: score.placementCounts,
|
placementCounts: score.placementCounts,
|
||||||
participantsCompleted: score.participantsCompleted,
|
participantsCompleted: score.participantsCompleted,
|
||||||
participantsTotal: score.participantsTotal,
|
participantsTotal: score.participantsTotal,
|
||||||
|
actualPoints: projected.actualPoints,
|
||||||
|
projectedPoints: projected.projectedPoints,
|
||||||
|
participantsFinished: projected.participantsFinished,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
@ -819,6 +924,10 @@ export async function recalculateStandings(
|
||||||
eighthPlaceCount: teamScore.placementCounts[8],
|
eighthPlaceCount: teamScore.placementCounts[8],
|
||||||
participantsRemaining:
|
participantsRemaining:
|
||||||
teamScore.participantsTotal - teamScore.participantsCompleted,
|
teamScore.participantsTotal - teamScore.participantsCompleted,
|
||||||
|
// Phase 5.4: Projected points tracking
|
||||||
|
actualPoints: teamScore.actualPoints?.toString() ?? null,
|
||||||
|
projectedPoints: teamScore.projectedPoints?.toString() ?? null,
|
||||||
|
participantsFinished: teamScore.participantsFinished ?? null,
|
||||||
calculatedAt: new Date(),
|
calculatedAt: new Date(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,10 @@ export async function getSeasonStandings(
|
||||||
},
|
},
|
||||||
participantsRemaining: standing.participantsRemaining,
|
participantsRemaining: standing.participantsRemaining,
|
||||||
calculatedAt: standing.calculatedAt,
|
calculatedAt: standing.calculatedAt,
|
||||||
|
// Phase 5.4: Include projected points
|
||||||
|
actualPoints: standing.actualPoints ? parseFloat(standing.actualPoints) : null,
|
||||||
|
projectedPoints: standing.projectedPoints ? parseFloat(standing.projectedPoints) : null,
|
||||||
|
participantsFinished: standing.participantsFinished,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -92,6 +96,10 @@ export async function getTeamStanding(
|
||||||
},
|
},
|
||||||
participantsRemaining: standing.participantsRemaining,
|
participantsRemaining: standing.participantsRemaining,
|
||||||
calculatedAt: standing.calculatedAt,
|
calculatedAt: standing.calculatedAt,
|
||||||
|
// Phase 5.4: Include projected points
|
||||||
|
actualPoints: standing.actualPoints ? parseFloat(standing.actualPoints) : null,
|
||||||
|
projectedPoints: standing.projectedPoints ? parseFloat(standing.projectedPoints) : null,
|
||||||
|
participantsFinished: standing.participantsFinished,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -133,41 +141,82 @@ export async function getTeamScoreBreakdown(
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Calculate points for each pick
|
// Get scoring rules for EV calculation
|
||||||
const pickBreakdown = picks.map((pick) => {
|
const scoringRules = {
|
||||||
const result = pick.participant.results[0];
|
pointsFor1st: season.pointsFor1st,
|
||||||
let points = 0;
|
pointsFor2nd: season.pointsFor2nd,
|
||||||
|
pointsFor3rd: season.pointsFor3rd,
|
||||||
|
pointsFor4th: season.pointsFor4th,
|
||||||
|
pointsFor5th: season.pointsFor5th,
|
||||||
|
pointsFor6th: season.pointsFor6th,
|
||||||
|
pointsFor7th: season.pointsFor7th,
|
||||||
|
pointsFor8th: season.pointsFor8th,
|
||||||
|
};
|
||||||
|
|
||||||
if (result && result.finalPosition) {
|
// Calculate points and projected points for each pick
|
||||||
// Calculate points based on placement
|
const pickBreakdown = await Promise.all(
|
||||||
const pointsMap: Record<number, number> = {
|
picks.map(async (pick) => {
|
||||||
1: season.pointsFor1st,
|
const result = pick.participant.results[0];
|
||||||
2: season.pointsFor2nd,
|
let points = 0;
|
||||||
3: season.pointsFor3rd,
|
let projectedPoints: number | null = null;
|
||||||
4: season.pointsFor4th,
|
|
||||||
5: season.pointsFor5th,
|
if (result && result.finalPosition) {
|
||||||
6: season.pointsFor6th,
|
// Calculate points based on placement
|
||||||
7: season.pointsFor7th,
|
const pointsMap: Record<number, number> = {
|
||||||
8: season.pointsFor8th,
|
1: season.pointsFor1st,
|
||||||
|
2: season.pointsFor2nd,
|
||||||
|
3: season.pointsFor3rd,
|
||||||
|
4: season.pointsFor4th,
|
||||||
|
5: season.pointsFor5th,
|
||||||
|
6: season.pointsFor6th,
|
||||||
|
7: season.pointsFor7th,
|
||||||
|
8: season.pointsFor8th,
|
||||||
|
};
|
||||||
|
points = pointsMap[result.finalPosition] || 0;
|
||||||
|
projectedPoints = points; // Finished = projected equals actual
|
||||||
|
} else {
|
||||||
|
// Participant is unfinished - get EV
|
||||||
|
const { getParticipantEV } = await import("./participant-expected-value");
|
||||||
|
const { calculateEV } = await import("~/services/ev-calculator");
|
||||||
|
|
||||||
|
const ev = await getParticipantEV(
|
||||||
|
pick.participant.id,
|
||||||
|
pick.participant.sportsSeasonId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (ev) {
|
||||||
|
const probabilities = {
|
||||||
|
probFirst: parseFloat(ev.probFirst),
|
||||||
|
probSecond: parseFloat(ev.probSecond),
|
||||||
|
probThird: parseFloat(ev.probThird),
|
||||||
|
probFourth: parseFloat(ev.probFourth),
|
||||||
|
probFifth: parseFloat(ev.probFifth),
|
||||||
|
probSixth: parseFloat(ev.probSixth),
|
||||||
|
probSeventh: parseFloat(ev.probSeventh),
|
||||||
|
probEighth: parseFloat(ev.probEighth),
|
||||||
|
};
|
||||||
|
|
||||||
|
projectedPoints = calculateEV(probabilities, scoringRules);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
pickNumber: pick.pickNumber,
|
||||||
|
round: pick.round,
|
||||||
|
participant: {
|
||||||
|
id: pick.participant.id,
|
||||||
|
name: pick.participant.name,
|
||||||
|
sport: pick.participant.sportsSeason.sport.name,
|
||||||
|
sportsSeasonId: pick.participant.sportsSeasonId,
|
||||||
|
},
|
||||||
|
finalPosition: result?.finalPosition ?? null,
|
||||||
|
points,
|
||||||
|
projectedPoints,
|
||||||
|
// A participant is complete if they have a result record (even if finalPosition is 0)
|
||||||
|
isComplete: !!result,
|
||||||
};
|
};
|
||||||
points = pointsMap[result.finalPosition] || 0;
|
})
|
||||||
}
|
);
|
||||||
|
|
||||||
return {
|
|
||||||
pickNumber: pick.pickNumber,
|
|
||||||
round: pick.round,
|
|
||||||
participant: {
|
|
||||||
id: pick.participant.id,
|
|
||||||
name: pick.participant.name,
|
|
||||||
sport: pick.participant.sportsSeason.sport.name,
|
|
||||||
sportsSeasonId: pick.participant.sportsSeasonId,
|
|
||||||
},
|
|
||||||
finalPosition: result?.finalPosition ?? null,
|
|
||||||
points,
|
|
||||||
// A participant is complete if they have a result record (even if finalPosition is 0)
|
|
||||||
isComplete: !!result,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Group by sport for easier display
|
// Group by sport for easier display
|
||||||
// Structure: { sportName: { sportsSeasonId, picks } }
|
// Structure: { sportName: { sportsSeasonId, picks } }
|
||||||
|
|
@ -183,6 +232,15 @@ export async function getTeamScoreBreakdown(
|
||||||
bySport[sport].picks.push(pick);
|
bySport[sport].picks.push(pick);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const actualPoints = pickBreakdown
|
||||||
|
.filter((p) => p.isComplete)
|
||||||
|
.reduce((sum, p) => sum + p.points, 0);
|
||||||
|
|
||||||
|
const projectedTotalPoints = pickBreakdown.reduce(
|
||||||
|
(sum, p) => sum + (p.projectedPoints ?? 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
team: await db.query.teams.findFirst({
|
team: await db.query.teams.findFirst({
|
||||||
where: eq(schema.teams.id, teamId),
|
where: eq(schema.teams.id, teamId),
|
||||||
|
|
@ -190,6 +248,8 @@ export async function getTeamScoreBreakdown(
|
||||||
picks: pickBreakdown,
|
picks: pickBreakdown,
|
||||||
bySport,
|
bySport,
|
||||||
totalPoints: pickBreakdown.reduce((sum, p) => sum + p.points, 0),
|
totalPoints: pickBreakdown.reduce((sum, p) => sum + p.points, 0),
|
||||||
|
actualPoints,
|
||||||
|
projectedPoints: projectedTotalPoints,
|
||||||
completedCount: pickBreakdown.filter((p) => p.isComplete).length,
|
completedCount: pickBreakdown.filter((p) => p.isComplete).length,
|
||||||
totalCount: pickBreakdown.length,
|
totalCount: pickBreakdown.length,
|
||||||
};
|
};
|
||||||
|
|
@ -303,6 +363,10 @@ export async function createDailySnapshot(
|
||||||
seventhPlaceCount: standing.seventhPlaceCount,
|
seventhPlaceCount: standing.seventhPlaceCount,
|
||||||
eighthPlaceCount: standing.eighthPlaceCount,
|
eighthPlaceCount: standing.eighthPlaceCount,
|
||||||
participantsRemaining: standing.participantsRemaining,
|
participantsRemaining: standing.participantsRemaining,
|
||||||
|
// Phase 5.4: Copy projected points tracking
|
||||||
|
actualPoints: standing.actualPoints,
|
||||||
|
projectedPoints: standing.projectedPoints,
|
||||||
|
participantsFinished: standing.participantsFinished,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
143
app/models/tournament-group.ts
Normal file
143
app/models/tournament-group.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
import { eq, and, inArray } from "drizzle-orm";
|
||||||
|
import { database } from "~/database/context";
|
||||||
|
import * as schema from "~/database/schema";
|
||||||
|
|
||||||
|
export type TournamentGroup = typeof schema.tournamentGroups.$inferSelect;
|
||||||
|
export type TournamentGroupMember = typeof schema.tournamentGroupMembers.$inferSelect;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create empty tournament groups for a scoring event
|
||||||
|
*/
|
||||||
|
export async function createGroupsForEvent(
|
||||||
|
eventId: string,
|
||||||
|
groupLabels: string[]
|
||||||
|
): Promise<TournamentGroup[]> {
|
||||||
|
const db = database();
|
||||||
|
const groups = await db
|
||||||
|
.insert(schema.tournamentGroups)
|
||||||
|
.values(
|
||||||
|
groupLabels.map((label) => ({
|
||||||
|
scoringEventId: eventId,
|
||||||
|
groupName: label,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add participants to a tournament group
|
||||||
|
*/
|
||||||
|
export async function addMembersToGroup(
|
||||||
|
groupId: string,
|
||||||
|
participantIds: string[]
|
||||||
|
): Promise<TournamentGroupMember[]> {
|
||||||
|
const db = database();
|
||||||
|
const members = await db
|
||||||
|
.insert(schema.tournamentGroupMembers)
|
||||||
|
.values(
|
||||||
|
participantIds.map((participantId) => ({
|
||||||
|
tournamentGroupId: groupId,
|
||||||
|
participantId,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
return members;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all tournament groups for a scoring event with members and participant data
|
||||||
|
*/
|
||||||
|
export async function findGroupsByEventId(eventId: string) {
|
||||||
|
const db = database();
|
||||||
|
return await db.query.tournamentGroups.findMany({
|
||||||
|
where: eq(schema.tournamentGroups.scoringEventId, eventId),
|
||||||
|
orderBy: (groups, { asc }) => [asc(groups.groupName)],
|
||||||
|
with: {
|
||||||
|
members: {
|
||||||
|
with: {
|
||||||
|
participant: true,
|
||||||
|
},
|
||||||
|
orderBy: (members, { asc }) => [asc(members.createdAt)],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle the eliminated status of a group member
|
||||||
|
*/
|
||||||
|
export async function toggleMemberEliminated(
|
||||||
|
memberId: string
|
||||||
|
): Promise<TournamentGroupMember> {
|
||||||
|
const db = database();
|
||||||
|
|
||||||
|
// Get current state
|
||||||
|
const member = await db.query.tournamentGroupMembers.findFirst({
|
||||||
|
where: eq(schema.tournamentGroupMembers.id, memberId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!member) {
|
||||||
|
throw new Error("Tournament group member not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(schema.tournamentGroupMembers)
|
||||||
|
.set({
|
||||||
|
eliminated: !member.eliminated,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(schema.tournamentGroupMembers.id, memberId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get participant IDs of non-eliminated group members (advancing teams)
|
||||||
|
*/
|
||||||
|
export async function getAdvancingParticipantIds(
|
||||||
|
eventId: string
|
||||||
|
): Promise<string[]> {
|
||||||
|
const db = database();
|
||||||
|
const groups = await db.query.tournamentGroups.findMany({
|
||||||
|
where: eq(schema.tournamentGroups.scoringEventId, eventId),
|
||||||
|
with: {
|
||||||
|
members: {
|
||||||
|
where: eq(schema.tournamentGroupMembers.eliminated, false),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return groups.flatMap((g) => g.members.map((m) => m.participantId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get participant IDs of eliminated group members
|
||||||
|
*/
|
||||||
|
export async function getEliminatedParticipantIds(
|
||||||
|
eventId: string
|
||||||
|
): Promise<string[]> {
|
||||||
|
const db = database();
|
||||||
|
const groups = await db.query.tournamentGroups.findMany({
|
||||||
|
where: eq(schema.tournamentGroups.scoringEventId, eventId),
|
||||||
|
with: {
|
||||||
|
members: {
|
||||||
|
where: eq(schema.tournamentGroupMembers.eliminated, true),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return groups.flatMap((g) => g.members.map((m) => m.participantId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete all tournament groups for a scoring event (for regeneration)
|
||||||
|
*/
|
||||||
|
export async function deleteGroupsByEventId(eventId: string): Promise<void> {
|
||||||
|
const db = database();
|
||||||
|
// Members cascade-delete when groups are deleted
|
||||||
|
await db
|
||||||
|
.delete(schema.tournamentGroups)
|
||||||
|
.where(eq(schema.tournamentGroups.scoringEventId, eventId));
|
||||||
|
}
|
||||||
|
|
@ -9,9 +9,18 @@ import {
|
||||||
setMatchWinner,
|
setMatchWinner,
|
||||||
advanceWinnerTemplate,
|
advanceWinnerTemplate,
|
||||||
findPlayoffMatchById,
|
findPlayoffMatchById,
|
||||||
|
assignParticipantsToKnockout,
|
||||||
} from "~/models/playoff-match";
|
} from "~/models/playoff-match";
|
||||||
import { processPlayoffEvent } from "~/models/scoring-calculator";
|
import { processPlayoffEvent } from "~/models/scoring-calculator";
|
||||||
import { getBracketTemplate } from "~/lib/bracket-templates";
|
import { getBracketTemplate } from "~/lib/bracket-templates";
|
||||||
|
import {
|
||||||
|
createGroupsForEvent,
|
||||||
|
addMembersToGroup,
|
||||||
|
findGroupsByEventId,
|
||||||
|
toggleMemberEliminated,
|
||||||
|
getAdvancingParticipantIds,
|
||||||
|
getEliminatedParticipantIds,
|
||||||
|
} from "~/models/tournament-group";
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
|
|
@ -36,6 +45,9 @@ export async function loader({ params }: Route.LoaderArgs) {
|
||||||
const participants = await findParticipantsBySportsSeasonId(params.id);
|
const participants = await findParticipantsBySportsSeasonId(params.id);
|
||||||
const matches = await findPlayoffMatchesByEventId(params.eventId);
|
const matches = await findPlayoffMatchesByEventId(params.eventId);
|
||||||
|
|
||||||
|
// Fetch tournament groups if this event has a group-stage template
|
||||||
|
const tournamentGroups = await findGroupsByEventId(params.eventId);
|
||||||
|
|
||||||
// The matches already include participant relations from the model query
|
// The matches already include participant relations from the model query
|
||||||
return {
|
return {
|
||||||
sportsSeason: sportsSeason as typeof sportsSeason & {
|
sportsSeason: sportsSeason as typeof sportsSeason & {
|
||||||
|
|
@ -49,6 +61,7 @@ export async function loader({ params }: Route.LoaderArgs) {
|
||||||
winner: { id: string; name: string } | null;
|
winner: { id: string; name: string } | null;
|
||||||
loser: { id: string; name: string } | null;
|
loser: { id: string; name: string } | null;
|
||||||
}>,
|
}>,
|
||||||
|
tournamentGroups,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -552,5 +565,157 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (intent === "generate-groups") {
|
||||||
|
const templateId = formData.get("templateId");
|
||||||
|
|
||||||
|
if (typeof templateId !== "string" || !templateId) {
|
||||||
|
return { error: "Template ID is required" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const template = getBracketTemplate(templateId);
|
||||||
|
if (!template || !template.groupStage) {
|
||||||
|
return { error: "Invalid template or template has no group stage" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { groupStage } = template;
|
||||||
|
const totalTeams = groupStage.groupCount * groupStage.teamsPerGroup;
|
||||||
|
|
||||||
|
// Collect participant IDs from form
|
||||||
|
const participantIds: string[] = [];
|
||||||
|
for (let i = 0; i < totalTeams; i++) {
|
||||||
|
const participantId = formData.get(`participant${i}`);
|
||||||
|
if (typeof participantId !== "string" || !participantId) {
|
||||||
|
return { error: `Participant ${i + 1} is required` };
|
||||||
|
}
|
||||||
|
participantIds.push(participantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for duplicates
|
||||||
|
const uniqueParticipants = new Set(participantIds);
|
||||||
|
if (uniqueParticipants.size !== participantIds.length) {
|
||||||
|
return { error: "Each participant can only be selected once" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Update the event with template info
|
||||||
|
await updateScoringEvent(params.eventId, {
|
||||||
|
bracketTemplateId: templateId,
|
||||||
|
scoringStartsAtRound: template.scoringStartsAtRound,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create tournament groups
|
||||||
|
const groups = await createGroupsForEvent(params.eventId, groupStage.groupLabels);
|
||||||
|
|
||||||
|
// Distribute participants into groups (in selection order)
|
||||||
|
for (let groupIndex = 0; groupIndex < groups.length; groupIndex++) {
|
||||||
|
const startIdx = groupIndex * groupStage.teamsPerGroup;
|
||||||
|
const groupParticipantIds = participantIds.slice(
|
||||||
|
startIdx,
|
||||||
|
startIdx + groupStage.teamsPerGroup
|
||||||
|
);
|
||||||
|
await addMembersToGroup(groups[groupIndex].id, groupParticipantIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate the empty knockout bracket structure
|
||||||
|
await generateBracketFromTemplate(params.eventId, templateId);
|
||||||
|
|
||||||
|
return { success: "Groups and knockout bracket structure created successfully" };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error generating groups:", error);
|
||||||
|
return {
|
||||||
|
error: error instanceof Error ? error.message : "Failed to generate groups",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent === "toggle-elimination") {
|
||||||
|
const memberId = formData.get("memberId");
|
||||||
|
|
||||||
|
if (typeof memberId !== "string" || !memberId) {
|
||||||
|
return { error: "Member ID is required" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await toggleMemberEliminated(memberId);
|
||||||
|
return {
|
||||||
|
success: updated.eliminated
|
||||||
|
? "Team marked as eliminated"
|
||||||
|
: "Team reinstated",
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error toggling elimination:", error);
|
||||||
|
return {
|
||||||
|
error: error instanceof Error ? error.message : "Failed to toggle elimination",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent === "populate-knockout") {
|
||||||
|
try {
|
||||||
|
const event = await getScoringEventById(params.eventId);
|
||||||
|
if (!event) {
|
||||||
|
return { error: "Event not found" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse match assignments from form
|
||||||
|
const assignments: Array<{
|
||||||
|
matchNumber: number;
|
||||||
|
slot: "participant1Id" | "participant2Id";
|
||||||
|
participantId: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (const [key, value] of formData.entries()) {
|
||||||
|
const matchPattern = /^match-(\d+)-(participant1Id|participant2Id)$/;
|
||||||
|
const match = key.match(matchPattern);
|
||||||
|
if (match && typeof value === "string" && value) {
|
||||||
|
assignments.push({
|
||||||
|
matchNumber: parseInt(match[1], 10),
|
||||||
|
slot: match[2] as "participant1Id" | "participant2Id",
|
||||||
|
participantId: value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assignments.length !== 32) {
|
||||||
|
return { error: `Expected 32 assignments but got ${assignments.length}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate all assignments are unique participants
|
||||||
|
const assignedParticipantIds = new Set(assignments.map((a) => a.participantId));
|
||||||
|
if (assignedParticipantIds.size !== 32) {
|
||||||
|
return { error: "All 32 knockout slots must have unique participants" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate all assigned participants are non-eliminated
|
||||||
|
const advancingIds = new Set(await getAdvancingParticipantIds(params.eventId));
|
||||||
|
for (const participantId of assignedParticipantIds) {
|
||||||
|
if (!advancingIds.has(participantId)) {
|
||||||
|
return { error: "All assigned participants must be non-eliminated group members" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign participants to knockout bracket
|
||||||
|
await assignParticipantsToKnockout(params.eventId, assignments);
|
||||||
|
|
||||||
|
// Mark eliminated group participants with finalPosition = 0
|
||||||
|
const eliminatedIds = await getEliminatedParticipantIds(params.eventId);
|
||||||
|
const { setParticipantResult } = await import("~/models/participant-result");
|
||||||
|
for (const participantId of eliminatedIds) {
|
||||||
|
await setParticipantResult(participantId, event.sportsSeasonId, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[PopulateKnockout] Assigned 32 participants to knockout, marked ${eliminatedIds.length} as eliminated`
|
||||||
|
);
|
||||||
|
|
||||||
|
return { success: "Knockout bracket populated successfully" };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error populating knockout:", error);
|
||||||
|
return {
|
||||||
|
error: error instanceof Error ? error.message : "Failed to populate knockout",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { error: "Invalid action" };
|
return { error: "Invalid action" };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "~/components/ui/select";
|
} from "~/components/ui/select";
|
||||||
import { ArrowLeft, Plus, Trophy } from "lucide-react";
|
import { ArrowLeft, Plus, Trophy, X, Check } from "lucide-react";
|
||||||
import { getAllBracketTemplates, getBracketTemplate } from "~/lib/bracket-templates";
|
import { getAllBracketTemplates, getBracketTemplate } from "~/lib/bracket-templates";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
|
|
@ -29,6 +29,7 @@ import {
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "~/components/ui/table";
|
} from "~/components/ui/table";
|
||||||
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { ParticipantSelector } from "~/components/ParticipantSelector";
|
import { ParticipantSelector } from "~/components/ParticipantSelector";
|
||||||
|
|
||||||
export { loader, action };
|
export { loader, action };
|
||||||
|
|
@ -37,13 +38,75 @@ export default function EventBracket({
|
||||||
loaderData,
|
loaderData,
|
||||||
actionData,
|
actionData,
|
||||||
}: Route.ComponentProps) {
|
}: Route.ComponentProps) {
|
||||||
const { sportsSeason, event, participants, matches } = loaderData;
|
const { sportsSeason, event, participants, matches, tournamentGroups } = loaderData;
|
||||||
const [selectedTemplate, setSelectedTemplate] = useState("simple_8");
|
const [selectedTemplate, setSelectedTemplate] = useState("simple_8");
|
||||||
const [selectedParticipants, setSelectedParticipants] = useState<Record<number, string>>({});
|
const [selectedParticipants, setSelectedParticipants] = useState<Record<number, string>>({});
|
||||||
const [selectedWinners, setSelectedWinners] = useState<Record<string, string>>({});
|
const [selectedWinners, setSelectedWinners] = useState<Record<string, string>>({});
|
||||||
|
const [knockoutAssignments, setKnockoutAssignments] = useState<Record<string, string>>({});
|
||||||
const templates = getAllBracketTemplates();
|
const templates = getAllBracketTemplates();
|
||||||
const template = templates.find((t) => t.id === selectedTemplate);
|
const template = templates.find((t) => t.id === selectedTemplate);
|
||||||
|
|
||||||
|
// Determine if this is a group-stage event
|
||||||
|
const hasGroupStage = template?.groupStage != null;
|
||||||
|
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
|
// Clear selected winners after successful batch submission
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (actionData?.success) {
|
if (actionData?.success) {
|
||||||
|
|
@ -65,30 +128,21 @@ export default function EventBracket({
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Memoized available participants calculation - only recalculates when dependencies change
|
// Memoized available participants calculation
|
||||||
// This prevents 68 × 68 filter operations on every render
|
|
||||||
const availableParticipantsMap = useMemo(() => {
|
const availableParticipantsMap = useMemo(() => {
|
||||||
// Build a Set of selected IDs for O(1) lookup
|
|
||||||
const selectedIdsSet = new Set(Object.values(selectedParticipants));
|
const selectedIdsSet = new Set(Object.values(selectedParticipants));
|
||||||
|
|
||||||
// Pre-calculate available participants for each seed slot
|
|
||||||
const map: Record<number, typeof participants> = {};
|
const map: Record<number, typeof participants> = {};
|
||||||
const totalSeeds = template?.totalTeams || 8;
|
const totalSeeds = template?.totalTeams || 8;
|
||||||
|
|
||||||
for (let i = 0; i < totalSeeds; i++) {
|
for (let i = 0; i < totalSeeds; i++) {
|
||||||
// For this seed slot, allow its current selection but exclude all others
|
|
||||||
const currentSelection = selectedParticipants[i];
|
const currentSelection = selectedParticipants[i];
|
||||||
|
|
||||||
map[i] = participants.filter((p: { id: string; name: string }) => {
|
map[i] = participants.filter((p: { id: string; name: string }) => {
|
||||||
// Allow if not selected, OR if it's the current selection for this seed
|
|
||||||
return !selectedIdsSet.has(p.id) || p.id === currentSelection;
|
return !selectedIdsSet.has(p.id) || p.id === currentSelection;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return map;
|
return map;
|
||||||
}, [selectedParticipants, participants, template?.totalTeams]);
|
}, [selectedParticipants, participants, template?.totalTeams]);
|
||||||
|
|
||||||
// Get available participants for a specific seed (now just a lookup)
|
|
||||||
const getAvailableParticipants = (seedIndex: number) => {
|
const getAvailableParticipants = (seedIndex: number) => {
|
||||||
return availableParticipantsMap[seedIndex] || participants;
|
return availableParticipantsMap[seedIndex] || participants;
|
||||||
};
|
};
|
||||||
|
|
@ -122,40 +176,30 @@ export default function EventBracket({
|
||||||
return grouped;
|
return grouped;
|
||||||
}, [matches]);
|
}, [matches]);
|
||||||
|
|
||||||
// Memoized: Get available rounds in chronological order (first round to finals)
|
// Memoized: Get available rounds in chronological order
|
||||||
const availableRounds = useMemo(() => {
|
const availableRounds = useMemo(() => {
|
||||||
const roundsInMatches = Array.from(new Set(matches.map(m => m.round)));
|
const roundsInMatches = Array.from(new Set(matches.map(m => m.round)));
|
||||||
|
|
||||||
// If event has a bracket template, use its round order
|
|
||||||
if (event.bracketTemplateId) {
|
if (event.bracketTemplateId) {
|
||||||
const eventTemplate = getBracketTemplate(event.bracketTemplateId);
|
const eventTemplate = getBracketTemplate(event.bracketTemplateId);
|
||||||
if (eventTemplate) {
|
if (eventTemplate) {
|
||||||
// Filter template rounds to only those that have matches
|
|
||||||
return eventTemplate.rounds
|
return eventTemplate.rounds
|
||||||
.map((r) => r.name)
|
.map((r) => r.name)
|
||||||
.filter((roundName: string) => roundsInMatches.includes(roundName));
|
.filter((roundName: string) => roundsInMatches.includes(roundName));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: return rounds as they appear
|
|
||||||
return roundsInMatches;
|
return roundsInMatches;
|
||||||
}, [matches, event.bracketTemplateId]);
|
}, [matches, event.bracketTemplateId]);
|
||||||
|
|
||||||
// Memoized: Get participants not yet in any match
|
|
||||||
const availableParticipants = useMemo(() => {
|
|
||||||
const participantsInMatches = new Set(
|
|
||||||
matches.flatMap(m => [m.participant1Id, m.participant2Id].filter(Boolean))
|
|
||||||
);
|
|
||||||
return participants.filter(
|
|
||||||
(p: { id: string }) => !participantsInMatches.has(p.id)
|
|
||||||
);
|
|
||||||
}, [matches, participants]);
|
|
||||||
|
|
||||||
// Check if all matches are complete
|
// Check if all matches are complete
|
||||||
const allMatchesComplete = useMemo(() => {
|
const allMatchesComplete = useMemo(() => {
|
||||||
return matches.length > 0 && matches.every((m: any) => m.isComplete);
|
return matches.length > 0 && matches.every((m: any) => m.isComplete);
|
||||||
}, [matches]);
|
}, [matches]);
|
||||||
|
|
||||||
|
// No bracket and no groups yet = setup phase
|
||||||
|
const showSetup = matches.length === 0 && !isGroupStageEvent;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8">
|
<div className="p-8">
|
||||||
<div className="max-w-6xl mx-auto">
|
<div className="max-w-6xl mx-auto">
|
||||||
|
|
@ -189,8 +233,8 @@ export default function EventBracket({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Reprocess Eliminations - For existing brackets */}
|
{/* Reprocess Eliminations - For existing brackets (non-group-stage) */}
|
||||||
{matches.length > 0 && (
|
{matches.length > 0 && !isGroupStageEvent && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Reprocess Eliminations</CardTitle>
|
<CardTitle>Reprocess Eliminations</CardTitle>
|
||||||
|
|
@ -215,8 +259,8 @@ export default function EventBracket({
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Generate Bracket Form */}
|
{/* ====== SETUP PHASE ====== */}
|
||||||
{matches.length === 0 && (
|
{showSetup && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Generate Bracket</CardTitle>
|
<CardTitle>Generate Bracket</CardTitle>
|
||||||
|
|
@ -226,7 +270,12 @@ export default function EventBracket({
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Form method="post" className="space-y-4">
|
<Form method="post" className="space-y-4">
|
||||||
<input type="hidden" name="intent" value="generate-bracket" />
|
{/* 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">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="templateId">Bracket Template</Label>
|
<Label htmlFor="templateId">Bracket Template</Label>
|
||||||
|
|
@ -249,222 +298,443 @@ export default function EventBracket({
|
||||||
</Select>
|
</Select>
|
||||||
{template && (
|
{template && (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Rounds: {template.rounds.map(r => r.name).join(" → ")}
|
{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>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
{/* Group-stage template: show group assignments */}
|
||||||
<Label>Select Participants (in order)</Label>
|
{hasGroupStage && template?.groupStage && (
|
||||||
<p className="text-sm text-muted-foreground">
|
<div className="space-y-4">
|
||||||
Select {template?.totalTeams || 8} participants for the bracket. Use search to quickly find participants.
|
<Label>Assign Participants to Groups</Label>
|
||||||
</p>
|
<p className="text-sm text-muted-foreground">
|
||||||
{[...Array(template?.totalTeams || 8)].map((_, i) => {
|
Assign {template.totalTeams} participants across {template.groupStage.groupCount} groups
|
||||||
const availableParticipants = getAvailableParticipants(i);
|
({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 (
|
return (
|
||||||
<div key={i} className="flex items-center gap-2">
|
<div key={seedIndex}>
|
||||||
<Label className="w-20 text-sm text-muted-foreground shrink-0">
|
<input
|
||||||
Seed {i + 1}
|
type="hidden"
|
||||||
</Label>
|
name={`participant${seedIndex}`}
|
||||||
<div className="flex-1 min-w-0">
|
value={selectedParticipants[seedIndex] || ""}
|
||||||
<input
|
required
|
||||||
type="hidden"
|
/>
|
||||||
name={`participant${i}`}
|
<ParticipantSelector
|
||||||
value={selectedParticipants[i] || ""}
|
participants={availableParticipants}
|
||||||
required
|
value={selectedParticipants[seedIndex] || ""}
|
||||||
/>
|
onValueChange={(value) => handleParticipantChange(seedIndex, value)}
|
||||||
<ParticipantSelector
|
placeholder={`Team ${teamIndex + 1}...`}
|
||||||
participants={availableParticipants}
|
/>
|
||||||
value={selectedParticipants[i] || ""}
|
</div>
|
||||||
onValueChange={(value) => handleParticipantChange(i, value)}
|
);
|
||||||
placeholder={`Select seed ${i + 1}...`}
|
})}
|
||||||
/>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Non-group-stage template: show seeded list */}
|
||||||
|
{!hasGroupStage && (
|
||||||
|
<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>
|
||||||
</div>
|
);
|
||||||
);
|
})}
|
||||||
})}
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
<Button type="submit" className="w-full">
|
<Button type="submit" className="w-full">
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Generate Bracket
|
{hasGroupStage ? "Generate Groups & Bracket" : "Generate Bracket"}
|
||||||
</Button>
|
</Button>
|
||||||
</Form>
|
</Form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Bracket Display */}
|
{/* ====== GROUP STAGE MANAGEMENT (Phase 2) ====== */}
|
||||||
{availableRounds.map((round: string) => (
|
{isGroupStageEvent && !knockoutPopulated && (
|
||||||
<Card key={round}>
|
<>
|
||||||
<CardHeader>
|
{/* Group Stage Summary */}
|
||||||
<CardTitle>{round}</CardTitle>
|
{groupStageStats && (
|
||||||
<CardDescription>
|
<Card>
|
||||||
{matchesByRound[round].length} {matchesByRound[round].length === 1 ? "match" : "matches"}
|
<CardHeader>
|
||||||
</CardDescription>
|
<CardTitle>Group Stage</CardTitle>
|
||||||
</CardHeader>
|
<CardDescription>
|
||||||
<CardContent>
|
Manage group stage eliminations. Mark teams as eliminated, then assign advancing teams to the knockout bracket.
|
||||||
<Table>
|
</CardDescription>
|
||||||
<TableHeader>
|
</CardHeader>
|
||||||
<TableRow>
|
<CardContent>
|
||||||
<TableHead className="w-16">Match</TableHead>
|
<div className="flex gap-4 text-sm">
|
||||||
<TableHead>Participant 1</TableHead>
|
<Badge variant="secondary">
|
||||||
<TableHead>Score</TableHead>
|
{groupStageStats.total} total teams
|
||||||
<TableHead className="w-24 text-center">vs</TableHead>
|
</Badge>
|
||||||
<TableHead>Score</TableHead>
|
<Badge variant="destructive">
|
||||||
<TableHead>Participant 2</TableHead>
|
{groupStageStats.eliminated} eliminated
|
||||||
<TableHead>Winner</TableHead>
|
</Badge>
|
||||||
<TableHead className="w-32">Actions</TableHead>
|
<Badge variant="default">
|
||||||
</TableRow>
|
{groupStageStats.advancing} advancing
|
||||||
</TableHeader>
|
</Badge>
|
||||||
<TableBody>
|
|
||||||
{matchesByRound[round].map((match) => (
|
|
||||||
<TableRow key={match.id}>
|
|
||||||
<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>
|
|
||||||
))}
|
|
||||||
</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>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Complete Round Button */}
|
|
||||||
{availableRounds.length > 0 && !event.isComplete && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Complete Round</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Finalize the current round and calculate placements
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Form method="post">
|
|
||||||
<input type="hidden" name="intent" value="complete-round" />
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="round">Round to Complete</Label>
|
|
||||||
<Select name="round" required>
|
|
||||||
<SelectTrigger id="round">
|
|
||||||
<SelectValue placeholder="Select round" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{availableRounds.map((round: string) => (
|
|
||||||
<SelectItem key={round} value={round}>
|
|
||||||
{round}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit">
|
</CardContent>
|
||||||
Complete Round & Calculate Placements
|
</Card>
|
||||||
</Button>
|
)}
|
||||||
</div>
|
|
||||||
</Form>
|
{/* Group Cards */}
|
||||||
</CardContent>
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
</Card>
|
{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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Finalize Bracket Button */}
|
{/* ====== KNOCKOUT BRACKET DISPLAY (Phase 3) ====== */}
|
||||||
{allMatchesComplete && !event.isComplete && (
|
{/* Show bracket rounds when: non-group event with matches, OR group event with knockout populated */}
|
||||||
<Card className="border-green-500 bg-green-50 dark:bg-green-950">
|
{((matches.length > 0 && !isGroupStageEvent) || knockoutPopulated) && (
|
||||||
<CardHeader>
|
<>
|
||||||
<CardTitle className="flex items-center gap-2">
|
{availableRounds.map((round: string) => (
|
||||||
<Trophy className="h-5 w-5 text-green-600" />
|
<Card key={round}>
|
||||||
Finalize Bracket
|
<CardHeader>
|
||||||
</CardTitle>
|
<CardTitle>{round}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
All matches are complete! Process all rounds and assign final placements.
|
{matchesByRound[round].length} {matchesByRound[round].length === 1 ? "match" : "matches"}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Form method="post">
|
<Table>
|
||||||
<input type="hidden" name="intent" value="finalize-bracket" />
|
<TableHeader>
|
||||||
<div className="space-y-4">
|
<TableRow>
|
||||||
<div className="text-sm space-y-2">
|
<TableHead className="w-16">Match</TableHead>
|
||||||
<p className="font-medium">This will:</p>
|
<TableHead>Participant 1</TableHead>
|
||||||
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
|
<TableHead>Score</TableHead>
|
||||||
<li>Process all playoff rounds in order</li>
|
<TableHead className="w-24 text-center">vs</TableHead>
|
||||||
<li>Assign fantasy placements (1st-8th) based on bracket results</li>
|
<TableHead>Score</TableHead>
|
||||||
<li>Assign 0 points to participants not in the bracket</li>
|
<TableHead>Participant 2</TableHead>
|
||||||
<li>Mark the event as complete</li>
|
<TableHead>Winner</TableHead>
|
||||||
<li>Recalculate all team standings</li>
|
<TableHead className="w-32">Actions</TableHead>
|
||||||
</ul>
|
</TableRow>
|
||||||
</div>
|
</TableHeader>
|
||||||
<Button type="submit" className="w-full bg-green-600 hover:bg-green-700">
|
<TableBody>
|
||||||
Finalize Bracket & Update Standings
|
{matchesByRound[round].map((match) => (
|
||||||
</Button>
|
<TableRow key={match.id}>
|
||||||
</div>
|
<TableCell className="font-semibold">
|
||||||
</Form>
|
{match.matchNumber}
|
||||||
</CardContent>
|
</TableCell>
|
||||||
</Card>
|
<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>
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Complete Round Button */}
|
||||||
|
{availableRounds.length > 0 && !event.isComplete && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Complete Round</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Finalize the current round and calculate placements
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Form method="post">
|
||||||
|
<input type="hidden" name="intent" value="complete-round" />
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="round">Round to Complete</Label>
|
||||||
|
<Select name="round" required>
|
||||||
|
<SelectTrigger id="round">
|
||||||
|
<SelectValue placeholder="Select round" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{availableRounds.map((round: string) => (
|
||||||
|
<SelectItem key={round} value={round}>
|
||||||
|
{round}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Button type="submit">
|
||||||
|
Complete Round & Calculate Placements
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Finalize Bracket Button */}
|
||||||
|
{allMatchesComplete && !event.isComplete && (
|
||||||
|
<Card className="border-green-500 bg-green-50 dark:bg-green-950">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Trophy className="h-5 w-5 text-green-600" />
|
||||||
|
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 bg-green-600 hover:bg-green-700">
|
||||||
|
Finalize Bracket & Update Standings
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Event Complete Badge */}
|
{/* Event Complete Badge */}
|
||||||
|
|
|
||||||
|
|
@ -174,6 +174,12 @@ export default function LeagueStandings() {
|
||||||
<Card className="mt-6">
|
<Card className="mt-6">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div className="text-sm text-muted-foreground space-y-2">
|
<div className="text-sm text-muted-foreground space-y-2">
|
||||||
|
<p>
|
||||||
|
<strong>Projected Points:</strong> Shows actual points from finished
|
||||||
|
participants plus expected value (EV) from remaining participants based
|
||||||
|
on their probability distributions. The "+X.X EV" indicates how many
|
||||||
|
additional points are expected from unfinished participants.
|
||||||
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<strong>Tiebreaker Rules:</strong> Teams are ranked by total
|
<strong>Tiebreaker Rules:</strong> Teams are ranked by total
|
||||||
points. If tied, the team with more 1st place finishes ranks
|
points. If tied, the team with more 1st place finishes ranks
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,10 @@ export interface TeamStanding {
|
||||||
};
|
};
|
||||||
participantsRemaining: number;
|
participantsRemaining: number;
|
||||||
calculatedAt: Date;
|
calculatedAt: Date;
|
||||||
|
// Phase 5.4: Expected value projections
|
||||||
|
actualPoints?: number | null;
|
||||||
|
projectedPoints?: number | null;
|
||||||
|
participantsFinished?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TeamStandingSnapshot {
|
export interface TeamStandingSnapshot {
|
||||||
|
|
|
||||||
|
|
@ -438,6 +438,10 @@ export const teamStandings = pgTable("team_standings", {
|
||||||
seventhPlaceCount: integer("seventh_place_count").notNull().default(0),
|
seventhPlaceCount: integer("seventh_place_count").notNull().default(0),
|
||||||
eighthPlaceCount: integer("eighth_place_count").notNull().default(0),
|
eighthPlaceCount: integer("eighth_place_count").notNull().default(0),
|
||||||
participantsRemaining: integer("participants_remaining").notNull().default(0), // Not yet finished
|
participantsRemaining: integer("participants_remaining").notNull().default(0), // Not yet finished
|
||||||
|
// Expected value tracking (Phase 5.4)
|
||||||
|
actualPoints: decimal("actual_points", { precision: 10, scale: 2 }), // Points from finished participants
|
||||||
|
projectedPoints: decimal("projected_points", { precision: 10, scale: 2 }), // actualPoints + EVs of unfinished
|
||||||
|
participantsFinished: integer("participants_finished"), // Count of finished participants
|
||||||
calculatedAt: timestamp("calculated_at").defaultNow().notNull(),
|
calculatedAt: timestamp("calculated_at").defaultNow().notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -497,6 +501,29 @@ export const participantExpectedValues = pgTable("participant_expected_values",
|
||||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Tournament group stage tables (for FIFA World Cup style tournaments)
|
||||||
|
export const tournamentGroups = pgTable("tournament_groups", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
scoringEventId: uuid("scoring_event_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => scoringEvents.id, { onDelete: "cascade" }),
|
||||||
|
groupName: varchar("group_name", { length: 10 }).notNull(), // "A" through "L"
|
||||||
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const tournamentGroupMembers = pgTable("tournament_group_members", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
tournamentGroupId: uuid("tournament_group_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => tournamentGroups.id, { onDelete: "cascade" }),
|
||||||
|
participantId: uuid("participant_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => participants.id, { onDelete: "cascade" }),
|
||||||
|
eliminated: boolean("eliminated").notNull().default(false),
|
||||||
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
// Participant season results (for F1 current points tracking during season)
|
// Participant season results (for F1 current points tracking during season)
|
||||||
export const participantSeasonResults = pgTable("participant_season_results", {
|
export const participantSeasonResults = pgTable("participant_season_results", {
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
|
@ -658,6 +685,7 @@ export const scoringEventsRelations = relations(scoringEvents, ({ one, many }) =
|
||||||
}),
|
}),
|
||||||
eventResults: many(eventResults),
|
eventResults: many(eventResults),
|
||||||
playoffMatches: many(playoffMatches),
|
playoffMatches: many(playoffMatches),
|
||||||
|
tournamentGroups: many(tournamentGroups),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const eventResultsRelations = relations(eventResults, ({ one }) => ({
|
export const eventResultsRelations = relations(eventResults, ({ one }) => ({
|
||||||
|
|
@ -766,3 +794,23 @@ export const participantSeasonResultsRelations = relations(participantSeasonResu
|
||||||
references: [sportsSeasons.id],
|
references: [sportsSeasons.id],
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Tournament Group Relations
|
||||||
|
export const tournamentGroupsRelations = relations(tournamentGroups, ({ one, many }) => ({
|
||||||
|
scoringEvent: one(scoringEvents, {
|
||||||
|
fields: [tournamentGroups.scoringEventId],
|
||||||
|
references: [scoringEvents.id],
|
||||||
|
}),
|
||||||
|
members: many(tournamentGroupMembers),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const tournamentGroupMembersRelations = relations(tournamentGroupMembers, ({ one }) => ({
|
||||||
|
group: one(tournamentGroups, {
|
||||||
|
fields: [tournamentGroupMembers.tournamentGroupId],
|
||||||
|
references: [tournamentGroups.id],
|
||||||
|
}),
|
||||||
|
participant: one(participants, {
|
||||||
|
fields: [tournamentGroupMembers.participantId],
|
||||||
|
references: [participants.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
|
||||||
34
docker-compose.yml
Normal file
34
docker-compose.yml
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: brackt-db
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
POSTGRES_DB: brackt
|
||||||
|
ports:
|
||||||
|
- "5439:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
container_name: brackt-app
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgres://postgres:postgres@db:5432/brackt
|
||||||
|
NODE_ENV: production
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
profiles:
|
||||||
|
- full
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
4
drizzle/0027_add_projected_points_to_team_standings.sql
Normal file
4
drizzle/0027_add_projected_points_to_team_standings.sql
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
-- Phase 5.4: Add projected points tracking to team_standings
|
||||||
|
ALTER TABLE "team_standings" ADD COLUMN "actual_points" numeric(10, 2);
|
||||||
|
ALTER TABLE "team_standings" ADD COLUMN "projected_points" numeric(10, 2);
|
||||||
|
ALTER TABLE "team_standings" ADD COLUMN "participants_finished" integer;
|
||||||
33
drizzle/0028_add_tournament_groups.sql
Normal file
33
drizzle/0028_add_tournament_groups.sql
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS "tournament_groups" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"scoring_event_id" uuid NOT NULL,
|
||||||
|
"group_name" varchar(10) NOT NULL,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "tournament_group_members" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"tournament_group_id" uuid NOT NULL,
|
||||||
|
"participant_id" uuid NOT NULL,
|
||||||
|
"eliminated" boolean DEFAULT false NOT NULL,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "tournament_groups" ADD CONSTRAINT "tournament_groups_scoring_event_id_scoring_events_id_fk" FOREIGN KEY ("scoring_event_id") REFERENCES "public"."scoring_events"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "tournament_group_members" ADD CONSTRAINT "tournament_group_members_tournament_group_id_tournament_groups_id_fk" FOREIGN KEY ("tournament_group_id") REFERENCES "public"."tournament_groups"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "tournament_group_members" ADD CONSTRAINT "tournament_group_members_participant_id_participants_id_fk" FOREIGN KEY ("participant_id") REFERENCES "public"."participants"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
|
@ -190,6 +190,20 @@
|
||||||
"when": 1763278000000,
|
"when": 1763278000000,
|
||||||
"tag": "0026_add_source_odds_to_participant_ev",
|
"tag": "0026_add_source_odds_to_participant_ev",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 27,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1763279000000,
|
||||||
|
"tag": "0027_add_projected_points_to_team_standings",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 28,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1763280000000,
|
||||||
|
"tag": "0028_add_tournament_groups",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -24,12 +24,20 @@
|
||||||
- ✅ Admin UI with preview (5.3.3) - `/admin/sports-seasons/:id/recalculate-probabilities`
|
- ✅ Admin UI with preview (5.3.3) - `/admin/sports-seasons/:id/recalculate-probabilities`
|
||||||
- ✅ Automatic triggers (5.3.4) - Integrated into scoring calculator
|
- ✅ Automatic triggers (5.3.4) - Integrated into scoring calculator
|
||||||
|
|
||||||
|
**Phase 5.4 (Projected Totals Display)**: ✅ **COMPLETE**
|
||||||
|
- ✅ Standings snapshot calculation (5.4.1)
|
||||||
|
- ✅ Standings page UI (5.4.2)
|
||||||
|
- ✅ Team breakdown page (5.4.3)
|
||||||
|
- ⏭️ Participant detail page (5.4.4) - SKIPPED (not needed for MVP)
|
||||||
|
|
||||||
**Total Test Coverage**: 105/105 tests passing (33 EV + 51 Elo/Bracket + 13 ICM + 8 probability-updater)
|
**Total Test Coverage**: 105/105 tests passing (33 EV + 51 Elo/Bracket + 13 ICM + 8 probability-updater)
|
||||||
|
|
||||||
**Ready to Test**: Yes!
|
**Ready to Test**: Yes!
|
||||||
- Manual entry: `/admin/sports-seasons/:id/expected-values`
|
- Admin: Manual entry: `/admin/sports-seasons/:id/expected-values`
|
||||||
- Futures odds (ICM): `/admin/sports-seasons/:id/futures-odds` ⭐ Works with all teams! Monte Carlo simulation (2-5 sec)
|
- Admin: Futures odds (ICM): `/admin/sports-seasons/:id/futures-odds` ⭐ Works with all teams! Monte Carlo simulation (2-5 sec)
|
||||||
- Recalculate: `/admin/sports-seasons/:id/recalculate-probabilities` ⭐ **NEW - Auto-updates when results entered!**
|
- Admin: Recalculate: `/admin/sports-seasons/:id/recalculate-probabilities` ⭐ Auto-updates when results entered!
|
||||||
|
- **User: League standings**: `/leagues/:leagueId/standings/:seasonId` ⭐ **NEW - Shows projected points!**
|
||||||
|
- **User: Team breakdown**: `/leagues/:leagueId/standings/:seasonId/teams/:teamId` ⭐ **NEW - Shows per-participant projections!**
|
||||||
|
|
||||||
**Key Features**:
|
**Key Features**:
|
||||||
- Monte Carlo ICM simulation: 100,000 iterations for accurate probability distributions
|
- Monte Carlo ICM simulation: 100,000 iterations for accurate probability distributions
|
||||||
|
|
@ -571,42 +579,58 @@ async function resimulateWithPartialResults(
|
||||||
- ✅ Automatic triggers integrated into scoring calculator
|
- ✅ Automatic triggers integrated into scoring calculator
|
||||||
- ✅ Comprehensive error handling and logging
|
- ✅ Comprehensive error handling and logging
|
||||||
|
|
||||||
### Phase 5.4: Projected Totals Display
|
### Phase 5.4: Projected Totals Display ✅ **COMPLETE**
|
||||||
|
|
||||||
**Goal**: Show "Projected Points" to league members
|
**Goal**: Show "Projected Points" to league members
|
||||||
|
|
||||||
- [ ] **5.4.1** Update standings snapshot calculation
|
- [x] **5.4.1** Update standings snapshot calculation ✅ **COMPLETE**
|
||||||
- [ ] Modify `recalculateStandings()` in `app/models/scoring-calculator.ts`
|
- [x] Modified `recalculateStandings()` in `app/models/scoring-calculator.ts`
|
||||||
- [ ] For each team:
|
- [x] Created `calculateTeamProjectedScore()` function
|
||||||
- [ ] Calculate `actualPoints` (finished participants only)
|
- [x] For each team:
|
||||||
- [ ] Calculate EVs for unfinished participants
|
- [x] Calculate `actualPoints` (finished participants only)
|
||||||
- [ ] `projectedPoints = actualPoints + sum(EVs)`
|
- [x] Calculate EVs for unfinished participants
|
||||||
- [ ] Store in `team_standings_snapshots`
|
- [x] `projectedPoints = actualPoints + sum(EVs)`
|
||||||
|
- [x] Store in `team_standings` and `team_standings_snapshots`
|
||||||
|
- [x] Added database migration (0027_add_projected_points_to_team_standings.sql)
|
||||||
|
|
||||||
- [ ] **5.4.2** Standings page updates
|
- [x] **5.4.2** Standings page updates ✅ **COMPLETE**
|
||||||
- [ ] Route: `/leagues/:leagueId/standings/:seasonId`
|
- [x] Route: `/leagues/:leagueId/standings/:seasonId`
|
||||||
- [ ] Add "Projected Points" column
|
- [x] Added "Projected Points" column
|
||||||
- [ ] Show actual vs projected
|
- [x] Shows actual vs projected with "+X.X EV" indicator
|
||||||
- [ ] Visual indicator: "X participants remaining"
|
- [x] Visual indicator: "X participants remaining"
|
||||||
- [ ] Tooltip: "Projected points = actual points + expected value of remaining participants"
|
- [x] Added tooltip explanation in info card
|
||||||
|
|
||||||
- [ ] **5.4.3** Team breakdown page updates
|
- [x] **5.4.3** Team breakdown page updates ✅ **COMPLETE**
|
||||||
- [ ] Route: `/leagues/:leagueId/standings/:seasonId/teams/:teamId`
|
- [x] Route: `/leagues/:leagueId/standings/:seasonId/teams/:teamId`
|
||||||
- [ ] For each participant:
|
- [x] For each participant:
|
||||||
- [ ] If finished: show actual points
|
- [x] If finished: show actual points
|
||||||
- [ ] If not finished: show "Projected: X.X points"
|
- [x] If not finished: show "Projected: X.X points"
|
||||||
- [ ] Totals section:
|
- [x] Totals section:
|
||||||
- [ ] Actual Points: XXX
|
- [x] Actual Points card
|
||||||
- [ ] Projected Points: XXX
|
- [x] Projected Points card with EV indicator
|
||||||
- [ ] Participants Remaining: X
|
- [x] Participants count
|
||||||
|
- [x] Updated `getTeamScoreBreakdown()` to fetch and calculate EVs
|
||||||
|
|
||||||
- [ ] **5.4.4** Participant detail page (new)
|
- [~] **5.4.4** Participant detail page ⏭️ **SKIPPED**
|
||||||
- [ ] Route: `/leagues/:leagueId/participants/:participantId`
|
- Not needed for MVP - participants can be viewed through team breakdown
|
||||||
- [ ] Show participant info
|
- Can be added in future phase if needed
|
||||||
- [ ] Current status (finished or in progress)
|
- [~] Route: `/leagues/:leagueId/participants/:participantId`
|
||||||
- [ ] If finished: actual placement and points
|
- [~] Show participant info
|
||||||
- [ ] If not finished: projected points (league-specific)
|
- [~] Current status (finished or in progress)
|
||||||
- [ ] Ownership: which teams in this league own this participant
|
- [~] If finished: actual placement and points
|
||||||
|
- [~] If not finished: projected points (league-specific)
|
||||||
|
- [~] Ownership: which teams in this league own this participant
|
||||||
|
|
||||||
|
**Phase 5.4 Status**: ✅ **COMPLETE** (3/3 required tasks)
|
||||||
|
|
||||||
|
**Deliverables**:
|
||||||
|
- ✅ Projected points calculation integrated into standings
|
||||||
|
- ✅ StandingsTable component shows actual and projected points
|
||||||
|
- ✅ Team breakdown page shows projected points for unfinished participants
|
||||||
|
- ✅ Summary cards display actual vs projected totals
|
||||||
|
- ✅ League-specific EV calculation (uses each league's scoring rules)
|
||||||
|
- ✅ Database schema updated with migration
|
||||||
|
- ✅ Model layers updated to expose projected points
|
||||||
|
|
||||||
### Phase 5.5: Draft Integration
|
### Phase 5.5: Draft Integration
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue