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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c6ba59b0e6
commit
d88be08deb
12 changed files with 849 additions and 287 deletions
|
|
@ -7,6 +7,7 @@ import {
|
||||||
} from "~/components/ui/card";
|
} from "~/components/ui/card";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { Users } from "lucide-react";
|
import { Users } from "lucide-react";
|
||||||
|
import { getAvatarColor } from "~/lib/color-hash";
|
||||||
|
|
||||||
interface GroupMember {
|
interface GroupMember {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -52,29 +53,13 @@ export function GroupStageDisplay({
|
||||||
const ownershipMap = new Map<string, TeamOwnership>();
|
const ownershipMap = new Map<string, TeamOwnership>();
|
||||||
teamOwnerships.forEach((o) => ownershipMap.set(o.participantId, o));
|
teamOwnerships.forEach((o) => ownershipMap.set(o.participantId, o));
|
||||||
|
|
||||||
const getTeamAvatar = (teamName: string) => {
|
const getTeamAvatar = (teamName: string) => (
|
||||||
const initial = teamName.charAt(0).toUpperCase();
|
<div
|
||||||
const hash = teamName.split("").reduce((acc, char) => acc + char.charCodeAt(0), 0);
|
className={`h-6 w-6 rounded-full ${getAvatarColor(teamName)} text-white text-xs font-bold flex items-center justify-center`}
|
||||||
const colors = [
|
>
|
||||||
"bg-blue-500",
|
{teamName.charAt(0).toUpperCase()}
|
||||||
"bg-green-500",
|
</div>
|
||||||
"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 totalTeams = groups.reduce((sum, g) => sum + g.members.length, 0);
|
||||||
const eliminatedCount = groups.reduce(
|
const eliminatedCount = groups.reduce(
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,20 @@
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "~/components/ui/card";
|
|
||||||
import { Badge } from "~/components/ui/badge";
|
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 {
|
interface Match {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -19,251 +27,482 @@ interface Match {
|
||||||
isComplete: boolean;
|
isComplete: boolean;
|
||||||
participant1Score: string | null;
|
participant1Score: string | null;
|
||||||
participant2Score: string | null;
|
participant2Score: string | null;
|
||||||
participant1?: {
|
participant1?: Participant | null;
|
||||||
id: string;
|
participant2?: Participant | null;
|
||||||
name: string;
|
winner?: Participant | null;
|
||||||
} | null;
|
loser?: Participant | null;
|
||||||
participant2?: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
} | null;
|
|
||||||
winner?: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
} | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TeamOwnership {
|
interface TeamOwnership {
|
||||||
participantId: string;
|
participantId: string;
|
||||||
teamName: string;
|
teamName: string;
|
||||||
teamId: string;
|
teamId: string;
|
||||||
ownerName?: string; // Optional: user's display name
|
ownerName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PlayoffBracketProps {
|
interface PlayoffBracketProps {
|
||||||
matches: Match[];
|
matches: Match[];
|
||||||
rounds: string[]; // Ordered list of round names
|
rounds: string[]; // Ordered list of round names (earliest first)
|
||||||
teamOwnerships?: TeamOwnership[]; // Which teams own which participants
|
preEliminatedParticipants?: { id: string; name: string }[]; // Eliminated before bracket (e.g. group stage)
|
||||||
showOwnership?: boolean; // Toggle ownership display
|
participantPoints?: { participantId: string; points: number }[]; // Computed fantasy points per participant
|
||||||
|
teamOwnerships?: TeamOwnership[];
|
||||||
|
userParticipantIds?: string[];
|
||||||
|
showOwnership?: boolean;
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Group matches by round, sorted by matchNumber, in one pass. */
|
||||||
|
export function groupMatchesByRound(matches: Match[]): Map<string, Match[]> {
|
||||||
|
const byRound = new Map<string, Match[]>();
|
||||||
|
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
|
* 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.
|
||||||
* Features:
|
|
||||||
* - Visual bracket display grouped by round
|
|
||||||
* - Shows match winners and scores
|
|
||||||
* - Ownership hints with team avatars and tooltips (Q15)
|
|
||||||
* - Responsive layout
|
|
||||||
*/
|
*/
|
||||||
|
export function buildFeederMap(
|
||||||
|
matchesByRound: Map<string, Match[]>,
|
||||||
|
orderedRounds: string[]
|
||||||
|
): Map<string, { round: string; matchNumber: number }> {
|
||||||
|
const feederMap = new Map<string, { round: string; matchNumber: number }>();
|
||||||
|
|
||||||
|
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({
|
export function PlayoffBracket({
|
||||||
matches,
|
matches,
|
||||||
rounds,
|
rounds,
|
||||||
|
preEliminatedParticipants = [],
|
||||||
|
participantPoints = [],
|
||||||
teamOwnerships = [],
|
teamOwnerships = [],
|
||||||
|
userParticipantIds = [],
|
||||||
showOwnership = true,
|
showOwnership = true,
|
||||||
title = "Playoff Bracket",
|
title = "Playoff Bracket",
|
||||||
description,
|
description,
|
||||||
}: PlayoffBracketProps) {
|
}: PlayoffBracketProps) {
|
||||||
// Create a map of participant ID to ownership info for fast lookup
|
const userParticipantSet = new Set(userParticipantIds);
|
||||||
const ownershipMap = new Map<string, TeamOwnership>();
|
const ownershipMap = new Map<string, TeamOwnership>();
|
||||||
teamOwnerships.forEach((ownership) => {
|
teamOwnerships.forEach((o) => ownershipMap.set(o.participantId, o));
|
||||||
ownershipMap.set(ownership.participantId, ownership);
|
const pointsMap = new Map(participantPoints.map((p) => [p.participantId, p.points]));
|
||||||
});
|
|
||||||
|
|
||||||
// Group matches by round
|
// Group matches once; reused for feeder map and rendering
|
||||||
const matchesByRound = rounds.reduce((acc, round) => {
|
const matchesByRound = groupMatchesByRound(matches);
|
||||||
acc[round] = matches.filter((m) => m.round === round);
|
const feederMap = buildFeederMap(matchesByRound, rounds);
|
||||||
return acc;
|
|
||||||
}, {} as Record<string, Match[]>);
|
|
||||||
|
|
||||||
// Get ownership info for a participant
|
const getTbdLabel = (round: string, matchNumber: number, slot: "p1" | "p2") => {
|
||||||
const getOwnership = (participantId: string | null): TeamOwnership | null => {
|
const feeder = feederMap.get(`${round}:${matchNumber}:${slot}`);
|
||||||
if (!participantId || !showOwnership) return null;
|
if (!feeder) return "TBD";
|
||||||
return ownershipMap.get(participantId) || null;
|
return `Winner of ${feeder.round} M${feeder.matchNumber}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Generate avatar from team name (first letter)
|
// Build elimination rankings: collect losers per round, then assign rank labels
|
||||||
const getTeamAvatar = (teamName: string) => {
|
const losersByRound = new Map<string, Array<{ participant: Participant; score: string | null; ownership: TeamOwnership | null }>>();
|
||||||
const initial = teamName.charAt(0).toUpperCase();
|
let hasScore = false;
|
||||||
// Use a simple hash to generate a consistent color
|
let bracketWinner: Participant | null = null;
|
||||||
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 (
|
const lastRound = rounds[rounds.length - 1];
|
||||||
<div
|
const finalMatch = lastRound
|
||||||
className={`h-6 w-6 rounded-full ${colorClass} text-white text-xs font-bold flex items-center justify-center`}
|
? (matchesByRound.get(lastRound) || []).find((m) => m.matchNumber === 1)
|
||||||
>
|
: null;
|
||||||
{initial}
|
if (finalMatch?.winner) bracketWinner = finalMatch.winner;
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Render a participant row with optional ownership indicator
|
for (const match of matches) {
|
||||||
const renderParticipant = (
|
if (!match.isComplete || !match.loser) continue;
|
||||||
participant: { id: string; name: string } | null | undefined,
|
const loserScore =
|
||||||
participantId: string | null,
|
match.loserId === match.participant1Id
|
||||||
isWinner: boolean,
|
? match.participant1Score
|
||||||
score: string | null
|
: match.participant2Score;
|
||||||
) => {
|
if (loserScore) hasScore = true;
|
||||||
const ownership = getOwnership(participantId);
|
if (!losersByRound.has(match.round)) losersByRound.set(match.round, []);
|
||||||
const participantName = participant?.name || "TBD";
|
losersByRound.get(match.round)!.push({
|
||||||
|
participant: match.loser,
|
||||||
|
score: loserScore,
|
||||||
|
ownership: ownershipMap.get(match.loser.id) || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
// Walk rounds latest→earliest to assign rank labels (no mutation)
|
||||||
<div className="flex items-center justify-between gap-2 min-w-0">
|
const rankedEntries: EliminatedEntry[] = [];
|
||||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
let nextRank = 2;
|
||||||
{isWinner && (
|
for (let ri = rounds.length - 1; ri >= 0; ri--) {
|
||||||
<Trophy className="h-4 w-4 text-yellow-500 shrink-0" />
|
const roundLosers = losersByRound.get(rounds[ri]) || [];
|
||||||
)}
|
if (roundLosers.length === 0) continue;
|
||||||
<span
|
const rankEnd = nextRank + roundLosers.length - 1;
|
||||||
className={`truncate ${
|
const rankLabel =
|
||||||
isWinner ? "font-bold" : "font-medium"
|
nextRank === rankEnd ? `#${nextRank}` : `#${nextRank}–${rankEnd}`;
|
||||||
} ${participantName === "TBD" ? "text-muted-foreground italic" : ""}`}
|
for (const loser of roundLosers) {
|
||||||
>
|
rankedEntries.push({ ...loser, rankLabel });
|
||||||
{participantName}
|
}
|
||||||
</span>
|
nextRank += roundLosers.length;
|
||||||
</div>
|
}
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
|
||||||
{score && (
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{parseFloat(score)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{ownership && (
|
|
||||||
<div
|
|
||||||
className="cursor-help"
|
|
||||||
title={`${ownership.teamName}${ownership.ownerName ? ` (${ownership.ownerName})` : ""}`}
|
|
||||||
>
|
|
||||||
{getTeamAvatar(ownership.teamName)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Count how many matches have ownership info
|
const showRankings = rankedEntries.length > 0 || bracketWinner !== null || preEliminatedParticipants.length > 0;
|
||||||
const matchesWithOwnership = matches.filter(
|
|
||||||
(m) =>
|
// Hoist winner row lookups so we don't need an IIFE in JSX
|
||||||
(m.participant1Id && ownershipMap.has(m.participant1Id)) ||
|
const winnerIsOwned = bracketWinner ? userParticipantSet.has(bracketWinner.id) : false;
|
||||||
(m.participant2Id && ownershipMap.has(m.participant2Id))
|
const winnerOwnership = bracketWinner ? ownershipMap.get(bracketWinner.id) : undefined;
|
||||||
).length;
|
const winnerPts = bracketWinner ? pointsMap.get(bracketWinner.id) : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<div className="space-y-6">
|
||||||
<CardHeader>
|
<div>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<h2 className="text-xl font-semibold">{title}</h2>
|
||||||
<Users className="h-5 w-5" />
|
{description && (
|
||||||
{title}
|
<p className="text-sm text-muted-foreground mt-1">{description}</p>
|
||||||
</CardTitle>
|
|
||||||
{description && <CardDescription>{description}</CardDescription>}
|
|
||||||
{showOwnership && matchesWithOwnership > 0 && (
|
|
||||||
<div className="text-sm text-muted-foreground flex items-center gap-2 mt-2">
|
|
||||||
<Badge variant="secondary" className="text-xs">
|
|
||||||
{matchesWithOwnership} match{matchesWithOwnership !== 1 ? "es" : ""} with team ownership
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent>
|
|
||||||
{matches.length === 0 ? (
|
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
|
||||||
<p className="text-sm">No bracket matches available yet.</p>
|
|
||||||
<p className="text-xs mt-1">
|
|
||||||
Matches will appear here once the bracket is set up.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{rounds.map((round) => {
|
|
||||||
const roundMatches = matchesByRound[round] || [];
|
|
||||||
if (roundMatches.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
{matches.length === 0 ? (
|
||||||
<div key={round} className="space-y-3">
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
<div className="flex items-center gap-2">
|
<p className="text-sm">No bracket matches available yet.</p>
|
||||||
<h3 className="font-semibold text-lg">{round}</h3>
|
<p className="text-xs mt-1">
|
||||||
<Badge variant="outline" className="text-xs">
|
Matches will appear here once the bracket is set up.
|
||||||
{roundMatches.length} match{roundMatches.length !== 1 ? "es" : ""}
|
</p>
|
||||||
</Badge>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{rounds.map((round) => {
|
||||||
|
const roundMatches = matchesByRound.get(round) || [];
|
||||||
|
if (roundMatches.length === 0) return null;
|
||||||
|
|
||||||
<div className="grid gap-3">
|
return (
|
||||||
{roundMatches
|
<div key={round}>
|
||||||
.sort((a, b) => a.matchNumber - b.matchNumber)
|
<div className="flex items-center gap-2 mb-3">
|
||||||
.map((match) => {
|
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
const isComplete = match.isComplete;
|
{round}
|
||||||
const participant1IsWinner =
|
</h3>
|
||||||
match.winnerId === match.participant1Id;
|
<div className="flex-1 border-t border-border" />
|
||||||
const participant2IsWinner =
|
<span className="text-xs text-muted-foreground">
|
||||||
match.winnerId === match.participant2Id;
|
{roundMatches.length}{" "}
|
||||||
|
{roundMatches.length === 1 ? "match" : "matches"}
|
||||||
return (
|
</span>
|
||||||
<Card
|
|
||||||
key={match.id}
|
|
||||||
className={`${
|
|
||||||
isComplete
|
|
||||||
? "bg-muted/30"
|
|
||||||
: "border-primary/20"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-center justify-between gap-4 mb-2">
|
|
||||||
<span className="text-xs font-semibold text-muted-foreground">
|
|
||||||
Match {match.matchNumber}
|
|
||||||
</span>
|
|
||||||
{isComplete && (
|
|
||||||
<Badge variant="secondary" className="text-xs">
|
|
||||||
Complete
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
{renderParticipant(
|
|
||||||
match.participant1,
|
|
||||||
match.participant1Id,
|
|
||||||
participant1IsWinner,
|
|
||||||
match.participant1Score
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="flex-1 border-t border-muted"></div>
|
|
||||||
<span className="px-2 text-xs text-muted-foreground">
|
|
||||||
vs
|
|
||||||
</span>
|
|
||||||
<div className="flex-1 border-t border-muted"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{renderParticipant(
|
|
||||||
match.participant2,
|
|
||||||
match.participant2Id,
|
|
||||||
participant2IsWinner,
|
|
||||||
match.participant2Score
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
})}
|
<div className="grid md:grid-cols-2 gap-2">
|
||||||
</div>
|
{roundMatches.map((match) => {
|
||||||
)}
|
const p1IsWinner =
|
||||||
</CardContent>
|
match.isComplete && match.winnerId === match.participant1Id;
|
||||||
</Card>
|
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 (
|
||||||
|
<div
|
||||||
|
key={match.id}
|
||||||
|
className={`rounded-lg border bg-card px-3 py-2 ${matchHasOwned ? "border-electric/50" : "border-border"}`}
|
||||||
|
>
|
||||||
|
{/* Match label row */}
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-xs font-mono text-muted-foreground">
|
||||||
|
M{match.matchNumber}
|
||||||
|
</span>
|
||||||
|
{match.isComplete && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
Done
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Participants: left vs right (stacks on mobile) */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2">
|
||||||
|
{/* Participant 1 — left-aligned */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{p1IsOwned && !p1IsWinner && (
|
||||||
|
<span className="inline-block w-1.5 h-1.5 rounded-full bg-electric shrink-0" />
|
||||||
|
)}
|
||||||
|
{p1IsWinner && (
|
||||||
|
<Trophy className="h-3.5 w-3.5 text-yellow-500 shrink-0" />
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={[
|
||||||
|
"text-sm font-medium truncate",
|
||||||
|
p1IsLoser ? "text-muted-foreground line-through" : "",
|
||||||
|
p1IsTbd ? "text-muted-foreground italic font-normal" : "",
|
||||||
|
p1IsWinner ? "font-semibold" : "",
|
||||||
|
p1IsOwned && !p1IsLoser ? "text-electric" : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")}
|
||||||
|
>
|
||||||
|
{p1Name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{!p1IsTbd && p1Ownership && (
|
||||||
|
<div className="mt-1">
|
||||||
|
<TeamOwnerBadge
|
||||||
|
teamName={p1Ownership.teamName}
|
||||||
|
ownerName={p1Ownership.ownerName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Center: scores + vs */}
|
||||||
|
<div className="shrink-0 flex sm:flex-col items-center gap-1 sm:gap-0.5 min-w-[2.5rem]">
|
||||||
|
{match.participant1Score ? (
|
||||||
|
<>
|
||||||
|
<span className="text-xs tabular-nums font-medium">
|
||||||
|
{parseFloat(match.participant1Score)}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground/60 uppercase tracking-wider">
|
||||||
|
vs
|
||||||
|
</span>
|
||||||
|
<span className="text-xs tabular-nums font-medium">
|
||||||
|
{match.participant2Score
|
||||||
|
? parseFloat(match.participant2Score)
|
||||||
|
: "—"}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">vs</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Participant 2 — right-aligned on sm+, left-aligned on mobile */}
|
||||||
|
<div className="flex-1 min-w-0 flex flex-col sm:items-end">
|
||||||
|
<div className="flex items-center gap-1 sm:justify-end">
|
||||||
|
<span
|
||||||
|
className={[
|
||||||
|
"text-sm font-medium truncate",
|
||||||
|
p2IsLoser ? "text-muted-foreground line-through" : "",
|
||||||
|
p2IsTbd ? "text-muted-foreground italic font-normal" : "",
|
||||||
|
p2IsWinner ? "font-semibold" : "",
|
||||||
|
p2IsOwned && !p2IsLoser ? "text-electric" : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")}
|
||||||
|
>
|
||||||
|
{p2Name}
|
||||||
|
</span>
|
||||||
|
{p2IsWinner && (
|
||||||
|
<Trophy className="h-3.5 w-3.5 text-yellow-500 shrink-0" />
|
||||||
|
)}
|
||||||
|
{p2IsOwned && !p2IsWinner && (
|
||||||
|
<span className="inline-block w-1.5 h-1.5 rounded-full bg-electric shrink-0" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!p2IsTbd && p2Ownership && (
|
||||||
|
<div className="mt-1">
|
||||||
|
<TeamOwnerBadge
|
||||||
|
teamName={p2Ownership.teamName}
|
||||||
|
ownerName={p2Ownership.ownerName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Final Rankings */}
|
||||||
|
{showRankings && (
|
||||||
|
<Card className="border-electric/30">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-electric flex items-center gap-2">
|
||||||
|
<Trophy className="h-5 w-5" />
|
||||||
|
{bracketWinner ? "Final Rankings" : "Eliminated Teams"}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-20">Rank</TableHead>
|
||||||
|
<TableHead>Participant</TableHead>
|
||||||
|
{showOwnership && (
|
||||||
|
<TableHead className="w-40 pl-6">Drafted By</TableHead>
|
||||||
|
)}
|
||||||
|
{pointsMap.size > 0 && (
|
||||||
|
<TableHead className="text-right w-16">Pts</TableHead>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{bracketWinner && (
|
||||||
|
<TableRow className={winnerIsOwned ? "bg-electric/8 border-l-2 border-l-electric" : "bg-yellow-50/30 dark:bg-yellow-950/10"}>
|
||||||
|
<TableCell className="font-semibold text-yellow-600 dark:text-yellow-400">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Trophy className="h-3 w-3" />
|
||||||
|
#1
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-semibold">
|
||||||
|
{bracketWinner.name}
|
||||||
|
{winnerIsOwned && (
|
||||||
|
<Star className="inline ml-1.5 h-3 w-3 fill-current text-electric" />
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
{showOwnership && (
|
||||||
|
<TableCell className="pl-6">
|
||||||
|
{winnerOwnership ? (
|
||||||
|
<TeamOwnerBadge teamName={winnerOwnership.teamName} ownerName={winnerOwnership.ownerName} />
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
{pointsMap.size > 0 && (
|
||||||
|
<TableCell className="text-right font-semibold text-electric">
|
||||||
|
{winnerPts ?? "—"}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{rankedEntries.map((entry, i) => {
|
||||||
|
const isOwned = userParticipantSet.has(entry.participant.id);
|
||||||
|
const pts = pointsMap.get(entry.participant.id);
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={`${entry.participant.id}-${i}`}
|
||||||
|
className={isOwned ? "bg-electric/5 border-l-2 border-l-electric opacity-80" : "opacity-60"}
|
||||||
|
>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{entry.rankLabel}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className={isOwned ? "text-electric font-medium" : "text-muted-foreground"}>
|
||||||
|
{entry.participant.name}
|
||||||
|
{isOwned && (
|
||||||
|
<Star className="inline ml-1.5 h-3 w-3 fill-current text-electric" />
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
{showOwnership && (
|
||||||
|
<TableCell className="pl-6">
|
||||||
|
{entry.ownership ? (
|
||||||
|
<TeamOwnerBadge
|
||||||
|
teamName={entry.ownership.teamName}
|
||||||
|
ownerName={entry.ownership.ownerName}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
{pointsMap.size > 0 && (
|
||||||
|
<TableCell className="text-right tabular-nums text-muted-foreground">
|
||||||
|
{pts ?? "—"}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{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 (
|
||||||
|
<TableRow
|
||||||
|
key={p.id}
|
||||||
|
className={isOwned ? "bg-electric/5 border-l-2 border-l-electric opacity-60" : "opacity-40"}
|
||||||
|
>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">—</TableCell>
|
||||||
|
<TableCell className={isOwned ? "text-electric font-medium" : "text-muted-foreground"}>
|
||||||
|
{p.name}
|
||||||
|
{isOwned && (
|
||||||
|
<Star className="inline ml-1.5 h-3 w-3 fill-current text-electric" />
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
{showOwnership && (
|
||||||
|
<TableCell className="pl-6">
|
||||||
|
{ownership ? (
|
||||||
|
<TeamOwnerBadge teamName={ownership.teamName} ownerName={ownership.ownerName} />
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
{pointsMap.size > 0 && (
|
||||||
|
<TableCell className="text-right tabular-nums text-muted-foreground">
|
||||||
|
{pts ?? "—"}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
} from "~/components/ui/table";
|
} from "~/components/ui/table";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { TrendingUp, TrendingDown, Minus, Flag, CheckCircle2, Star } from "lucide-react";
|
import { TrendingUp, TrendingDown, Minus, Flag, CheckCircle2, Star } from "lucide-react";
|
||||||
|
import { TeamOwnerBadge } from "~/components/ui/team-owner-badge";
|
||||||
|
|
||||||
interface SeasonStanding {
|
interface SeasonStanding {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -76,31 +77,6 @@ export function SeasonStandings({
|
||||||
return ownershipMap.get(participantId) || null;
|
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 (
|
|
||||||
<div
|
|
||||||
className={`h-6 w-6 rounded-full ${colorClass} text-white text-xs font-bold flex items-center justify-center`}
|
|
||||||
>
|
|
||||||
{initial}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get movement indicator
|
// Get movement indicator
|
||||||
const getMovementIndicator = (
|
const getMovementIndicator = (
|
||||||
currentPosition: number,
|
currentPosition: number,
|
||||||
|
|
@ -271,15 +247,10 @@ export function SeasonStandings({
|
||||||
{showOwnership && (
|
{showOwnership && (
|
||||||
<TableCell className="pl-6">
|
<TableCell className="pl-6">
|
||||||
{ownership ? (
|
{ownership ? (
|
||||||
<div className="flex items-center gap-2">
|
<TeamOwnerBadge
|
||||||
{getTeamAvatar(ownership.teamName)}
|
teamName={ownership.teamName}
|
||||||
<div className="min-w-0">
|
ownerName={ownership.ownerName}
|
||||||
<p className="text-xs font-medium truncate">{ownership.teamName}</p>
|
/>
|
||||||
{ownership.ownerName && (
|
|
||||||
<p className="text-xs text-muted-foreground truncate">{ownership.ownerName}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-muted-foreground">-</span>
|
<span className="text-xs text-muted-foreground">-</span>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,10 @@ interface Match {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
} | null;
|
} | null;
|
||||||
|
loser?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SeasonStanding {
|
interface SeasonStanding {
|
||||||
|
|
@ -92,6 +96,8 @@ interface SportSeasonDisplayProps {
|
||||||
// Playoff data
|
// Playoff data
|
||||||
playoffMatches?: Match[];
|
playoffMatches?: Match[];
|
||||||
playoffRounds?: string[];
|
playoffRounds?: string[];
|
||||||
|
preEliminatedParticipants?: { id: string; name: string }[];
|
||||||
|
participantPoints?: { participantId: string; points: number }[];
|
||||||
|
|
||||||
// Season standings data (F1)
|
// Season standings data (F1)
|
||||||
seasonStandings?: SeasonStanding[];
|
seasonStandings?: SeasonStanding[];
|
||||||
|
|
@ -117,6 +123,8 @@ export function SportSeasonDisplay({
|
||||||
sportName,
|
sportName,
|
||||||
playoffMatches = [],
|
playoffMatches = [],
|
||||||
playoffRounds = [],
|
playoffRounds = [],
|
||||||
|
preEliminatedParticipants = [],
|
||||||
|
participantPoints = [],
|
||||||
seasonStandings = [],
|
seasonStandings = [],
|
||||||
seasonIsFinalized = false,
|
seasonIsFinalized = false,
|
||||||
qpStandings = [],
|
qpStandings = [],
|
||||||
|
|
@ -137,10 +145,12 @@ export function SportSeasonDisplay({
|
||||||
<PlayoffBracket
|
<PlayoffBracket
|
||||||
matches={playoffMatches}
|
matches={playoffMatches}
|
||||||
rounds={playoffRounds}
|
rounds={playoffRounds}
|
||||||
|
preEliminatedParticipants={preEliminatedParticipants}
|
||||||
|
participantPoints={participantPoints}
|
||||||
teamOwnerships={teamOwnerships}
|
teamOwnerships={teamOwnerships}
|
||||||
|
userParticipantIds={userParticipantIds}
|
||||||
showOwnership={showOwnership}
|
showOwnership={showOwnership}
|
||||||
title={`${sportName} ${sportSeasonName}`}
|
title="Playoff Bracket"
|
||||||
description="Playoff bracket"
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -153,7 +163,7 @@ export function SportSeasonDisplay({
|
||||||
userParticipantIds={userParticipantIds}
|
userParticipantIds={userParticipantIds}
|
||||||
showOwnership={showOwnership}
|
showOwnership={showOwnership}
|
||||||
isFinalized={seasonIsFinalized}
|
isFinalized={seasonIsFinalized}
|
||||||
title={`${sportName} ${sportSeasonName}`}
|
title={sportSeasonName}
|
||||||
description="Championship standings - positions calculated from points"
|
description="Championship standings - positions calculated from points"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
138
app/components/scoring/__tests__/PlayoffBracket.test.tsx
Normal file
138
app/components/scoring/__tests__/PlayoffBracket.test.tsx
Normal file
|
|
@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
42
app/components/ui/team-owner-badge.tsx
Normal file
42
app/components/ui/team-owner-badge.tsx
Normal file
|
|
@ -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 = (
|
||||||
|
<div
|
||||||
|
className={`h-6 w-6 rounded-full ${colorClass} text-white text-xs font-bold flex items-center justify-center shrink-0`}
|
||||||
|
>
|
||||||
|
{initial}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const text = (
|
||||||
|
<div className={`min-w-0 ${align === "right" ? "text-right" : ""}`}>
|
||||||
|
<p className="text-xs font-medium truncate">{teamName}</p>
|
||||||
|
{ownerName && (
|
||||||
|
<p className="text-xs text-muted-foreground truncate">{ownerName}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2 ${align === "right" ? "flex-row-reverse" : ""}`}
|
||||||
|
>
|
||||||
|
{avatar}
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
app/lib/color-hash.ts
Normal file
18
app/lib/color-hash.ts
Normal file
|
|
@ -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];
|
||||||
|
}
|
||||||
|
|
@ -152,6 +152,7 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
sportsCount,
|
sportsCount,
|
||||||
teamsWithOwners,
|
teamsWithOwners,
|
||||||
isDraftOrderSet,
|
isDraftOrderSet,
|
||||||
|
draftSlots,
|
||||||
sportsSeasons,
|
sportsSeasons,
|
||||||
standings,
|
standings,
|
||||||
origin,
|
origin,
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ import {
|
||||||
} from "~/models/scoring-event";
|
} from "~/models/scoring-event";
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
|
|
||||||
export async function loader(args: Route.LoaderArgs) {
|
export async function loader(args: Route.LoaderArgs) {
|
||||||
const { userId } = await getAuth(args);
|
const { userId } = await getAuth(args);
|
||||||
|
|
@ -135,6 +135,8 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
|
|
||||||
let playoffMatches: any[] = [];
|
let playoffMatches: any[] = [];
|
||||||
let playoffRounds: string[] = [];
|
let playoffRounds: string[] = [];
|
||||||
|
let preEliminatedParticipants: { id: string; name: string }[] = [];
|
||||||
|
let participantPoints: { participantId: string; points: number }[] = [];
|
||||||
let seasonStandings: SeasonStanding[] = [];
|
let seasonStandings: SeasonStanding[] = [];
|
||||||
let qpStandings: any[] = [];
|
let qpStandings: any[] = [];
|
||||||
|
|
||||||
|
|
@ -153,13 +155,39 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
participant1: true,
|
participant1: true,
|
||||||
participant2: true,
|
participant2: true,
|
||||||
winner: true,
|
winner: true,
|
||||||
|
loser: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
playoffMatches = matches;
|
playoffMatches = matches;
|
||||||
|
|
||||||
const roundSet = new Set(matches.map((m: any) => m.round));
|
const roundMatchCounts = new Map<string, number>();
|
||||||
playoffRounds = Array.from(roundSet);
|
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") {
|
} else if (scoringPattern === "season_standings") {
|
||||||
// Fetch F1-style championship standings and map to SeasonStanding shape
|
// Fetch F1-style championship standings and map to SeasonStanding shape
|
||||||
const results = await getSeasonResults(sportsSeasonId);
|
const results = await getSeasonResults(sportsSeasonId);
|
||||||
|
|
@ -196,6 +224,8 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
scoringPattern,
|
scoringPattern,
|
||||||
playoffMatches,
|
playoffMatches,
|
||||||
playoffRounds,
|
playoffRounds,
|
||||||
|
preEliminatedParticipants,
|
||||||
|
participantPoints,
|
||||||
seasonStandings,
|
seasonStandings,
|
||||||
qpStandings,
|
qpStandings,
|
||||||
teamOwnerships,
|
teamOwnerships,
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,8 @@ export default function SportSeasonDetail({
|
||||||
scoringPattern,
|
scoringPattern,
|
||||||
playoffMatches,
|
playoffMatches,
|
||||||
playoffRounds,
|
playoffRounds,
|
||||||
|
preEliminatedParticipants,
|
||||||
|
participantPoints,
|
||||||
seasonStandings,
|
seasonStandings,
|
||||||
qpStandings,
|
qpStandings,
|
||||||
teamOwnerships,
|
teamOwnerships,
|
||||||
|
|
@ -79,15 +81,16 @@ export default function SportSeasonDetail({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Event schedule (upcoming + recent) - shown when there's event data */}
|
{/* Event schedule - hidden for bracket sports (the bracket itself is the event) */}
|
||||||
{(upcomingEvents.length > 0 || recentEvents.length > 0) && (
|
{scoringPattern !== "playoff_bracket" &&
|
||||||
<div className="mb-6">
|
(upcomingEvents.length > 0 || recentEvents.length > 0) && (
|
||||||
<EventSchedule
|
<div className="mb-6">
|
||||||
upcomingEvents={upcomingEvents as any}
|
<EventSchedule
|
||||||
recentEvents={recentEvents as any}
|
upcomingEvents={upcomingEvents as any}
|
||||||
/>
|
recentEvents={recentEvents as any}
|
||||||
</div>
|
/>
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<SportSeasonDisplay
|
<SportSeasonDisplay
|
||||||
scoringPattern={scoringPattern as any}
|
scoringPattern={scoringPattern as any}
|
||||||
|
|
@ -95,6 +98,8 @@ export default function SportSeasonDetail({
|
||||||
sportName={sportsSeason.sport.name}
|
sportName={sportsSeason.sport.name}
|
||||||
playoffMatches={playoffMatches as any}
|
playoffMatches={playoffMatches as any}
|
||||||
playoffRounds={playoffRounds}
|
playoffRounds={playoffRounds}
|
||||||
|
preEliminatedParticipants={preEliminatedParticipants}
|
||||||
|
participantPoints={participantPoints}
|
||||||
seasonStandings={seasonStandings as any}
|
seasonStandings={seasonStandings as any}
|
||||||
seasonIsFinalized={seasonIsFinalized}
|
seasonIsFinalized={seasonIsFinalized}
|
||||||
qpStandings={qpStandings as any}
|
qpStandings={qpStandings as any}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
|
||||||
sportsCount,
|
sportsCount,
|
||||||
teamsWithOwners,
|
teamsWithOwners,
|
||||||
isDraftOrderSet,
|
isDraftOrderSet,
|
||||||
|
draftSlots,
|
||||||
sportsSeasons,
|
sportsSeasons,
|
||||||
standings,
|
standings,
|
||||||
origin,
|
origin,
|
||||||
|
|
@ -273,6 +274,58 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Draft Order card - shown when order is set, pre_draft/draft */}
|
||||||
|
{season && isDraftOrPreDraft && draftSlots.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Draft Order</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{season.status === "pre_draft"
|
||||||
|
? "Upcoming draft order"
|
||||||
|
: "Current draft order"}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button asChild size="sm">
|
||||||
|
<Link to={`/leagues/${league.id}/draft/${season.id}`}>
|
||||||
|
Enter Draft Room
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{draftSlots.map((slot, index) => {
|
||||||
|
const ownerName = slot.team.ownerId
|
||||||
|
? ownerMap[slot.team.ownerId]
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={slot.id}
|
||||||
|
className="flex items-center gap-3 py-2 border-b last:border-0"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center w-7 h-7 rounded-full bg-primary text-primary-foreground font-bold text-xs">
|
||||||
|
{index + 1}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium text-sm truncate">
|
||||||
|
{slot.team.name}
|
||||||
|
</p>
|
||||||
|
{ownerName && (
|
||||||
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
|
{ownerName}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Teams card - pre_draft/draft only; replaced by standings panel post-draft */}
|
{/* Teams card - pre_draft/draft only; replaced by standings panel post-draft */}
|
||||||
{season && isDraftOrPreDraft && (
|
{season && isDraftOrPreDraft && (
|
||||||
<Card>
|
<Card>
|
||||||
|
|
|
||||||
70
app/routes/leagues/__tests__/draft-order-visibility.test.ts
Normal file
70
app/routes/leagues/__tests__/draft-order-visibility.test.ts
Normal file
|
|
@ -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 && <DraftOrderCard />}
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue