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:
Chris Parsons 2026-03-09 22:44:33 -07:00 committed by GitHub
parent c6ba59b0e6
commit d88be08deb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 849 additions and 287 deletions

View file

@ -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<string, TeamOwnership>();
teamOwnerships.forEach((o) => ownershipMap.set(o.participantId, o));
const getTeamAvatar = (teamName: string) => {
const initial = teamName.charAt(0).toUpperCase();
const hash = teamName.split("").reduce((acc, char) => acc + char.charCodeAt(0), 0);
const colors = [
"bg-blue-500",
"bg-green-500",
"bg-purple-500",
"bg-pink-500",
"bg-amber-500",
"bg-red-500",
"bg-indigo-500",
"bg-teal-500",
];
const colorClass = colors[hash % colors.length];
return (
const getTeamAvatar = (teamName: string) => (
<div
className={`h-6 w-6 rounded-full ${colorClass} text-white text-xs font-bold flex items-center justify-center`}
className={`h-6 w-6 rounded-full ${getAvatarColor(teamName)} text-white text-xs font-bold flex items-center justify-center`}
>
{initial}
{teamName.charAt(0).toUpperCase()}
</div>
);
};
const totalTeams = groups.reduce((sum, g) => sum + g.members.length, 0);
const eliminatedCount = groups.reduce(

View file

@ -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,164 +27,171 @@ 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<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
*
* 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<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({
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<string, TeamOwnership>();
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 once; reused for feeder map and rendering
const matchesByRound = groupMatchesByRound(matches);
const feederMap = buildFeederMap(matchesByRound, rounds);
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}`;
};
// Build elimination rankings: collect losers per round, then assign rank labels
const losersByRound = new Map<string, Array<{ participant: Participant; score: string | null; ownership: TeamOwnership | null }>>();
let hasScore = false;
let bracketWinner: Participant | null = null;
const lastRound = rounds[rounds.length - 1];
const finalMatch = lastRound
? (matchesByRound.get(lastRound) || []).find((m) => m.matchNumber === 1)
: null;
if (finalMatch?.winner) bracketWinner = finalMatch.winner;
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,
});
}
// Group matches by round
const matchesByRound = rounds.reduce((acc, round) => {
acc[round] = matches.filter((m) => m.round === round);
return acc;
}, {} as Record<string, Match[]>);
// 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;
}
// Get ownership info for a participant
const getOwnership = (participantId: string | null): TeamOwnership | null => {
if (!participantId || !showOwnership) return null;
return ownershipMap.get(participantId) || null;
};
const showRankings = rankedEntries.length > 0 || bracketWinner !== null || preEliminatedParticipants.length > 0;
// 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];
// 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 (
<div
className={`h-6 w-6 rounded-full ${colorClass} text-white text-xs font-bold flex items-center justify-center`}
>
{initial}
</div>
);
};
// 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";
return (
<div className="flex items-center justify-between gap-2 min-w-0">
<div className="flex items-center gap-2 min-w-0 flex-1">
{isWinner && (
<Trophy className="h-4 w-4 text-yellow-500 shrink-0" />
)}
<span
className={`truncate ${
isWinner ? "font-bold" : "font-medium"
} ${participantName === "TBD" ? "text-muted-foreground italic" : ""}`}
>
{participantName}
</span>
</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 className="space-y-6">
<div>
<h2 className="text-xl font-semibold">{title}</h2>
{description && (
<p className="text-sm text-muted-foreground mt-1">{description}</p>
)}
</div>
</div>
);
};
// 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;
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5" />
{title}
</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>
<CardContent>
{matches.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<p className="text-sm">No bracket matches available yet.</p>
@ -185,85 +200,309 @@ export function PlayoffBracket({
</p>
</div>
) : (
<div className="space-y-6">
<div className="space-y-8">
{rounds.map((round) => {
const roundMatches = matchesByRound[round] || [];
const roundMatches = matchesByRound.get(round) || [];
if (roundMatches.length === 0) return null;
return (
<div key={round} className="space-y-3">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-lg">{round}</h3>
<Badge variant="outline" className="text-xs">
{roundMatches.length} match{roundMatches.length !== 1 ? "es" : ""}
</Badge>
<div key={round}>
<div className="flex items-center gap-2 mb-3">
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{round}
</h3>
<div className="flex-1 border-t border-border" />
<span className="text-xs text-muted-foreground">
{roundMatches.length}{" "}
{roundMatches.length === 1 ? "match" : "matches"}
</span>
</div>
<div className="grid gap-3">
{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;
<div className="grid md:grid-cols-2 gap-2">
{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 (
<Card
<div
key={match.id}
className={`${
isComplete
? "bg-muted/30"
: "border-primary/20"
}`}
className={`rounded-lg border bg-card px-3 py-2 ${matchHasOwned ? "border-electric/50" : "border-border"}`}
>
<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}
{/* 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>
{isComplete && (
{match.isComplete && (
<Badge variant="secondary" className="text-xs">
Complete
Done
</Badge>
)}
</div>
<div className="space-y-2">
{renderParticipant(
match.participant1,
match.participant1Id,
participant1IsWinner,
match.participant1Score
{/* 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>
<div className="flex items-center">
<div className="flex-1 border-t border-muted"></div>
<span className="px-2 text-xs text-muted-foreground">
{/* 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>
<div className="flex-1 border-t border-muted"></div>
<span className="text-xs tabular-nums font-medium">
{match.participant2Score
? parseFloat(match.participant2Score)
: "—"}
</span>
</>
) : (
<span className="text-xs text-muted-foreground">vs</span>
)}
</div>
{renderParticipant(
match.participant2,
match.participant2Id,
participant2IsWinner,
match.participant2Score
{/* 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>
</CardContent>
</Card>
{!p2IsTbd && p2Ownership && (
<div className="mt-1">
<TeamOwnerBadge
teamName={p2Ownership.teamName}
ownerName={p2Ownership.ownerName}
/>
</div>
)}
</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>
);
}

View file

@ -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 (
<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
const getMovementIndicator = (
currentPosition: number,
@ -271,15 +247,10 @@ export function SeasonStandings({
{showOwnership && (
<TableCell className="pl-6">
{ownership ? (
<div className="flex items-center gap-2">
{getTeamAvatar(ownership.teamName)}
<div className="min-w-0">
<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>
<TeamOwnerBadge
teamName={ownership.teamName}
ownerName={ownership.ownerName}
/>
) : (
<span className="text-xs text-muted-foreground">-</span>
)}

View file

@ -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({
<PlayoffBracket
matches={playoffMatches}
rounds={playoffRounds}
preEliminatedParticipants={preEliminatedParticipants}
participantPoints={participantPoints}
teamOwnerships={teamOwnerships}
userParticipantIds={userParticipantIds}
showOwnership={showOwnership}
title={`${sportName} ${sportSeasonName}`}
description="Playoff bracket"
title="Playoff Bracket"
/>
);
@ -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"
/>
);

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

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

View file

@ -152,6 +152,7 @@ export async function loader(args: Route.LoaderArgs) {
sportsCount,
teamsWithOwners,
isDraftOrderSet,
draftSlots,
sportsSeasons,
standings,
origin,

View file

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

View file

@ -47,6 +47,8 @@ export default function SportSeasonDetail({
scoringPattern,
playoffMatches,
playoffRounds,
preEliminatedParticipants,
participantPoints,
seasonStandings,
qpStandings,
teamOwnerships,
@ -79,8 +81,9 @@ export default function SportSeasonDetail({
</div>
</div>
{/* 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) && (
<div className="mb-6">
<EventSchedule
upcomingEvents={upcomingEvents as any}
@ -95,6 +98,8 @@ export default function SportSeasonDetail({
sportName={sportsSeason.sport.name}
playoffMatches={playoffMatches as any}
playoffRounds={playoffRounds}
preEliminatedParticipants={preEliminatedParticipants}
participantPoints={participantPoints}
seasonStandings={seasonStandings as any}
seasonIsFinalized={seasonIsFinalized}
qpStandings={qpStandings as any}

View file

@ -30,6 +30,7 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
sportsCount,
teamsWithOwners,
isDraftOrderSet,
draftSlots,
sportsSeasons,
standings,
origin,
@ -273,6 +274,58 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
</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 */}
{season && isDraftOrPreDraft && (
<Card>

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