- NCAAM R64/R32 winners no longer receive T5 floor points; only Sweet Sixteen winners entering the first scoring round get a floor - AFL T5-T6 and T7-T8 score as separate tiers (avg([5,6]) and avg([7,8])) instead of one shared avg([5-8]) pool - Eliminated teams now always show their correct final rank (e.g. T33 for NCAAM R64 losers) even during partial round scoring, by deriving the rank label from the total match count per round rather than the dynamic still-alive count - Discord notifications now fire when a drafted participant is eliminated with 0 points (e.g. R64 losers whose team score doesn't change) - Discord notifications no longer include all prior matches — scoped to the current batch via matchIds - Discord section headers have spacing; rank medals replaced with numbers - Rounds auto-complete when all matches are marked done; manual Complete Round dropdown removed - Fixed double Discord notification when the last match in a round triggers auto-complete: processPlayoffEvent now accepts skipRecalculate so the caller controls when the notification fires - Fixed falsy check on finalPosition=0 in calculateTeamScore - Undrafted 0-point participants filtered from Eliminated Teams display Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
657 lines
28 KiB
TypeScript
657 lines
28 KiB
TypeScript
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 { Trophy, Star } from "lucide-react";
|
||
import { TeamOwnerBadge } from "~/components/ui/team-owner-badge";
|
||
|
||
interface Participant {
|
||
id: string;
|
||
name: string;
|
||
}
|
||
|
||
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;
|
||
participant1?: Participant | null;
|
||
participant2?: Participant | null;
|
||
winner?: Participant | null;
|
||
loser?: Participant | null;
|
||
}
|
||
|
||
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 }[]; // Eliminated before bracket (e.g. group stage)
|
||
participantPoints?: { participantId: string; points: number }[]; // Computed fantasy points per participant
|
||
partialScoreParticipantIds?: string[]; // Still-competing participants with provisional floor scores
|
||
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;
|
||
}>,
|
||
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 (as winner OR loser).
|
||
// Tracking appearances — not just wins — correctly handles AFL double-chance brackets,
|
||
// where a Qualifying Finals loser advances to Semi-Finals automatically (no intermediate
|
||
// win required). If a participant appears in a later round, 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);
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
export function PlayoffBracket({
|
||
matches,
|
||
rounds,
|
||
preEliminatedParticipants = [],
|
||
participantPoints = [],
|
||
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]));
|
||
|
||
// 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.
|
||
// In double-chance brackets (e.g. AFL), a participant may lose one round but
|
||
// win a later one — only count them as eliminated at their FINAL losing match.
|
||
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;
|
||
|
||
// Compute which participants are eliminated in which round, handling double-chance.
|
||
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 (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,
|
||
});
|
||
}
|
||
|
||
// Walk rounds latest→earliest to assign rank labels.
|
||
// Use TOTAL match count per round (not eliminated-so-far) so that partial scoring
|
||
// shows the correct eventual rank. E.g. R64 losers in NCAAM 68 always show T33
|
||
// even while other R64 games are still pending.
|
||
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; // rank 1 = champion (even if not yet decided)
|
||
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;
|
||
}
|
||
|
||
// Exclude pre-eliminated participants already ranked via bracket match losers
|
||
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;
|
||
|
||
// Build participant lookup from match data for the "In Contention" table
|
||
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);
|
||
|
||
// Hide participants with 0 points that nobody drafted — they add noise without value.
|
||
const isDraftedOrScoring = (participantId: string) =>
|
||
ownershipMap.has(participantId) || (pointsMap.get(participantId) ?? 0) > 0;
|
||
|
||
// Hoist winner row lookups so we don't need an IIFE in JSX
|
||
const winnerIsOwned = bracketWinner ? userParticipantSet.has(bracketWinner.id) : false;
|
||
const winnerOwnership = bracketWinner ? ownershipMap.get(bracketWinner.id) : undefined;
|
||
const winnerPts = bracketWinner ? pointsMap.get(bracketWinner.id) : undefined;
|
||
|
||
return (
|
||
<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>
|
||
|
||
{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">
|
||
{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;
|
||
|
||
return (
|
||
<div
|
||
key={match.id}
|
||
className={`rounded-lg border bg-card px-3 py-2 ${matchHasOwned ? "border-electric/50" : "border-border"}`}
|
||
>
|
||
{/* Match label row */}
|
||
<div className="flex items-center justify-between mb-2">
|
||
<span className="text-xs font-mono text-muted-foreground">
|
||
M{match.matchNumber}
|
||
</span>
|
||
{match.isComplete && (
|
||
<Badge variant="secondary" className="text-xs">
|
||
Done
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
|
||
{/* Participants: left vs right (stacks on mobile) */}
|
||
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2">
|
||
{/* Participant 1 — left-aligned */}
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-1">
|
||
{p1IsOwned && !p1IsWinner && (
|
||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-electric shrink-0" />
|
||
)}
|
||
{p1IsWinner && (
|
||
<Trophy className="h-3.5 w-3.5 text-yellow-500 shrink-0" />
|
||
)}
|
||
<span
|
||
className={[
|
||
"text-sm font-medium truncate",
|
||
p1IsLoser ? "text-muted-foreground line-through" : "",
|
||
p1IsTbd ? "text-muted-foreground italic font-normal" : "",
|
||
p1IsWinner ? "font-semibold" : "",
|
||
p1IsOwned && !p1IsLoser ? "text-electric" : "",
|
||
]
|
||
.filter(Boolean)
|
||
.join(" ")}
|
||
>
|
||
{p1Name}
|
||
</span>
|
||
</div>
|
||
{!p1IsTbd && p1Ownership && (
|
||
<div className="mt-1">
|
||
<TeamOwnerBadge
|
||
teamName={p1Ownership.teamName}
|
||
ownerName={p1Ownership.ownerName}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Center: scores + vs */}
|
||
<div className="shrink-0 flex sm:flex-col items-center gap-1 sm:gap-0.5 min-w-[2.5rem]">
|
||
{match.participant1Score ? (
|
||
<>
|
||
<span className="text-xs tabular-nums font-medium">
|
||
{parseFloat(match.participant1Score)}
|
||
</span>
|
||
<span className="text-[10px] text-muted-foreground/60 uppercase tracking-wider">
|
||
vs
|
||
</span>
|
||
<span className="text-xs tabular-nums font-medium">
|
||
{match.participant2Score
|
||
? parseFloat(match.participant2Score)
|
||
: "—"}
|
||
</span>
|
||
</>
|
||
) : (
|
||
<span className="text-xs text-muted-foreground">vs</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* Participant 2 — right-aligned on sm+, left-aligned on mobile */}
|
||
<div className="flex-1 min-w-0 flex flex-col sm:items-end">
|
||
<div className="flex items-center gap-1 sm:justify-end">
|
||
<span
|
||
className={[
|
||
"text-sm font-medium truncate",
|
||
p2IsLoser ? "text-muted-foreground line-through" : "",
|
||
p2IsTbd ? "text-muted-foreground italic font-normal" : "",
|
||
p2IsWinner ? "font-semibold" : "",
|
||
p2IsOwned && !p2IsLoser ? "text-electric" : "",
|
||
]
|
||
.filter(Boolean)
|
||
.join(" ")}
|
||
>
|
||
{p2Name}
|
||
</span>
|
||
{p2IsWinner && (
|
||
<Trophy className="h-3.5 w-3.5 text-yellow-500 shrink-0" />
|
||
)}
|
||
{p2IsOwned && !p2IsWinner && (
|
||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-electric shrink-0" />
|
||
)}
|
||
</div>
|
||
{!p2IsTbd && p2Ownership && (
|
||
<div className="mt-1">
|
||
<TeamOwnerBadge
|
||
teamName={p2Ownership.teamName}
|
||
ownerName={p2Ownership.ownerName}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
{/* 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 / Eliminated Teams */}
|
||
{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, 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>
|
||
);
|
||
})}
|
||
|
||
{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>
|
||
);
|
||
}
|