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";
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
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,
|
||||
teamsWithOwners,
|
||||
isDraftOrderSet,
|
||||
draftSlots,
|
||||
sportsSeasons,
|
||||
standings,
|
||||
origin,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
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