From d88be08deb414f446f136ae0a94b28e2ffb704a2 Mon Sep 17 00:00:00 2001 From: Chris Parsons <438676+chrisparsons83@users.noreply.github.com> Date: Mon, 9 Mar 2026 22:44:33 -0700 Subject: [PATCH] User/chris/bracket UI redesign (#95) * feat: redesign playoff bracket UI with compact layout and owner display - Replace per-match Card wrappers with compact left-vs-right rows (stacks on mobile) - Add TeamOwnerBadge component (colored avatar + team name + username), matching the standings "Drafted By" style - Show owner info below each participant name in bracket matches - Add TBD forward-reference labels ("Winner of Quarterfinals M1") computed via buildFeederMap - Add Final Rankings table below bracket showing all participants ranked by elimination round - Fix round ordering in loader: sort by match count descending (more matches = earlier round) - Add loser relation to playoff matches query for elimination tracking - Extract getAvatarColor to app/lib/color-hash.ts (shared across GroupStageDisplay, SeasonStandings, TeamOwnerBadge) - Extract groupMatchesByRound helper; share grouped map between feeder computation and render - Remove dead getTeamAvatar from SeasonStandings after TeamOwnerBadge adoption - Add tests for groupMatchesByRound and buildFeederMap (7 tests) Co-Authored-By: Claude Sonnet 4.6 * feat: add draft order card to league home page Show draft order on the league home page when order is set and season is in pre_draft or draft status. Includes team name, owner name, and a link to the draft room. Co-Authored-By: Claude Sonnet 4.6 * feat: enhance playoff bracket with eliminated teams, points, and ownership highlights - Show eliminated teams (including group-stage losers) in Final Rankings card - Display computed fantasy points per participant in rankings table - Card title switches between "Eliminated Teams" and "Final Rankings" based on bracket completion - Highlight owned participants with electric blue name, dot indicator, and card border in bracket matches - Highlight owned rows with electric border/background in rankings table - Suppress EventSchedule for playoff_bracket sports seasons - Fix title redundancy: bracket section title no longer repeats sport name - Two-column match grid on desktop (md:grid-cols-2) - Code review fixes: parallel DB fetches, targeted query for pre-eliminated, single-pass parseFloat, remove IIFE Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- app/components/scoring/GroupStageDisplay.tsx | 31 +- app/components/scoring/PlayoffBracket.tsx | 669 ++++++++++++------ app/components/scoring/SeasonStandings.tsx | 39 +- app/components/scoring/SportSeasonDisplay.tsx | 16 +- .../scoring/__tests__/PlayoffBracket.test.tsx | 138 ++++ app/components/ui/team-owner-badge.tsx | 42 ++ app/lib/color-hash.ts | 18 + app/routes/leagues/$leagueId.server.ts | 1 + ...d.sports-seasons.$sportsSeasonId.server.ts | 36 +- ...eagueId.sports-seasons.$sportsSeasonId.tsx | 23 +- app/routes/leagues/$leagueId.tsx | 53 ++ .../__tests__/draft-order-visibility.test.ts | 70 ++ 12 files changed, 849 insertions(+), 287 deletions(-) create mode 100644 app/components/scoring/__tests__/PlayoffBracket.test.tsx create mode 100644 app/components/ui/team-owner-badge.tsx create mode 100644 app/lib/color-hash.ts create mode 100644 app/routes/leagues/__tests__/draft-order-visibility.test.ts diff --git a/app/components/scoring/GroupStageDisplay.tsx b/app/components/scoring/GroupStageDisplay.tsx index 05fe6a0..4cfda90 100644 --- a/app/components/scoring/GroupStageDisplay.tsx +++ b/app/components/scoring/GroupStageDisplay.tsx @@ -7,6 +7,7 @@ import { } from "~/components/ui/card"; import { Badge } from "~/components/ui/badge"; import { Users } from "lucide-react"; +import { getAvatarColor } from "~/lib/color-hash"; interface GroupMember { id: string; @@ -52,29 +53,13 @@ export function GroupStageDisplay({ const ownershipMap = new Map(); 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 ( -
- {initial} -
- ); - }; + const getTeamAvatar = (teamName: string) => ( +
+ {teamName.charAt(0).toUpperCase()} +
+ ); const totalTeams = groups.reduce((sum, g) => sum + g.members.length, 0); const eliminatedCount = groups.reduce( diff --git a/app/components/scoring/PlayoffBracket.tsx b/app/components/scoring/PlayoffBracket.tsx index 6219456..fc45525 100644 --- a/app/components/scoring/PlayoffBracket.tsx +++ b/app/components/scoring/PlayoffBracket.tsx @@ -1,12 +1,20 @@ -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "~/components/ui/card"; import { Badge } from "~/components/ui/badge"; -import { Trophy, Users } from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "~/components/ui/table"; +import { Trophy, Star } from "lucide-react"; +import { TeamOwnerBadge } from "~/components/ui/team-owner-badge"; + +interface Participant { + id: string; + name: string; +} interface Match { id: string; @@ -19,251 +27,482 @@ interface Match { isComplete: boolean; participant1Score: string | null; participant2Score: string | null; - participant1?: { - id: string; - name: string; - } | null; - participant2?: { - id: string; - name: string; - } | null; - winner?: { - id: string; - name: string; - } | null; + participant1?: Participant | null; + participant2?: Participant | null; + winner?: Participant | null; + loser?: Participant | null; } interface TeamOwnership { participantId: string; teamName: string; teamId: string; - ownerName?: string; // Optional: user's display name + ownerName?: string; } interface PlayoffBracketProps { matches: Match[]; - rounds: string[]; // Ordered list of round names - teamOwnerships?: TeamOwnership[]; // Which teams own which participants - showOwnership?: boolean; // Toggle ownership display + rounds: string[]; // Ordered list of round names (earliest first) + preEliminatedParticipants?: { id: string; name: string }[]; // Eliminated before bracket (e.g. group stage) + participantPoints?: { participantId: string; points: number }[]; // Computed fantasy points per participant + teamOwnerships?: TeamOwnership[]; + userParticipantIds?: string[]; + showOwnership?: boolean; title?: string; description?: string; } +/** Group matches by round, sorted by matchNumber, in one pass. */ +export function groupMatchesByRound(matches: Match[]): Map { + const byRound = new Map(); + for (const match of matches) { + if (!byRound.has(match.round)) byRound.set(match.round, []); + byRound.get(match.round)!.push(match); + } + for (const group of byRound.values()) { + group.sort((a, b) => a.matchNumber - b.matchNumber); + } + return byRound; +} + /** - * PlayoffBracket component - Displays playoff bracket with optional ownership hints - * - * Features: - * - Visual bracket display grouped by round - * - Shows match winners and scores - * - Ownership hints with team avatars and tooltips (Q15) - * - Responsive layout + * For a standard single-elimination bracket, slot p1 of match N in round R + * comes from match (2N-1) in the previous round, and slot p2 from match 2N. */ +export function buildFeederMap( + matchesByRound: Map, + orderedRounds: string[] +): Map { + const feederMap = new Map(); + + for (let ri = 1; ri < orderedRounds.length; ri++) { + const currentRound = orderedRounds[ri]; + const prevRound = orderedRounds[ri - 1]; + const prevMatchNums = new Set( + (matchesByRound.get(prevRound) || []).map((m) => m.matchNumber) + ); + for (const match of matchesByRound.get(currentRound) || []) { + const p1Src = 2 * (match.matchNumber - 1) + 1; + const p2Src = 2 * (match.matchNumber - 1) + 2; + if (prevMatchNums.has(p1Src)) { + feederMap.set(`${currentRound}:${match.matchNumber}:p1`, { + round: prevRound, + matchNumber: p1Src, + }); + } + if (prevMatchNums.has(p2Src)) { + feederMap.set(`${currentRound}:${match.matchNumber}:p2`, { + round: prevRound, + matchNumber: p2Src, + }); + } + } + } + + return feederMap; +} + +interface EliminatedEntry { + participant: Participant; + score: string | null; + rankLabel: string; + ownership: TeamOwnership | null; +} + export function PlayoffBracket({ matches, rounds, + preEliminatedParticipants = [], + participantPoints = [], teamOwnerships = [], + userParticipantIds = [], showOwnership = true, title = "Playoff Bracket", description, }: PlayoffBracketProps) { - // Create a map of participant ID to ownership info for fast lookup + const userParticipantSet = new Set(userParticipantIds); const ownershipMap = new Map(); - teamOwnerships.forEach((ownership) => { - ownershipMap.set(ownership.participantId, ownership); - }); + teamOwnerships.forEach((o) => ownershipMap.set(o.participantId, o)); + const pointsMap = new Map(participantPoints.map((p) => [p.participantId, p.points])); - // Group matches by round - const matchesByRound = rounds.reduce((acc, round) => { - acc[round] = matches.filter((m) => m.round === round); - return acc; - }, {} as Record); + // Group matches once; reused for feeder map and rendering + const matchesByRound = groupMatchesByRound(matches); + const feederMap = buildFeederMap(matchesByRound, rounds); - // Get ownership info for a participant - const getOwnership = (participantId: string | null): TeamOwnership | null => { - if (!participantId || !showOwnership) return null; - return ownershipMap.get(participantId) || null; + const getTbdLabel = (round: string, matchNumber: number, slot: "p1" | "p2") => { + const feeder = feederMap.get(`${round}:${matchNumber}:${slot}`); + if (!feeder) return "TBD"; + return `Winner of ${feeder.round} M${feeder.matchNumber}`; }; - // Generate avatar from team name (first letter) - const getTeamAvatar = (teamName: string) => { - const initial = teamName.charAt(0).toUpperCase(); - // Use a simple hash to generate a consistent color - 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]; + // Build elimination rankings: collect losers per round, then assign rank labels + const losersByRound = new Map>(); + let hasScore = false; + let bracketWinner: Participant | null = null; - return ( -
- {initial} -
- ); - }; + const lastRound = rounds[rounds.length - 1]; + const finalMatch = lastRound + ? (matchesByRound.get(lastRound) || []).find((m) => m.matchNumber === 1) + : null; + if (finalMatch?.winner) bracketWinner = finalMatch.winner; - // Render a participant row with optional ownership indicator - const renderParticipant = ( - participant: { id: string; name: string } | null | undefined, - participantId: string | null, - isWinner: boolean, - score: string | null - ) => { - const ownership = getOwnership(participantId); - const participantName = participant?.name || "TBD"; + for (const match of matches) { + if (!match.isComplete || !match.loser) continue; + const loserScore = + match.loserId === match.participant1Id + ? match.participant1Score + : match.participant2Score; + if (loserScore) hasScore = true; + if (!losersByRound.has(match.round)) losersByRound.set(match.round, []); + losersByRound.get(match.round)!.push({ + participant: match.loser, + score: loserScore, + ownership: ownershipMap.get(match.loser.id) || null, + }); + } - return ( -
-
- {isWinner && ( - - )} - - {participantName} - -
-
- {score && ( - - {parseFloat(score)} - - )} - {ownership && ( -
- {getTeamAvatar(ownership.teamName)} -
- )} -
-
- ); - }; + // Walk rounds latest→earliest to assign rank labels (no mutation) + const rankedEntries: EliminatedEntry[] = []; + let nextRank = 2; + for (let ri = rounds.length - 1; ri >= 0; ri--) { + const roundLosers = losersByRound.get(rounds[ri]) || []; + if (roundLosers.length === 0) continue; + const rankEnd = nextRank + roundLosers.length - 1; + const rankLabel = + nextRank === rankEnd ? `#${nextRank}` : `#${nextRank}–${rankEnd}`; + for (const loser of roundLosers) { + rankedEntries.push({ ...loser, rankLabel }); + } + nextRank += roundLosers.length; + } - // Count how many matches have ownership info - const matchesWithOwnership = matches.filter( - (m) => - (m.participant1Id && ownershipMap.has(m.participant1Id)) || - (m.participant2Id && ownershipMap.has(m.participant2Id)) - ).length; + const showRankings = rankedEntries.length > 0 || bracketWinner !== null || preEliminatedParticipants.length > 0; + + // Hoist winner row lookups so we don't need an IIFE in JSX + const winnerIsOwned = bracketWinner ? userParticipantSet.has(bracketWinner.id) : false; + const winnerOwnership = bracketWinner ? ownershipMap.get(bracketWinner.id) : undefined; + const winnerPts = bracketWinner ? pointsMap.get(bracketWinner.id) : undefined; return ( - - - - - {title} - - {description && {description}} - {showOwnership && matchesWithOwnership > 0 && ( -
- - {matchesWithOwnership} match{matchesWithOwnership !== 1 ? "es" : ""} with team ownership - -
+
+
+

{title}

+ {description && ( +

{description}

)} - - - {matches.length === 0 ? ( -
-

No bracket matches available yet.

-

- Matches will appear here once the bracket is set up. -

-
- ) : ( -
- {rounds.map((round) => { - const roundMatches = matchesByRound[round] || []; - if (roundMatches.length === 0) return null; +
- return ( -
-
-

{round}

- - {roundMatches.length} match{roundMatches.length !== 1 ? "es" : ""} - -
+ {matches.length === 0 ? ( +
+

No bracket matches available yet.

+

+ Matches will appear here once the bracket is set up. +

+
+ ) : ( +
+ {rounds.map((round) => { + const roundMatches = matchesByRound.get(round) || []; + if (roundMatches.length === 0) return null; -
- {roundMatches - .sort((a, b) => a.matchNumber - b.matchNumber) - .map((match) => { - const isComplete = match.isComplete; - const participant1IsWinner = - match.winnerId === match.participant1Id; - const participant2IsWinner = - match.winnerId === match.participant2Id; - - return ( - - -
- - Match {match.matchNumber} - - {isComplete && ( - - Complete - - )} -
- -
- {renderParticipant( - match.participant1, - match.participant1Id, - participant1IsWinner, - match.participant1Score - )} - -
-
- - vs - -
-
- - {renderParticipant( - match.participant2, - match.participant2Id, - participant2IsWinner, - match.participant2Score - )} -
-
-
- ); - })} -
+ return ( +
+
+

+ {round} +

+
+ + {roundMatches.length}{" "} + {roundMatches.length === 1 ? "match" : "matches"} +
- ); - })} -
- )} - - + +
+ {roundMatches.map((match) => { + const p1IsWinner = + match.isComplete && match.winnerId === match.participant1Id; + const p2IsWinner = + match.isComplete && match.winnerId === match.participant2Id; + const p1IsLoser = + match.isComplete && match.loserId === match.participant1Id; + const p2IsLoser = + match.isComplete && match.loserId === match.participant2Id; + + const p1Name = + match.participant1?.name || + (match.participant1Id + ? "TBD" + : getTbdLabel(round, match.matchNumber, "p1")); + const p2Name = + match.participant2?.name || + (match.participant2Id + ? "TBD" + : getTbdLabel(round, match.matchNumber, "p2")); + + const p1IsTbd = !match.participant1Id; + const p2IsTbd = !match.participant2Id; + + const p1IsOwned = !p1IsTbd && userParticipantSet.has(match.participant1Id!); + const p2IsOwned = !p2IsTbd && userParticipantSet.has(match.participant2Id!); + const matchHasOwned = p1IsOwned || p2IsOwned; + + const p1Ownership = + showOwnership && match.participant1Id + ? ownershipMap.get(match.participant1Id) || null + : null; + const p2Ownership = + showOwnership && match.participant2Id + ? ownershipMap.get(match.participant2Id) || null + : null; + + return ( +
+ {/* Match label row */} +
+ + M{match.matchNumber} + + {match.isComplete && ( + + Done + + )} +
+ + {/* Participants: left vs right (stacks on mobile) */} +
+ {/* Participant 1 — left-aligned */} +
+
+ {p1IsOwned && !p1IsWinner && ( + + )} + {p1IsWinner && ( + + )} + + {p1Name} + +
+ {!p1IsTbd && p1Ownership && ( +
+ +
+ )} +
+ + {/* Center: scores + vs */} +
+ {match.participant1Score ? ( + <> + + {parseFloat(match.participant1Score)} + + + vs + + + {match.participant2Score + ? parseFloat(match.participant2Score) + : "—"} + + + ) : ( + vs + )} +
+ + {/* Participant 2 — right-aligned on sm+, left-aligned on mobile */} +
+
+ + {p2Name} + + {p2IsWinner && ( + + )} + {p2IsOwned && !p2IsWinner && ( + + )} +
+ {!p2IsTbd && p2Ownership && ( +
+ +
+ )} +
+
+
+ ); + })} +
+
+ ); + })} + + {/* Final Rankings */} + {showRankings && ( + + + + + {bracketWinner ? "Final Rankings" : "Eliminated Teams"} + + + + + + + Rank + Participant + {showOwnership && ( + Drafted By + )} + {pointsMap.size > 0 && ( + Pts + )} + + + + {bracketWinner && ( + + + + + #1 + + + + {bracketWinner.name} + {winnerIsOwned && ( + + )} + + {showOwnership && ( + + {winnerOwnership ? ( + + ) : ( + - + )} + + )} + {pointsMap.size > 0 && ( + + {winnerPts ?? "—"} + + )} + + )} + + {rankedEntries.map((entry, i) => { + const isOwned = userParticipantSet.has(entry.participant.id); + const pts = pointsMap.get(entry.participant.id); + return ( + + + {entry.rankLabel} + + + {entry.participant.name} + {isOwned && ( + + )} + + {showOwnership && ( + + {entry.ownership ? ( + + ) : ( + - + )} + + )} + {pointsMap.size > 0 && ( + + {pts ?? "—"} + + )} + + ); + })} + + {preEliminatedParticipants.map((p) => { + const isOwned = userParticipantSet.has(p.id); + const ownership = showOwnership ? ownershipMap.get(p.id) || null : null; + const pts = pointsMap.get(p.id); + return ( + + + + {p.name} + {isOwned && ( + + )} + + {showOwnership && ( + + {ownership ? ( + + ) : ( + - + )} + + )} + {pointsMap.size > 0 && ( + + {pts ?? "—"} + + )} + + ); + })} + +
+
+
+ )} +
+ )} +
); } diff --git a/app/components/scoring/SeasonStandings.tsx b/app/components/scoring/SeasonStandings.tsx index abf11d7..2989c85 100644 --- a/app/components/scoring/SeasonStandings.tsx +++ b/app/components/scoring/SeasonStandings.tsx @@ -15,6 +15,7 @@ import { } from "~/components/ui/table"; import { Badge } from "~/components/ui/badge"; import { TrendingUp, TrendingDown, Minus, Flag, CheckCircle2, Star } from "lucide-react"; +import { TeamOwnerBadge } from "~/components/ui/team-owner-badge"; interface SeasonStanding { id: string; @@ -76,31 +77,6 @@ export function SeasonStandings({ return ownershipMap.get(participantId) || null; }; - // Generate avatar from team name (first letter) - 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 ( -
- {initial} -
- ); - }; - // Get movement indicator const getMovementIndicator = ( currentPosition: number, @@ -271,15 +247,10 @@ export function SeasonStandings({ {showOwnership && ( {ownership ? ( -
- {getTeamAvatar(ownership.teamName)} -
-

{ownership.teamName}

- {ownership.ownerName && ( -

{ownership.ownerName}

- )} -
-
+ ) : ( - )} diff --git a/app/components/scoring/SportSeasonDisplay.tsx b/app/components/scoring/SportSeasonDisplay.tsx index 8cbd780..451c861 100644 --- a/app/components/scoring/SportSeasonDisplay.tsx +++ b/app/components/scoring/SportSeasonDisplay.tsx @@ -42,6 +42,10 @@ interface Match { id: string; name: string; } | null; + loser?: { + id: string; + name: string; + } | null; } interface SeasonStanding { @@ -92,6 +96,8 @@ interface SportSeasonDisplayProps { // Playoff data playoffMatches?: Match[]; playoffRounds?: string[]; + preEliminatedParticipants?: { id: string; name: string }[]; + participantPoints?: { participantId: string; points: number }[]; // Season standings data (F1) seasonStandings?: SeasonStanding[]; @@ -117,6 +123,8 @@ export function SportSeasonDisplay({ sportName, playoffMatches = [], playoffRounds = [], + preEliminatedParticipants = [], + participantPoints = [], seasonStandings = [], seasonIsFinalized = false, qpStandings = [], @@ -137,10 +145,12 @@ export function SportSeasonDisplay({ ); @@ -153,7 +163,7 @@ export function SportSeasonDisplay({ userParticipantIds={userParticipantIds} showOwnership={showOwnership} isFinalized={seasonIsFinalized} - title={`${sportName} ${sportSeasonName}`} + title={sportSeasonName} description="Championship standings - positions calculated from points" /> ); diff --git a/app/components/scoring/__tests__/PlayoffBracket.test.tsx b/app/components/scoring/__tests__/PlayoffBracket.test.tsx new file mode 100644 index 0000000..4536fdb --- /dev/null +++ b/app/components/scoring/__tests__/PlayoffBracket.test.tsx @@ -0,0 +1,138 @@ +import { describe, it, expect } from "vitest"; +import { buildFeederMap, groupMatchesByRound } from "../PlayoffBracket"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeMatch( + round: string, + matchNumber: number, + overrides: Partial<{ + participant1Id: string | null; + participant2Id: string | null; + winnerId: string | null; + loserId: string | null; + isComplete: boolean; + }> = {} +) { + return { + id: `${round}-${matchNumber}`, + round, + matchNumber, + participant1Id: overrides.participant1Id ?? `p${matchNumber * 2 - 1}`, + participant2Id: overrides.participant2Id ?? `p${matchNumber * 2}`, + winnerId: overrides.winnerId ?? null, + loserId: overrides.loserId ?? null, + isComplete: overrides.isComplete ?? false, + participant1Score: null, + participant2Score: null, + }; +} + +// --------------------------------------------------------------------------- +// groupMatchesByRound +// --------------------------------------------------------------------------- + +describe("groupMatchesByRound", () => { + it("groups matches by round and sorts by matchNumber", () => { + const matches = [ + makeMatch("Semifinals", 2), + makeMatch("Semifinals", 1), + makeMatch("Finals", 1), + ]; + const grouped = groupMatchesByRound(matches); + expect(grouped.get("Semifinals")?.map((m) => m.matchNumber)).toEqual([1, 2]); + expect(grouped.get("Finals")?.map((m) => m.matchNumber)).toEqual([1]); + }); + + it("returns an empty map for an empty input", () => { + expect(groupMatchesByRound([]).size).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// buildFeederMap +// --------------------------------------------------------------------------- + +describe("buildFeederMap", () => { + it("returns an empty map when there is only one round", () => { + const matches = [makeMatch("Finals", 1)]; + const map = buildFeederMap(groupMatchesByRound(matches), ["Finals"]); + expect(map.size).toBe(0); + }); + + it("maps SF slots to the correct QF matches for an 8-team bracket", () => { + const rounds = ["Quarterfinals", "Semifinals", "Finals"]; + const matches = [ + makeMatch("Quarterfinals", 1), + makeMatch("Quarterfinals", 2), + makeMatch("Quarterfinals", 3), + makeMatch("Quarterfinals", 4), + makeMatch("Semifinals", 1), + makeMatch("Semifinals", 2), + makeMatch("Finals", 1), + ]; + + const map = buildFeederMap(groupMatchesByRound(matches), rounds); + + // SF Match 1, slot p1 ← QF Match 1 + expect(map.get("Semifinals:1:p1")).toEqual({ round: "Quarterfinals", matchNumber: 1 }); + // SF Match 1, slot p2 ← QF Match 2 + expect(map.get("Semifinals:1:p2")).toEqual({ round: "Quarterfinals", matchNumber: 2 }); + // SF Match 2, slot p1 ← QF Match 3 + expect(map.get("Semifinals:2:p1")).toEqual({ round: "Quarterfinals", matchNumber: 3 }); + // SF Match 2, slot p2 ← QF Match 4 + expect(map.get("Semifinals:2:p2")).toEqual({ round: "Quarterfinals", matchNumber: 4 }); + }); + + it("maps Finals slots to the correct SF matches", () => { + const rounds = ["Quarterfinals", "Semifinals", "Finals"]; + const matches = [ + makeMatch("Quarterfinals", 1), + makeMatch("Quarterfinals", 2), + makeMatch("Quarterfinals", 3), + makeMatch("Quarterfinals", 4), + makeMatch("Semifinals", 1), + makeMatch("Semifinals", 2), + makeMatch("Finals", 1), + ]; + + const map = buildFeederMap(groupMatchesByRound(matches), rounds); + + expect(map.get("Finals:1:p1")).toEqual({ round: "Semifinals", matchNumber: 1 }); + expect(map.get("Finals:1:p2")).toEqual({ round: "Semifinals", matchNumber: 2 }); + }); + + it("does not add an entry when the source match does not exist in the previous round", () => { + const rounds = ["Quarterfinals", "Finals"]; + const matches = [ + makeMatch("Quarterfinals", 1), + makeMatch("Quarterfinals", 2), + makeMatch("Finals", 1), + ]; + + const map = buildFeederMap(groupMatchesByRound(matches), rounds); + + expect(map.get("Finals:1:p1")).toEqual({ round: "Quarterfinals", matchNumber: 1 }); + expect(map.get("Finals:1:p2")).toEqual({ round: "Quarterfinals", matchNumber: 2 }); + expect(map.has("Finals:2:p1")).toBe(false); + }); + + it("handles a 16-team bracket correctly for Round of 16 → Quarterfinals", () => { + const rounds = ["Round of 16", "Quarterfinals", "Semifinals", "Finals"]; + const matches = [ + ...[1, 2, 3, 4, 5, 6, 7, 8].map((n) => makeMatch("Round of 16", n)), + ...[1, 2, 3, 4].map((n) => makeMatch("Quarterfinals", n)), + ...[1, 2].map((n) => makeMatch("Semifinals", n)), + makeMatch("Finals", 1), + ]; + + const map = buildFeederMap(groupMatchesByRound(matches), rounds); + + expect(map.get("Quarterfinals:1:p1")).toEqual({ round: "Round of 16", matchNumber: 1 }); + expect(map.get("Quarterfinals:1:p2")).toEqual({ round: "Round of 16", matchNumber: 2 }); + expect(map.get("Quarterfinals:4:p1")).toEqual({ round: "Round of 16", matchNumber: 7 }); + expect(map.get("Quarterfinals:4:p2")).toEqual({ round: "Round of 16", matchNumber: 8 }); + }); +}); diff --git a/app/components/ui/team-owner-badge.tsx b/app/components/ui/team-owner-badge.tsx new file mode 100644 index 0000000..51639ab --- /dev/null +++ b/app/components/ui/team-owner-badge.tsx @@ -0,0 +1,42 @@ +import { getAvatarColor } from "~/lib/color-hash"; + +interface TeamOwnerBadgeProps { + teamName: string; + ownerName?: string; + align?: "left" | "right"; // right = avatar on the right side for the right column +} + +export function TeamOwnerBadge({ + teamName, + ownerName, + align = "left", +}: TeamOwnerBadgeProps) { + const initial = teamName.charAt(0).toUpperCase(); + const colorClass = getAvatarColor(teamName); + + const avatar = ( +
+ {initial} +
+ ); + + const text = ( +
+

{teamName}

+ {ownerName && ( +

{ownerName}

+ )} +
+ ); + + return ( +
+ {avatar} + {text} +
+ ); +} diff --git a/app/lib/color-hash.ts b/app/lib/color-hash.ts new file mode 100644 index 0000000..427394c --- /dev/null +++ b/app/lib/color-hash.ts @@ -0,0 +1,18 @@ +const AVATAR_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", +]; + +/** Returns a consistent Tailwind bg color class for a given team name. */ +export function getAvatarColor(teamName: string): string { + const hash = teamName + .split("") + .reduce((acc, char) => acc + char.charCodeAt(0), 0); + return AVATAR_COLORS[hash % AVATAR_COLORS.length]; +} diff --git a/app/routes/leagues/$leagueId.server.ts b/app/routes/leagues/$leagueId.server.ts index a071314..ca83b44 100644 --- a/app/routes/leagues/$leagueId.server.ts +++ b/app/routes/leagues/$leagueId.server.ts @@ -152,6 +152,7 @@ export async function loader(args: Route.LoaderArgs) { sportsCount, teamsWithOwners, isDraftOrderSet, + draftSlots, sportsSeasons, standings, origin, diff --git a/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.server.ts b/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.server.ts index 1c1b868..d94c64b 100644 --- a/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.server.ts +++ b/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.server.ts @@ -16,7 +16,7 @@ import { } from "~/models/scoring-event"; import { database } from "~/database/context"; import * as schema from "~/database/schema"; -import { eq } from "drizzle-orm"; +import { eq, and } from "drizzle-orm"; export async function loader(args: Route.LoaderArgs) { const { userId } = await getAuth(args); @@ -135,6 +135,8 @@ export async function loader(args: Route.LoaderArgs) { let playoffMatches: any[] = []; let playoffRounds: string[] = []; + let preEliminatedParticipants: { id: string; name: string }[] = []; + let participantPoints: { participantId: string; points: number }[] = []; let seasonStandings: SeasonStanding[] = []; let qpStandings: any[] = []; @@ -153,13 +155,39 @@ export async function loader(args: Route.LoaderArgs) { participant1: true, participant2: true, winner: true, + loser: true, }, }); playoffMatches = matches; - const roundSet = new Set(matches.map((m: any) => m.round)); - playoffRounds = Array.from(roundSet); + const roundMatchCounts = new Map(); + for (const m of matches) { + roundMatchCounts.set(m.round, (roundMatchCounts.get(m.round) || 0) + 1); + } + playoffRounds = Array.from(roundMatchCounts.keys()).sort( + (a, b) => (roundMatchCounts.get(b) || 0) - (roundMatchCounts.get(a) || 0) + ); } + + // Fetch group-stage losers and fantasy points in parallel + const [eliminatedResults, seasonResults] = await Promise.all([ + db.query.participantResults.findMany({ + where: and( + eq(schema.participantResults.sportsSeasonId, sportsSeasonId), + eq(schema.participantResults.finalPosition, 0) + ), + with: { participant: true }, + }), + getSeasonResults(sportsSeasonId), + ]); + + preEliminatedParticipants = eliminatedResults + .filter((r) => r.participant) + .map((r) => ({ id: (r as any).participant.id, name: (r as any).participant.name })); + + participantPoints = seasonResults + .map((r) => ({ participantId: r.participantId, points: parseFloat(r.currentPoints || "0") })) + .filter((r) => r.points > 0); } else if (scoringPattern === "season_standings") { // Fetch F1-style championship standings and map to SeasonStanding shape const results = await getSeasonResults(sportsSeasonId); @@ -196,6 +224,8 @@ export async function loader(args: Route.LoaderArgs) { scoringPattern, playoffMatches, playoffRounds, + preEliminatedParticipants, + participantPoints, seasonStandings, qpStandings, teamOwnerships, diff --git a/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.tsx b/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.tsx index e8851fc..5a5e34f 100644 --- a/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.tsx +++ b/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.tsx @@ -47,6 +47,8 @@ export default function SportSeasonDetail({ scoringPattern, playoffMatches, playoffRounds, + preEliminatedParticipants, + participantPoints, seasonStandings, qpStandings, teamOwnerships, @@ -79,15 +81,16 @@ export default function SportSeasonDetail({
- {/* Event schedule (upcoming + recent) - shown when there's event data */} - {(upcomingEvents.length > 0 || recentEvents.length > 0) && ( -
- -
- )} + {/* Event schedule - hidden for bracket sports (the bracket itself is the event) */} + {scoringPattern !== "playoff_bracket" && + (upcomingEvents.length > 0 || recentEvents.length > 0) && ( +
+ +
+ )} )} + {/* Draft Order card - shown when order is set, pre_draft/draft */} + {season && isDraftOrPreDraft && draftSlots.length > 0 && ( + + +
+
+ Draft Order + + {season.status === "pre_draft" + ? "Upcoming draft order" + : "Current draft order"} + +
+ +
+
+ +
+ {draftSlots.map((slot, index) => { + const ownerName = slot.team.ownerId + ? ownerMap[slot.team.ownerId] + : null; + return ( +
+
+ {index + 1} +
+
+

+ {slot.team.name} +

+ {ownerName && ( +

+ {ownerName} +

+ )} +
+
+ ); + })} +
+
+
+ )} + {/* Teams card - pre_draft/draft only; replaced by standings panel post-draft */} {season && isDraftOrPreDraft && ( diff --git a/app/routes/leagues/__tests__/draft-order-visibility.test.ts b/app/routes/leagues/__tests__/draft-order-visibility.test.ts new file mode 100644 index 0000000..f8e3379 --- /dev/null +++ b/app/routes/leagues/__tests__/draft-order-visibility.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from 'vitest'; + +// --------------------------------------------------------------------------- +// Logic extracted from the league home page component +// --------------------------------------------------------------------------- + +type SeasonStatus = 'pre_draft' | 'draft' | 'active' | 'completed'; + +/** + * Mirrors the isDraftOrPreDraft and draft-order-card visibility logic + * from app/routes/leagues/$leagueId.tsx: + * + * const isDraftOrPreDraft = season?.status === "pre_draft" || season?.status === "draft"; + * ... + * {season && isDraftOrPreDraft && draftSlots.length > 0 && } + */ +function shouldShowDraftOrderCard( + season: { status: SeasonStatus } | null, + draftSlotCount: number +): boolean { + if (!season) return false; + const isDraftOrPreDraft = season.status === 'pre_draft' || season.status === 'draft'; + return isDraftOrPreDraft && draftSlotCount > 0; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('Draft Order Card Visibility', () => { + describe('visible states (pre_draft / draft)', () => { + it('shows when status is pre_draft and draft slots are set', () => { + expect(shouldShowDraftOrderCard({ status: 'pre_draft' }, 4)).toBe(true); + }); + + it('shows when status is draft and draft slots are set', () => { + expect(shouldShowDraftOrderCard({ status: 'draft' }, 4)).toBe(true); + }); + + it('shows with a single draft slot', () => { + expect(shouldShowDraftOrderCard({ status: 'pre_draft' }, 1)).toBe(true); + }); + }); + + describe('hidden states (active / completed)', () => { + it('hides when status is active even if draft slots exist', () => { + expect(shouldShowDraftOrderCard({ status: 'active' }, 4)).toBe(false); + }); + + it('hides when status is completed even if draft slots exist', () => { + expect(shouldShowDraftOrderCard({ status: 'completed' }, 4)).toBe(false); + }); + }); + + describe('no draft slots', () => { + it('hides when no draft slots are set (pre_draft)', () => { + expect(shouldShowDraftOrderCard({ status: 'pre_draft' }, 0)).toBe(false); + }); + + it('hides when no draft slots are set (draft)', () => { + expect(shouldShowDraftOrderCard({ status: 'draft' }, 0)).toBe(false); + }); + }); + + describe('no season', () => { + it('hides when there is no season', () => { + expect(shouldShowDraftOrderCard(null, 4)).toBe(false); + }); + }); +});