brackt/app/components/scoring/PlayoffBracket.tsx
2026-04-20 12:20:51 -07:00

764 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState } from "react";
import { Badge } from "~/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table";
import { Button } from "~/components/ui/button";
import { Trophy, Star, Columns2, LayoutList } from "lucide-react";
import { TeamOwnerBadge } from "~/components/ui/team-owner-badge";
import {
BracketTreeView,
BracketTreePaginated,
type BracketMatch,
type BracketOwnership,
} from "./BracketTreeView";
interface Participant {
id: string;
name: string;
}
export interface Match {
id: string;
round: string;
matchNumber: number;
participant1Id: string | null;
participant2Id: string | null;
winnerId: string | null;
loserId: string | null;
isComplete: boolean;
participant1Score: string | null;
participant2Score: string | null;
isScoring?: boolean;
participant1?: Participant | null;
participant2?: Participant | null;
winner?: Participant | null;
loser?: Participant | null;
}
export interface TeamOwnership {
participantId: string;
teamName: string;
teamId: string;
ownerName?: string;
}
interface PlayoffBracketProps {
matches: Match[];
rounds: string[]; // Ordered list of round names (earliest first)
preEliminatedParticipants?: { id: string; name: string }[];
participantPoints?: { participantId: string; points: number }[];
partialScoreParticipantIds?: string[];
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;
}
/**
* 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;
}
/**
* For each complete match, determine which losers should be shown as eliminated
* in that round. In double-chance brackets (e.g. AFL), a participant who lost
* round X but then won a later round Y is NOT eliminated at round X — only their
* final loss counts.
*
* Returns a map of round name → array of eliminated participant IDs.
* Exported for unit testing.
*/
export function computeEliminatedByRound(
matches: Array<{
round: string;
isComplete: boolean;
winnerId: string | null;
loser?: { id: string; name: string } | null;
participant1Id?: string | null;
participant2Id?: string | null;
}>,
rounds: string[]
): Map<string, string[]> {
const roundIndex = new Map(rounds.map((r, i) => [r, i]));
// Track the latest round (by index) each participant has APPEARED IN.
// We track winnerId/loserId for completed matches AND participant1Id/participant2Id
// for ALL matches (even incomplete ones). This correctly handles:
// - AFL double-chance: a QF loser who appears in a later Semi-Finals match
// - NBA play-in: a 7v8 loser placed into a PIR2 slot (match not yet complete)
// If a participant appears in a later round slot, they are not yet eliminated.
const participantLatestRound = new Map<string, number>();
const updateLatest = (id: string, ri: number) => {
const prev = participantLatestRound.get(id) ?? -1;
if (ri > prev) participantLatestRound.set(id, ri);
};
for (const match of matches) {
const ri = roundIndex.get(match.round) ?? -1;
if (match.winnerId) updateLatest(match.winnerId, ri);
if (match.loser?.id) updateLatest(match.loser.id, ri);
if (match.participant1Id) updateLatest(match.participant1Id, ri);
if (match.participant2Id) updateLatest(match.participant2Id, ri);
}
const result = new Map<string, string[]>();
for (const match of matches) {
if (!match.isComplete || !match.loser) continue;
const lossRoundIndex = roundIndex.get(match.round) ?? -1;
const loserLatestAppear = participantLatestRound.get(match.loser.id) ?? -1;
// Double-chance survivor: participant appeared in a LATER round — not yet eliminated here.
if (loserLatestAppear > lossRoundIndex) continue;
if (!result.has(match.round)) result.set(match.round, []);
result.get(match.round)?.push(match.loser.id);
}
return result;
}
/** Returns true if every adjacent round pair has exactly ceil(prev/2) matches — pure single-elimination. */
function isSingleElimination(matchesByRound: Map<string, Match[]>, rounds: string[]): boolean {
if (rounds.length < 2) return true;
for (let i = 1; i < rounds.length; i++) {
const prev = matchesByRound.get(rounds[i - 1])?.length ?? 0;
const curr = matchesByRound.get(rounds[i])?.length ?? 0;
if (curr !== Math.ceil(prev / 2)) return false;
}
return true;
}
/** Find the index of the first round that has scoring matches. */
function firstScoringRoundIdx(matchesByRound: Map<string, Match[]>, rounds: string[]): number {
for (let i = 0; i < rounds.length; i++) {
if (matchesByRound.get(rounds[i])?.some((m) => m.isScoring)) return i;
}
return 0;
}
export function PlayoffBracket({
matches,
rounds,
preEliminatedParticipants = [],
participantPoints = [],
partialScoreParticipantIds: _partialScoreParticipantIds = [],
teamOwnerships = [],
userParticipantIds = [],
showOwnership = true,
title = "Playoff Bracket",
description,
}: PlayoffBracketProps) {
const userParticipantSet = new Set(userParticipantIds);
const ownershipMap = new Map<string, TeamOwnership>();
teamOwnerships.forEach((o) => ownershipMap.set(o.participantId, o));
const pointsMap = new Map(participantPoints.map((p) => [p.participantId, p.points]));
const matchesByRound = groupMatchesByRound(matches);
const feederMap = buildFeederMap(matchesByRound, rounds);
// Determine if the bracket supports the visual tree view
const supportsTreeView = matches.length > 0 && isSingleElimination(matchesByRound, rounds);
const scoringRoundIdx = firstScoringRoundIdx(matchesByRound, rounds);
const [viewMode, setViewMode] = useState<"tree" | "cards">("tree");
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
const losersByRound = new Map<string, Array<{ participant: Participant; score: string | null; ownership: TeamOwnership | null }>>();
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;
const eliminatedByRound = computeEliminatedByRound(matches, rounds);
for (const match of matches) {
if (!match.isComplete || !match.loser) continue;
const eliminatedInRound = eliminatedByRound.get(match.round);
if (!eliminatedInRound?.includes(match.loser.id)) continue;
const loserScore =
match.loserId === match.participant1Id
? match.participant1Score
: match.participant2Score;
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,
});
}
const allBracketParticipantIds = new Set<string>();
for (const match of matches) {
if (match.participant1Id) allBracketParticipantIds.add(match.participant1Id);
if (match.participant2Id) allBracketParticipantIds.add(match.participant2Id);
}
const rankedEntries: EliminatedEntry[] = [];
let nextRank = 2;
for (let ri = rounds.length - 1; ri >= 0; ri--) {
const roundName = rounds[ri];
const roundLosers = losersByRound.get(roundName) || [];
const totalMatchesInRound = matchesByRound.get(roundName)?.length ?? 0;
if (roundLosers.length > 0) {
const rankLabel = `T${nextRank}`;
for (const loser of roundLosers) {
rankedEntries.push({ ...loser, rankLabel });
}
}
nextRank += totalMatchesInRound;
}
const rankedParticipantIds = new Set(rankedEntries.map((e) => e.participant.id));
if (bracketWinner) rankedParticipantIds.add(bracketWinner.id);
const filteredPreEliminated = preEliminatedParticipants.filter(
(p) => !rankedParticipantIds.has(p.id)
);
const showRankings = rankedEntries.length > 0 || bracketWinner !== null || filteredPreEliminated.length > 0;
const participantMap = new Map<string, Participant>();
for (const match of matches) {
if (match.participant1) participantMap.set(match.participant1.id, match.participant1);
if (match.participant2) participantMap.set(match.participant2.id, match.participant2);
}
const activeParticipants = [...allBracketParticipantIds]
.filter((id) => !rankedParticipantIds.has(id))
.map((id) => participantMap.get(id))
.filter((p): p is Participant => p !== undefined);
const isDraftedOrScoring = (participantId: string) =>
ownershipMap.has(participantId) || (pointsMap.get(participantId) ?? 0) > 0;
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="space-y-6">
{/* Header */}
<div className="flex items-center justify-between gap-4">
<div>
<h2 className="text-xl font-semibold">{title}</h2>
{description && (
<p className="text-sm text-muted-foreground mt-1">{description}</p>
)}
</div>
{/* View toggle — desktop only, when tree view is supported */}
{supportsTreeView && (
<div className="hidden md:flex items-center gap-1 rounded-md border border-border p-0.5">
<Button
variant="ghost"
size="sm"
className={`h-7 px-2 ${viewMode === "tree" ? "bg-muted" : ""}`}
onClick={() => setViewMode("tree")}
title="Bracket tree"
>
<Columns2 className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className={`h-7 px-2 ${viewMode === "cards" ? "bg-muted" : ""}`}
onClick={() => setViewMode("cards")}
title="Card list"
>
<LayoutList className="h-3.5 w-3.5" />
</Button>
</div>
)}
</div>
{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-8">
{/* ── Desktop: visual tree or card list ── */}
{supportsTreeView && viewMode === "tree" ? (
<div className="hidden md:block">
<BracketTreeView
rounds={rounds}
matchesByRound={matchesByRound as Map<string, BracketMatch[]>}
ownershipMap={ownershipMap as Map<string, BracketOwnership>}
userParticipantIds={userParticipantSet}
/>
</div>
) : (
<div className="hidden md:block">
<CardListView
rounds={rounds}
matchesByRound={matchesByRound}
ownershipMap={ownershipMap}
userParticipantSet={userParticipantSet}
showOwnership={showOwnership}
getTbdLabel={getTbdLabel}
/>
</div>
)}
{/* ── Mobile: paginated tree or card list ── */}
{supportsTreeView ? (
<div className="md:hidden">
<BracketTreePaginated
rounds={rounds}
matchesByRound={matchesByRound as Map<string, BracketMatch[]>}
ownershipMap={ownershipMap as Map<string, BracketOwnership>}
userParticipantIds={userParticipantSet}
firstScoringRoundIdx={scoringRoundIdx}
/>
</div>
) : (
<div className="md:hidden">
<CardListView
rounds={rounds}
matchesByRound={matchesByRound}
ownershipMap={ownershipMap}
userParticipantSet={userParticipantSet}
showOwnership={showOwnership}
getTbdLabel={getTbdLabel}
/>
</div>
)}
{/* ── In Contention ── */}
{activeParticipants.length > 0 && (
<Card className="border-green-500/30">
<CardHeader>
<CardTitle className="text-green-600 dark:text-green-400 flex items-center gap-2">
<Star className="h-5 w-5" />
In Contention
</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<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>
{activeParticipants.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" : ""}
>
<TableCell className={isOwned ? "text-electric font-medium" : ""}>
{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 font-mono text-sm">
{pts !== undefined ? pts : ""}
</TableCell>
)}
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
)}
{/* ── 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.filter((entry) => isDraftedOrScoring(entry.participant.id)).map((entry) => {
const isOwned = userParticipantSet.has(entry.participant.id);
const pts = pointsMap.get(entry.participant.id);
return (
<TableRow
key={entry.participant.id}
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>
);
})}
{filteredPreEliminated.filter((p) => isDraftedOrScoring(p.id)).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>
);
}
// ─── Card list view (existing style, used as fallback) ───────────────────────
interface CardListViewProps {
rounds: string[];
matchesByRound: Map<string, Match[]>;
ownershipMap: Map<string, TeamOwnership>;
userParticipantSet: Set<string>;
showOwnership: boolean;
getTbdLabel: (round: string, matchNumber: number, slot: "p1" | "p2") => string;
}
function CardListView({
rounds,
matchesByRound,
ownershipMap,
userParticipantSet,
showOwnership,
getTbdLabel,
}: CardListViewProps) {
return (
<div className="space-y-8">
{rounds.map((round) => {
const roundMatches = matchesByRound.get(round) || [];
if (roundMatches.length === 0) return null;
return (
<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 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;
// Corona: gradient for complete, electric for user's pick, subtle for pending
const coronaStyle: React.CSSProperties = match.isComplete
? { background: "linear-gradient(to bottom, #adf661, #2ce1c1)" }
: matchHasOwned
? { background: "rgba(44, 225, 193, 0.4)" }
: { background: "rgba(255, 255, 255, 0.07)" };
return (
<div key={match.id} className="relative overflow-hidden rounded-lg">
{/* Corona layer — outer overflow-hidden handles all corner rounding */}
<div className="absolute top-0.5 bottom-0.5 left-0 right-0" style={coronaStyle} />
{/* Inner card — only mr-1 to expose corona on right */}
<div className="relative z-10 bg-muted rounded-l-none rounded-r-[5px] px-3 py-2 mr-1">
<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>
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2">
{/* Participant 1 */}
<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>
{/* Scores */}
<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 */}
<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} align="right" />
</div>
)}
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
);
}