When a participant wins a bracket round, they immediately earn provisional "floor" points (the averaged minimum they'd receive if eliminated next round). These update as they advance and are replaced by finalized scores on elimination. Key changes: - Add `is_partial_score` column to `participant_results` (migration 0038) - `processPlayoffEvent`: assign provisional position 5 to non-scoring round winners; assign round-appropriate floors to scoring round winners via `getGuaranteedMinimumPosition`; add catch-all for unrecognized round names - `upsertParticipantResult`: guard against un-finalizing rows (never overwrite isPartialScore=false with true) - `calculateBracketPoints`: new function averaging tied bracket tiers (5-8 → 20 pts, 3-4 → avg, 1-2 solo); used in `calculateTeamScore` for playoff_bracket pattern (pattern-aware, doesn't affect F1/golf scoring) - `PlayoffBracket`: "In Contention" table for still-active participants; AFL double-chance fix (participants who won a later match excluded from earlier round's loser list); correct `nextRank` starting position - Server loader: batch owner DB queries (one query vs N+1); deduplicate participantPoints; use calculateBracketPoints for bracket point display - Clean up Phase/Q-number tracking comments throughout scoring-calculator.ts - 3 new tests for non-scoring round provisional floor behavior Also includes a dev admin bypass via DEV_ADMIN_CLERK_ID env var (separate change on this branch, not part of floor scoring feature). Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
610 lines
26 KiB
TypeScript
610 lines
26 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;
|
||
}
|
||
|
||
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;
|
||
|
||
// Build the set of all participants who won at least one completed match.
|
||
// If a participant won any match, their earlier loss was not their final elimination.
|
||
const participantWinIds = new Set<string>();
|
||
for (const match of matches) {
|
||
if (match.isComplete && match.winnerId) participantWinIds.add(match.winnerId);
|
||
}
|
||
|
||
for (const match of matches) {
|
||
if (!match.isComplete || !match.loser) continue;
|
||
// Skip if this loser went on to win another match (double-chance bracket)
|
||
if (participantWinIds.has(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 (no mutation)
|
||
// nextRank must start after the still-alive participants (not always 2)
|
||
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 totalEliminatedInBracket = [...losersByRound.values()].reduce(
|
||
(sum, losers) => sum + losers.length,
|
||
0
|
||
);
|
||
const stillAlive =
|
||
allBracketParticipantIds.size - totalEliminatedInBracket - (bracketWinner ? 1 : 0);
|
||
const rankedEntries: EliminatedEntry[] = [];
|
||
let nextRank = stillAlive + (bracketWinner ? 2 : 1);
|
||
for (let ri = rounds.length - 1; ri >= 0; ri--) {
|
||
const roundLosers = losersByRound.get(rounds[ri]) || [];
|
||
if (roundLosers.length === 0) continue;
|
||
const rankLabel = `T${nextRank}`;
|
||
for (const loser of roundLosers) {
|
||
rankedEntries.push({ ...loser, rankLabel });
|
||
}
|
||
nextRank += roundLosers.length;
|
||
}
|
||
|
||
// 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);
|
||
|
||
// 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.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.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>
|
||
);
|
||
}
|