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:
Chris Parsons 2026-02-14 22:30:12 -08:00
parent 840212c4f8
commit 7970cb6a9c
19 changed files with 1590 additions and 303 deletions

View 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>
);
}

View file

@ -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>

View file

@ -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>
))} ))}

View file

@ -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,
}; };
/** /**

View file

@ -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,
});
}
}

View file

@ -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(),
}; };

View file

@ -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,
}); });
} }
} }

View 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));
}

View file

@ -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" };
} }

View file

@ -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 */}

View file

@ -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

View file

@ -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 {

View file

@ -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
View 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:

View 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;

View 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 $$;

View file

@ -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
} }
] ]
} }

View file

@ -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