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>Team</TableHead>
|
||||
<TableHead className="text-right">Points</TableHead>
|
||||
<TableHead className="text-right">Projected</TableHead>
|
||||
{showPlacementBreakdown && (
|
||||
<TableHead className="text-center">Placements</TableHead>
|
||||
)}
|
||||
|
|
@ -38,7 +39,7 @@ export function StandingsTable({
|
|||
<TableBody>
|
||||
{standings.length === 0 ? (
|
||||
<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
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
|
@ -62,7 +63,25 @@ export function StandingsTable({
|
|||
</Link>
|
||||
</TableCell>
|
||||
<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>
|
||||
{showPlacementBreakdown && (
|
||||
<TableCell>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ interface TeamScoreBreakdownProps {
|
|||
};
|
||||
finalPosition: number | null;
|
||||
points: number;
|
||||
projectedPoints: number | null;
|
||||
isComplete: boolean;
|
||||
}>;
|
||||
bySport: Record<string, { sportsSeasonId: string; picks: Array<{
|
||||
|
|
@ -36,9 +37,12 @@ interface TeamScoreBreakdownProps {
|
|||
};
|
||||
finalPosition: number | null;
|
||||
points: number;
|
||||
projectedPoints: number | null;
|
||||
isComplete: boolean;
|
||||
}> }>;
|
||||
totalPoints: number;
|
||||
actualPoints: number;
|
||||
projectedPoints: number;
|
||||
completedCount: number;
|
||||
totalCount: number;
|
||||
};
|
||||
|
|
@ -89,11 +93,17 @@ export function TeamScoreBreakdown({
|
|||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-4xl font-bold text-primary">
|
||||
{breakdown.totalPoints.toFixed(1)}
|
||||
{breakdown.actualPoints.toFixed(1)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Total Points
|
||||
Actual Points
|
||||
</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 && (
|
||||
<Badge className="mt-2" variant={standing.currentRank <= 3 ? "default" : "outline"}>
|
||||
Rank #{standing.currentRank}
|
||||
|
|
@ -103,7 +113,39 @@ export function TeamScoreBreakdown({
|
|||
</div>
|
||||
|
||||
{/* 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>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
|
|
@ -246,10 +288,21 @@ export function TeamScoreBreakdown({
|
|||
<Badge variant="outline">Pending</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-semibold">
|
||||
<TableCell className="text-right">
|
||||
{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>
|
||||
</TableRow>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,15 @@ export interface BracketRound {
|
|||
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 {
|
||||
/** Unique identifier for this template */
|
||||
id: string;
|
||||
|
|
@ -27,6 +36,10 @@ export interface BracketTemplate {
|
|||
rounds: BracketRound[];
|
||||
/** Round name where fantasy scoring begins */
|
||||
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
|
||||
*/
|
||||
|
|
@ -335,6 +405,7 @@ export const BRACKET_TEMPLATES: Record<string, BracketTemplate> = {
|
|||
ncaa_68: NCAA_68,
|
||||
nfl_14: NFL_14,
|
||||
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
|
||||
if (templateId === "ncaa_68") {
|
||||
return await generateNCAA68Bracket(eventId, template, participantIds);
|
||||
|
|
@ -1022,3 +1027,69 @@ async function advanceFirstFourWinner(
|
|||
// Fill the slot
|
||||
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
|
||||
* 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(
|
||||
teams.map(async (team) => {
|
||||
const score = await calculateTeamScore(team.id, seasonId, db);
|
||||
const projected = await calculateTeamProjectedScore(team.id, seasonId, db);
|
||||
return {
|
||||
teamId: team.id,
|
||||
totalPoints: score.totalPoints,
|
||||
placementCounts: score.placementCounts,
|
||||
participantsCompleted: score.participantsCompleted,
|
||||
participantsTotal: score.participantsTotal,
|
||||
actualPoints: projected.actualPoints,
|
||||
projectedPoints: projected.projectedPoints,
|
||||
participantsFinished: projected.participantsFinished,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
|
@ -819,6 +924,10 @@ export async function recalculateStandings(
|
|||
eighthPlaceCount: teamScore.placementCounts[8],
|
||||
participantsRemaining:
|
||||
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(),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -46,6 +46,10 @@ export async function getSeasonStandings(
|
|||
},
|
||||
participantsRemaining: standing.participantsRemaining,
|
||||
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,
|
||||
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
|
||||
const pickBreakdown = picks.map((pick) => {
|
||||
const result = pick.participant.results[0];
|
||||
let points = 0;
|
||||
// Get scoring rules for EV calculation
|
||||
const scoringRules = {
|
||||
pointsFor1st: season.pointsFor1st,
|
||||
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 based on placement
|
||||
const pointsMap: Record<number, number> = {
|
||||
1: season.pointsFor1st,
|
||||
2: season.pointsFor2nd,
|
||||
3: season.pointsFor3rd,
|
||||
4: season.pointsFor4th,
|
||||
5: season.pointsFor5th,
|
||||
6: season.pointsFor6th,
|
||||
7: season.pointsFor7th,
|
||||
8: season.pointsFor8th,
|
||||
// Calculate points and projected points for each pick
|
||||
const pickBreakdown = await Promise.all(
|
||||
picks.map(async (pick) => {
|
||||
const result = pick.participant.results[0];
|
||||
let points = 0;
|
||||
let projectedPoints: number | null = null;
|
||||
|
||||
if (result && result.finalPosition) {
|
||||
// Calculate points based on placement
|
||||
const pointsMap: Record<number, number> = {
|
||||
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
|
||||
// Structure: { sportName: { sportsSeasonId, picks } }
|
||||
|
|
@ -183,6 +232,15 @@ export async function getTeamScoreBreakdown(
|
|||
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 {
|
||||
team: await db.query.teams.findFirst({
|
||||
where: eq(schema.teams.id, teamId),
|
||||
|
|
@ -190,6 +248,8 @@ export async function getTeamScoreBreakdown(
|
|||
picks: pickBreakdown,
|
||||
bySport,
|
||||
totalPoints: pickBreakdown.reduce((sum, p) => sum + p.points, 0),
|
||||
actualPoints,
|
||||
projectedPoints: projectedTotalPoints,
|
||||
completedCount: pickBreakdown.filter((p) => p.isComplete).length,
|
||||
totalCount: pickBreakdown.length,
|
||||
};
|
||||
|
|
@ -303,6 +363,10 @@ export async function createDailySnapshot(
|
|||
seventhPlaceCount: standing.seventhPlaceCount,
|
||||
eighthPlaceCount: standing.eighthPlaceCount,
|
||||
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,
|
||||
advanceWinnerTemplate,
|
||||
findPlayoffMatchById,
|
||||
assignParticipantsToKnockout,
|
||||
} from "~/models/playoff-match";
|
||||
import { processPlayoffEvent } from "~/models/scoring-calculator";
|
||||
import { getBracketTemplate } from "~/lib/bracket-templates";
|
||||
import {
|
||||
createGroupsForEvent,
|
||||
addMembersToGroup,
|
||||
findGroupsByEventId,
|
||||
toggleMemberEliminated,
|
||||
getAdvancingParticipantIds,
|
||||
getEliminatedParticipantIds,
|
||||
} from "~/models/tournament-group";
|
||||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
|
|
@ -36,6 +45,9 @@ export async function loader({ params }: Route.LoaderArgs) {
|
|||
const participants = await findParticipantsBySportsSeasonId(params.id);
|
||||
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
|
||||
return {
|
||||
sportsSeason: sportsSeason as typeof sportsSeason & {
|
||||
|
|
@ -49,6 +61,7 @@ export async function loader({ params }: Route.LoaderArgs) {
|
|||
winner: { 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" };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} 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 {
|
||||
Table,
|
||||
|
|
@ -29,6 +29,7 @@ import {
|
|||
TableHeader,
|
||||
TableRow,
|
||||
} from "~/components/ui/table";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { ParticipantSelector } from "~/components/ParticipantSelector";
|
||||
|
||||
export { loader, action };
|
||||
|
|
@ -37,13 +38,75 @@ export default function EventBracket({
|
|||
loaderData,
|
||||
actionData,
|
||||
}: Route.ComponentProps) {
|
||||
const { sportsSeason, event, participants, matches } = loaderData;
|
||||
const { sportsSeason, event, participants, matches, tournamentGroups } = loaderData;
|
||||
const [selectedTemplate, setSelectedTemplate] = useState("simple_8");
|
||||
const [selectedParticipants, setSelectedParticipants] = useState<Record<number, string>>({});
|
||||
const [selectedWinners, setSelectedWinners] = useState<Record<string, string>>({});
|
||||
const [knockoutAssignments, setKnockoutAssignments] = useState<Record<string, string>>({});
|
||||
const templates = getAllBracketTemplates();
|
||||
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
|
||||
useEffect(() => {
|
||||
if (actionData?.success) {
|
||||
|
|
@ -65,30 +128,21 @@ export default function EventBracket({
|
|||
}));
|
||||
};
|
||||
|
||||
// Memoized available participants calculation - only recalculates when dependencies change
|
||||
// This prevents 68 × 68 filter operations on every render
|
||||
// Memoized available participants calculation
|
||||
const availableParticipantsMap = useMemo(() => {
|
||||
// Build a Set of selected IDs for O(1) lookup
|
||||
const selectedIdsSet = new Set(Object.values(selectedParticipants));
|
||||
|
||||
// Pre-calculate available participants for each seed slot
|
||||
const map: Record<number, typeof participants> = {};
|
||||
const totalSeeds = template?.totalTeams || 8;
|
||||
|
||||
for (let i = 0; i < totalSeeds; i++) {
|
||||
// For this seed slot, allow its current selection but exclude all others
|
||||
const currentSelection = selectedParticipants[i];
|
||||
|
||||
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 map;
|
||||
}, [selectedParticipants, participants, template?.totalTeams]);
|
||||
|
||||
// Get available participants for a specific seed (now just a lookup)
|
||||
const getAvailableParticipants = (seedIndex: number) => {
|
||||
return availableParticipantsMap[seedIndex] || participants;
|
||||
};
|
||||
|
|
@ -122,40 +176,30 @@ export default function EventBracket({
|
|||
return grouped;
|
||||
}, [matches]);
|
||||
|
||||
// Memoized: Get available rounds in chronological order (first round to finals)
|
||||
// Memoized: Get available rounds in chronological order
|
||||
const availableRounds = useMemo(() => {
|
||||
const roundsInMatches = Array.from(new Set(matches.map(m => m.round)));
|
||||
|
||||
// If event has a bracket template, use its round order
|
||||
if (event.bracketTemplateId) {
|
||||
const eventTemplate = getBracketTemplate(event.bracketTemplateId);
|
||||
if (eventTemplate) {
|
||||
// Filter template rounds to only those that have matches
|
||||
return eventTemplate.rounds
|
||||
.map((r) => r.name)
|
||||
.filter((roundName: string) => roundsInMatches.includes(roundName));
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: return rounds as they appear
|
||||
return roundsInMatches;
|
||||
}, [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
|
||||
const allMatchesComplete = useMemo(() => {
|
||||
return matches.length > 0 && matches.every((m: any) => m.isComplete);
|
||||
}, [matches]);
|
||||
|
||||
// No bracket and no groups yet = setup phase
|
||||
const showSetup = matches.length === 0 && !isGroupStageEvent;
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
|
|
@ -189,8 +233,8 @@ export default function EventBracket({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Reprocess Eliminations - For existing brackets */}
|
||||
{matches.length > 0 && (
|
||||
{/* Reprocess Eliminations - For existing brackets (non-group-stage) */}
|
||||
{matches.length > 0 && !isGroupStageEvent && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Reprocess Eliminations</CardTitle>
|
||||
|
|
@ -215,8 +259,8 @@ export default function EventBracket({
|
|||
</Card>
|
||||
)}
|
||||
|
||||
{/* Generate Bracket Form */}
|
||||
{matches.length === 0 && (
|
||||
{/* ====== SETUP PHASE ====== */}
|
||||
{showSetup && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Generate Bracket</CardTitle>
|
||||
|
|
@ -226,7 +270,12 @@ export default function EventBracket({
|
|||
</CardHeader>
|
||||
<CardContent>
|
||||
<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">
|
||||
<Label htmlFor="templateId">Bracket Template</Label>
|
||||
|
|
@ -249,222 +298,443 @@ export default function EventBracket({
|
|||
</Select>
|
||||
{template && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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);
|
||||
{/* Group-stage template: show group assignments */}
|
||||
{hasGroupStage && template?.groupStage && (
|
||||
<div className="space-y-4">
|
||||
<Label>Assign Participants to Groups</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Assign {template.totalTeams} participants across {template.groupStage.groupCount} groups
|
||||
({template.groupStage.teamsPerGroup} per group).
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{template.groupStage.groupLabels.map((label, groupIndex) => (
|
||||
<Card key={label} className="border-dashed">
|
||||
<CardHeader className="py-3 px-4">
|
||||
<CardTitle className="text-base">Group {label}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4 space-y-2">
|
||||
{[...Array(template.groupStage!.teamsPerGroup)].map((_, teamIndex) => {
|
||||
const seedIndex = groupIndex * template.groupStage!.teamsPerGroup + teamIndex;
|
||||
const availableParticipants = getAvailableParticipants(seedIndex);
|
||||
|
||||
return (
|
||||
<div key={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}...`}
|
||||
/>
|
||||
return (
|
||||
<div key={seedIndex}>
|
||||
<input
|
||||
type="hidden"
|
||||
name={`participant${seedIndex}`}
|
||||
value={selectedParticipants[seedIndex] || ""}
|
||||
required
|
||||
/>
|
||||
<ParticipantSelector
|
||||
participants={availableParticipants}
|
||||
value={selectedParticipants[seedIndex] || ""}
|
||||
onValueChange={(value) => handleParticipantChange(seedIndex, value)}
|
||||
placeholder={`Team ${teamIndex + 1}...`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Non-group-stage template: 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>
|
||||
)}
|
||||
|
||||
<Button type="submit" className="w-full">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Generate Bracket
|
||||
{hasGroupStage ? "Generate Groups & Bracket" : "Generate Bracket"}
|
||||
</Button>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Bracket Display */}
|
||||
{availableRounds.map((round: string) => (
|
||||
<Card key={round}>
|
||||
<CardHeader>
|
||||
<CardTitle>{round}</CardTitle>
|
||||
<CardDescription>
|
||||
{matchesByRound[round].length} {matchesByRound[round].length === 1 ? "match" : "matches"}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-16">Match</TableHead>
|
||||
<TableHead>Participant 1</TableHead>
|
||||
<TableHead>Score</TableHead>
|
||||
<TableHead className="w-24 text-center">vs</TableHead>
|
||||
<TableHead>Score</TableHead>
|
||||
<TableHead>Participant 2</TableHead>
|
||||
<TableHead>Winner</TableHead>
|
||||
<TableHead className="w-32">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{matchesByRound[round].map((match) => (
|
||||
<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>
|
||||
{/* ====== GROUP STAGE MANAGEMENT (Phase 2) ====== */}
|
||||
{isGroupStageEvent && !knockoutPopulated && (
|
||||
<>
|
||||
{/* Group Stage Summary */}
|
||||
{groupStageStats && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Group Stage</CardTitle>
|
||||
<CardDescription>
|
||||
Manage group stage eliminations. Mark teams as eliminated, then assign advancing teams to the knockout bracket.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex gap-4 text-sm">
|
||||
<Badge variant="secondary">
|
||||
{groupStageStats.total} total teams
|
||||
</Badge>
|
||||
<Badge variant="destructive">
|
||||
{groupStageStats.eliminated} eliminated
|
||||
</Badge>
|
||||
<Badge variant="default">
|
||||
{groupStageStats.advancing} advancing
|
||||
</Badge>
|
||||
</div>
|
||||
<Button type="submit">
|
||||
Complete Round & Calculate Placements
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Group Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{tournamentGroups.map((group: any) => (
|
||||
<Card key={group.id}>
|
||||
<CardHeader className="py-3 px-4">
|
||||
<CardTitle className="text-base">Group {group.groupName}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4 space-y-2">
|
||||
{(group.members || []).map((member: any) => (
|
||||
<div
|
||||
key={member.id}
|
||||
className={`flex items-center justify-between gap-2 p-2 rounded-md border ${
|
||||
member.eliminated
|
||||
? "bg-destructive/10 border-destructive/20"
|
||||
: "bg-background border-border"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`text-sm truncate ${
|
||||
member.eliminated ? "line-through text-muted-foreground" : ""
|
||||
}`}
|
||||
>
|
||||
{member.participant?.name || "Unknown"}
|
||||
</span>
|
||||
<Form method="post" className="shrink-0">
|
||||
<input type="hidden" name="intent" value="toggle-elimination" />
|
||||
<input type="hidden" name="memberId" value={member.id} />
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
variant={member.eliminated ? "outline" : "destructive"}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
{member.eliminated ? (
|
||||
<>
|
||||
<Check className="h-3 w-3 mr-1" />
|
||||
Reinstate
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<X className="h-3 w-3 mr-1" />
|
||||
Eliminate
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Knockout Assignment Section */}
|
||||
{groupStageStats && groupStageStats.advancing === 32 && (
|
||||
<Card className="border-blue-500">
|
||||
<CardHeader>
|
||||
<CardTitle>Assign Knockout Bracket</CardTitle>
|
||||
<CardDescription>
|
||||
32 teams are advancing. Assign them to the Round of 32 knockout bracket slots.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form method="post" className="space-y-4">
|
||||
<input type="hidden" name="intent" value="populate-knockout" />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{[...Array(16)].map((_, matchIndex) => {
|
||||
const matchNum = matchIndex + 1;
|
||||
const key1 = `match-${matchNum}-participant1Id`;
|
||||
const key2 = `match-${matchNum}-participant2Id`;
|
||||
|
||||
return (
|
||||
<Card key={matchNum} className="border-dashed">
|
||||
<CardContent className="p-4 space-y-2">
|
||||
<div className="text-sm font-semibold text-muted-foreground">
|
||||
Match {matchNum}
|
||||
</div>
|
||||
<input
|
||||
type="hidden"
|
||||
name={key1}
|
||||
value={knockoutAssignments[key1] || ""}
|
||||
required
|
||||
/>
|
||||
<ParticipantSelector
|
||||
participants={availableAdvancingMap[key1] || []}
|
||||
value={knockoutAssignments[key1] || ""}
|
||||
onValueChange={(value) =>
|
||||
setKnockoutAssignments((prev) => ({ ...prev, [key1]: value }))
|
||||
}
|
||||
placeholder="Team 1..."
|
||||
/>
|
||||
<div className="text-center text-xs text-muted-foreground">vs</div>
|
||||
<input
|
||||
type="hidden"
|
||||
name={key2}
|
||||
value={knockoutAssignments[key2] || ""}
|
||||
required
|
||||
/>
|
||||
<ParticipantSelector
|
||||
participants={availableAdvancingMap[key2] || []}
|
||||
value={knockoutAssignments[key2] || ""}
|
||||
onValueChange={(value) =>
|
||||
setKnockoutAssignments((prev) => ({ ...prev, [key2]: value }))
|
||||
}
|
||||
placeholder="Team 2..."
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full">
|
||||
Populate Knockout Bracket
|
||||
</Button>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{groupStageStats && groupStageStats.advancing !== 32 && groupStageStats.eliminated > 0 && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{groupStageStats.advancing} teams advancing (need exactly 32 to populate knockout bracket).
|
||||
{groupStageStats.advancing > 32 && ` Eliminate ${groupStageStats.advancing - 32} more.`}
|
||||
{groupStageStats.advancing < 32 && ` Reinstate ${32 - groupStageStats.advancing} teams or adjust eliminations.`}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
{/* ====== KNOCKOUT BRACKET DISPLAY (Phase 3) ====== */}
|
||||
{/* Show bracket rounds when: non-group event with matches, OR group event with knockout populated */}
|
||||
{((matches.length > 0 && !isGroupStageEvent) || knockoutPopulated) && (
|
||||
<>
|
||||
{availableRounds.map((round: string) => (
|
||||
<Card key={round}>
|
||||
<CardHeader>
|
||||
<CardTitle>{round}</CardTitle>
|
||||
<CardDescription>
|
||||
{matchesByRound[round].length} {matchesByRound[round].length === 1 ? "match" : "matches"}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-16">Match</TableHead>
|
||||
<TableHead>Participant 1</TableHead>
|
||||
<TableHead>Score</TableHead>
|
||||
<TableHead className="w-24 text-center">vs</TableHead>
|
||||
<TableHead>Score</TableHead>
|
||||
<TableHead>Participant 2</TableHead>
|
||||
<TableHead>Winner</TableHead>
|
||||
<TableHead className="w-32">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{matchesByRound[round].map((match) => (
|
||||
<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>
|
||||
<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 */}
|
||||
|
|
|
|||
|
|
@ -174,6 +174,12 @@ export default function LeagueStandings() {
|
|||
<Card className="mt-6">
|
||||
<CardContent className="pt-6">
|
||||
<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>
|
||||
<strong>Tiebreaker Rules:</strong> Teams are ranked by total
|
||||
points. If tied, the team with more 1st place finishes ranks
|
||||
|
|
|
|||
|
|
@ -21,6 +21,10 @@ export interface TeamStanding {
|
|||
};
|
||||
participantsRemaining: number;
|
||||
calculatedAt: Date;
|
||||
// Phase 5.4: Expected value projections
|
||||
actualPoints?: number | null;
|
||||
projectedPoints?: number | null;
|
||||
participantsFinished?: number | null;
|
||||
}
|
||||
|
||||
export interface TeamStandingSnapshot {
|
||||
|
|
|
|||
|
|
@ -438,6 +438,10 @@ export const teamStandings = pgTable("team_standings", {
|
|||
seventhPlaceCount: integer("seventh_place_count").notNull().default(0),
|
||||
eighthPlaceCount: integer("eighth_place_count").notNull().default(0),
|
||||
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(),
|
||||
});
|
||||
|
||||
|
|
@ -497,6 +501,29 @@ export const participantExpectedValues = pgTable("participant_expected_values",
|
|||
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)
|
||||
export const participantSeasonResults = pgTable("participant_season_results", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
|
|
@ -658,6 +685,7 @@ export const scoringEventsRelations = relations(scoringEvents, ({ one, many }) =
|
|||
}),
|
||||
eventResults: many(eventResults),
|
||||
playoffMatches: many(playoffMatches),
|
||||
tournamentGroups: many(tournamentGroups),
|
||||
}));
|
||||
|
||||
export const eventResultsRelations = relations(eventResults, ({ one }) => ({
|
||||
|
|
@ -766,3 +794,23 @@ export const participantSeasonResultsRelations = relations(participantSeasonResu
|
|||
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,
|
||||
"tag": "0026_add_source_odds_to_participant_ev",
|
||||
"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`
|
||||
- ✅ 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)
|
||||
|
||||
**Ready to Test**: Yes!
|
||||
- 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)
|
||||
- Recalculate: `/admin/sports-seasons/:id/recalculate-probabilities` ⭐ **NEW - Auto-updates when results entered!**
|
||||
- Admin: Manual entry: `/admin/sports-seasons/:id/expected-values`
|
||||
- Admin: Futures odds (ICM): `/admin/sports-seasons/:id/futures-odds` ⭐ Works with all teams! Monte Carlo simulation (2-5 sec)
|
||||
- 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**:
|
||||
- Monte Carlo ICM simulation: 100,000 iterations for accurate probability distributions
|
||||
|
|
@ -571,42 +579,58 @@ async function resimulateWithPartialResults(
|
|||
- ✅ Automatic triggers integrated into scoring calculator
|
||||
- ✅ 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
|
||||
|
||||
- [ ] **5.4.1** Update standings snapshot calculation
|
||||
- [ ] Modify `recalculateStandings()` in `app/models/scoring-calculator.ts`
|
||||
- [ ] For each team:
|
||||
- [ ] Calculate `actualPoints` (finished participants only)
|
||||
- [ ] Calculate EVs for unfinished participants
|
||||
- [ ] `projectedPoints = actualPoints + sum(EVs)`
|
||||
- [ ] Store in `team_standings_snapshots`
|
||||
- [x] **5.4.1** Update standings snapshot calculation ✅ **COMPLETE**
|
||||
- [x] Modified `recalculateStandings()` in `app/models/scoring-calculator.ts`
|
||||
- [x] Created `calculateTeamProjectedScore()` function
|
||||
- [x] For each team:
|
||||
- [x] Calculate `actualPoints` (finished participants only)
|
||||
- [x] Calculate EVs for unfinished participants
|
||||
- [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
|
||||
- [ ] Route: `/leagues/:leagueId/standings/:seasonId`
|
||||
- [ ] Add "Projected Points" column
|
||||
- [ ] Show actual vs projected
|
||||
- [ ] Visual indicator: "X participants remaining"
|
||||
- [ ] Tooltip: "Projected points = actual points + expected value of remaining participants"
|
||||
- [x] **5.4.2** Standings page updates ✅ **COMPLETE**
|
||||
- [x] Route: `/leagues/:leagueId/standings/:seasonId`
|
||||
- [x] Added "Projected Points" column
|
||||
- [x] Shows actual vs projected with "+X.X EV" indicator
|
||||
- [x] Visual indicator: "X participants remaining"
|
||||
- [x] Added tooltip explanation in info card
|
||||
|
||||
- [ ] **5.4.3** Team breakdown page updates
|
||||
- [ ] Route: `/leagues/:leagueId/standings/:seasonId/teams/:teamId`
|
||||
- [ ] For each participant:
|
||||
- [ ] If finished: show actual points
|
||||
- [ ] If not finished: show "Projected: X.X points"
|
||||
- [ ] Totals section:
|
||||
- [ ] Actual Points: XXX
|
||||
- [ ] Projected Points: XXX
|
||||
- [ ] Participants Remaining: X
|
||||
- [x] **5.4.3** Team breakdown page updates ✅ **COMPLETE**
|
||||
- [x] Route: `/leagues/:leagueId/standings/:seasonId/teams/:teamId`
|
||||
- [x] For each participant:
|
||||
- [x] If finished: show actual points
|
||||
- [x] If not finished: show "Projected: X.X points"
|
||||
- [x] Totals section:
|
||||
- [x] Actual Points card
|
||||
- [x] Projected Points card with EV indicator
|
||||
- [x] Participants count
|
||||
- [x] Updated `getTeamScoreBreakdown()` to fetch and calculate EVs
|
||||
|
||||
- [ ] **5.4.4** Participant detail page (new)
|
||||
- [ ] Route: `/leagues/:leagueId/participants/:participantId`
|
||||
- [ ] Show participant info
|
||||
- [ ] Current status (finished or in progress)
|
||||
- [ ] If finished: actual placement and points
|
||||
- [ ] If not finished: projected points (league-specific)
|
||||
- [ ] Ownership: which teams in this league own this participant
|
||||
- [~] **5.4.4** Participant detail page ⏭️ **SKIPPED**
|
||||
- Not needed for MVP - participants can be viewed through team breakdown
|
||||
- Can be added in future phase if needed
|
||||
- [~] Route: `/leagues/:leagueId/participants/:participantId`
|
||||
- [~] Show participant info
|
||||
- [~] Current status (finished or in progress)
|
||||
- [~] 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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue