All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m8s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m18s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
🚀 Deploy / 🧪 Test (push) Successful in 3m28s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m22s
🚀 Deploy / 🐳 Build (push) Successful in 1m22s
🚀 Deploy / 🚀 Deploy (push) Successful in 12s
The Third Place Game winner kept showing under "In Contention" on the FIFA World Cup bracket even after the game was scored. The scoring side was already correct — TEMPLATE_ROUND_CONFIG.fifa_48 finalizes the 3PG winner at finalPosition 3 with isPartialScore false, so they got their 50 points. The bug was in PlayoffBracket, which re-derives rankings from match records rather than participant results and only ever ranks losers. The 3PG winner lost the semifinal but was correctly excluded there by the double-chance guard, then won the 3PG — so they were never a loser anywhere, never ranked, and fell through into the active list. Fixing that also fixes the rank labels, which double-counted positions 3-4: the 3PG loser was labelled T3 rather than 4, and quarterfinal losers T6 rather than T5. Rankings are now built by computeRankedEntries, which resolves a consolation round at its feeder round: the winner takes the top of the tier the feeder's losers would otherwise share and the loser the next position, with the consolation round itself consuming none. Positions are derived rather than hardcoded to 3/4, so a consolation round hanging off a different feeder lands correctly too. Only matches that can be placed exactly are withheld from the loser-driven walk, so an incomplete or partially hydrated consolation match falls back to the old path instead of being dropped. loserFeedsInto is set on exactly one round in the templates (fifa_48 Semifinals), so every other bracket keeps its existing behavior. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
609 lines
24 KiB
TypeScript
609 lines
24 KiB
TypeScript
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";
|
||
import { GradientIcon } from "~/components/ui/GradientIcon";
|
||
import { RankingsRow } from "./RankingsRow";
|
||
import { BracketTreeView, type BracketMatch, type BracketOwnership } from "./BracketTreeView";
|
||
import { BracketTreePaginated } from "./BracketTreePaginated";
|
||
import { getBracketTemplate, type BracketTemplate } from "~/lib/bracket-templates";
|
||
import { NbaBracketLayout } from "./NbaBracketLayout";
|
||
import { TabbedBracketLayout } from "./TabbedBracketLayout";
|
||
|
||
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)
|
||
bracketTemplateId?: string | null;
|
||
preEliminatedParticipants?: { id: string; name: string }[];
|
||
participantPoints?: { participantId: string; points: number }[];
|
||
partialScoreParticipantIds?: string[];
|
||
teamOwnerships?: TeamOwnership[];
|
||
userParticipantIds?: string[];
|
||
showOwnership?: boolean;
|
||
title?: string;
|
||
description?: string;
|
||
/** "bracket" = full bracket + tables (default); "rankings" = final rankings table only */
|
||
mode?: "bracket" | "rankings";
|
||
}
|
||
|
||
/** 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;
|
||
}
|
||
|
||
/** The score recorded for one participant in a match, or null if they didn't play in it. */
|
||
function participantScore(match: Match, participantId: string | null): string | null {
|
||
if (!participantId) return null;
|
||
if (participantId === match.participant1Id) return match.participant1Score;
|
||
if (participantId === match.participant2Id) return match.participant2Score;
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* A consolation final: a round contested by the losers of an earlier round, which
|
||
* splits the positions those losers would otherwise share. FIFA's "Third Place Game"
|
||
* (fed by the Semifinals) is the only one in the templates today.
|
||
*/
|
||
export interface ConsolationRound {
|
||
/** The consolation round itself, e.g. "Third Place Game". */
|
||
round: string;
|
||
/** The round whose losers contest it, e.g. "Semifinals". */
|
||
feederRound: string;
|
||
}
|
||
|
||
/**
|
||
* Find the template's consolation round, if it has one.
|
||
* Exported for unit testing.
|
||
*/
|
||
export function findConsolationRound(
|
||
template: BracketTemplate | undefined
|
||
): ConsolationRound | undefined {
|
||
const feeder = template?.rounds.find((r) => r.loserFeedsInto);
|
||
if (!feeder?.loserFeedsInto) return undefined;
|
||
return { round: feeder.loserFeedsInto, feederRound: feeder.name };
|
||
}
|
||
|
||
/**
|
||
* Build the ordered final-rankings list from completed matches.
|
||
*
|
||
* Ranks are derived by walking rounds latest-first: the final's loser is 2nd, the
|
||
* previous round's losers share the next tier, and so on — each round consuming as
|
||
* many positions as it has matches.
|
||
*
|
||
* A consolation round needs different handling, because its winner never loses a
|
||
* match and so the loser-driven walk above would leave them unranked and "in
|
||
* contention" forever. Its two places are exactly the top of the tier its feeder
|
||
* round's losers would otherwise share, so it is resolved *at the feeder round* —
|
||
* the winner takes that tier's first position and the loser the second — and the
|
||
* consolation round itself consumes no positions. Positions are derived rather than
|
||
* hardcoded, so a consolation round hanging off a different feeder still lands right.
|
||
*
|
||
* Exported for unit testing.
|
||
*/
|
||
export function computeRankedEntries(
|
||
matches: Match[],
|
||
rounds: string[],
|
||
matchesByRound: Map<string, Match[]>,
|
||
consolation: ConsolationRound | undefined,
|
||
ownershipMap: Map<string, TeamOwnership>
|
||
): EliminatedEntry[] {
|
||
const eliminatedByRound = computeEliminatedByRound(matches, rounds);
|
||
|
||
// Only take the consolation path when both rounds actually have matches; otherwise
|
||
// fall through to the loser-driven walk so nothing is dropped.
|
||
const consolationActive =
|
||
consolation &&
|
||
rounds.includes(consolation.round) &&
|
||
rounds.includes(consolation.feederRound);
|
||
|
||
// Consolation matches we can place exactly. Anything else in that round (still in
|
||
// progress, or missing its hydrated winner/loser) deliberately stays eligible for
|
||
// the loser-driven walk rather than being silently dropped.
|
||
const consolationMatches =
|
||
consolationActive && consolation
|
||
? (matchesByRound.get(consolation.round) ?? []).filter(
|
||
(m): m is Match & { winner: Participant; loser: Participant } =>
|
||
m.isComplete && !!m.winner && !!m.loser
|
||
)
|
||
: [];
|
||
const exactlyPlacedMatchIds = new Set(consolationMatches.map((m) => m.id));
|
||
|
||
const entryFor = (
|
||
match: Match,
|
||
participant: Participant,
|
||
participantId: string | null
|
||
): Omit<EliminatedEntry, "rankLabel"> => ({
|
||
participant,
|
||
score: participantScore(match, participantId),
|
||
ownership: ownershipMap.get(participant.id) || null,
|
||
});
|
||
|
||
const losersByRound = new Map<string, Omit<EliminatedEntry, "rankLabel">[]>();
|
||
for (const match of matches) {
|
||
if (!match.isComplete || !match.loser) continue;
|
||
if (exactlyPlacedMatchIds.has(match.id)) continue;
|
||
if (!eliminatedByRound.get(match.round)?.includes(match.loser.id)) continue;
|
||
if (!losersByRound.has(match.round)) losersByRound.set(match.round, []);
|
||
losersByRound.get(match.round)?.push(entryFor(match, match.loser, match.loserId));
|
||
}
|
||
|
||
const rankedEntries: EliminatedEntry[] = [];
|
||
let nextRank = 2;
|
||
for (let ri = rounds.length - 1; ri >= 0; ri--) {
|
||
const roundName = rounds[ri];
|
||
|
||
// The consolation match splits the top of its feeder round's tier, so it is
|
||
// placed first and the round's remaining losers share what's left below it.
|
||
let tierRank = nextRank;
|
||
if (consolationActive && roundName === consolation?.feederRound) {
|
||
for (const match of consolationMatches) {
|
||
rankedEntries.push({
|
||
...entryFor(match, match.winner, match.winnerId),
|
||
rankLabel: `${tierRank}`,
|
||
});
|
||
rankedEntries.push({
|
||
...entryFor(match, match.loser, match.loserId),
|
||
rankLabel: `${tierRank + 1}`,
|
||
});
|
||
tierRank += 2;
|
||
}
|
||
}
|
||
|
||
for (const loser of losersByRound.get(roundName) ?? []) {
|
||
rankedEntries.push({ ...loser, rankLabel: `T${tierRank}` });
|
||
}
|
||
|
||
// The consolation round's places belong to its feeder round's tier, so it
|
||
// consumes none of its own.
|
||
if (consolationActive && roundName === consolation?.round) continue;
|
||
|
||
nextRank += matchesByRound.get(roundName)?.length ?? 0;
|
||
}
|
||
|
||
return rankedEntries;
|
||
}
|
||
|
||
/** 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;
|
||
}
|
||
|
||
const RANKING_ROW_CLASSES: Record<number, string> = {
|
||
1: "bg-yellow-500/10",
|
||
2: "bg-white/[0.14]",
|
||
3: "bg-orange-600/[0.08]",
|
||
};
|
||
|
||
function rankingRowCls(currentRank: number | undefined): string {
|
||
const podium = currentRank !== undefined ? RANKING_ROW_CLASSES[currentRank] : undefined;
|
||
return `flex flex-col sm:flex-row sm:items-center gap-0 rounded-lg px-3 py-3 sm:px-5 sm:py-4 transition-colors ${podium ?? "bg-white/[0.04]"}`;
|
||
}
|
||
|
||
export function PlayoffBracket({
|
||
matches,
|
||
rounds,
|
||
bracketTemplateId = null,
|
||
preEliminatedParticipants = [],
|
||
participantPoints = [],
|
||
partialScoreParticipantIds: _partialScoreParticipantIds = [],
|
||
teamOwnerships = [],
|
||
userParticipantIds = [],
|
||
showOwnership = true,
|
||
title = "Playoff Bracket",
|
||
description,
|
||
mode = "bracket",
|
||
}: 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 scoringRoundIdx = firstScoringRoundIdx(matchesByRound, rounds);
|
||
const template = bracketTemplateId ? getBracketTemplate(bracketTemplateId) : undefined;
|
||
|
||
const consolation = findConsolationRound(template);
|
||
const thirdPlaceRound = consolation?.round;
|
||
|
||
// Build elimination rankings
|
||
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 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 = computeRankedEntries(
|
||
matches,
|
||
rounds,
|
||
matchesByRound,
|
||
consolation,
|
||
ownershipMap
|
||
);
|
||
|
||
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)
|
||
// Owned-by-a-manager players first, then alphabetical by name.
|
||
.toSorted((a, b) => {
|
||
const aOwned = ownershipMap.has(a.id);
|
||
const bOwned = ownershipMap.has(b.id);
|
||
if (aOwned !== bOwned) return aOwned ? -1 : 1;
|
||
return a.name.localeCompare(b.name);
|
||
});
|
||
|
||
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>
|
||
<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">
|
||
{mode === "bracket" && (template?.phases ? (
|
||
<TabbedBracketLayout
|
||
rounds={rounds}
|
||
matchesByRound={matchesByRound as Map<string, BracketMatch[]>}
|
||
ownershipMap={ownershipMap as Map<string, BracketOwnership>}
|
||
userParticipantIds={userParticipantSet}
|
||
phases={template.phases}
|
||
scoringRoundIdx={scoringRoundIdx}
|
||
/>
|
||
) : template?.conferenceGroups ? (
|
||
<NbaBracketLayout
|
||
matches={matches}
|
||
rounds={rounds}
|
||
matchesByRound={matchesByRound as Map<string, BracketMatch[]>}
|
||
ownershipMap={ownershipMap as Map<string, BracketOwnership>}
|
||
userParticipantIds={userParticipantSet}
|
||
conferenceGroups={template.conferenceGroups}
|
||
scoringRoundIdx={scoringRoundIdx}
|
||
/>
|
||
) : (
|
||
<>
|
||
{/* ── Desktop ── */}
|
||
<div className="hidden md:block">
|
||
<BracketTreeView
|
||
rounds={rounds}
|
||
matchesByRound={matchesByRound as Map<string, BracketMatch[]>}
|
||
ownershipMap={ownershipMap as Map<string, BracketOwnership>}
|
||
userParticipantIds={userParticipantSet}
|
||
thirdPlaceRound={thirdPlaceRound}
|
||
/>
|
||
</div>
|
||
|
||
{/* ── Mobile ── */}
|
||
<div className="md:hidden">
|
||
<BracketTreePaginated
|
||
rounds={rounds}
|
||
matchesByRound={matchesByRound as Map<string, BracketMatch[]>}
|
||
ownershipMap={ownershipMap as Map<string, BracketOwnership>}
|
||
userParticipantIds={userParticipantSet}
|
||
firstScoringRoundIdx={scoringRoundIdx}
|
||
thirdPlaceRound={thirdPlaceRound}
|
||
/>
|
||
</div>
|
||
</>
|
||
))}
|
||
|
||
{/* ── In Contention ── */}
|
||
{mode === "bracket" && 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 ── */}
|
||
{mode === "rankings" && showRankings && (
|
||
<Card className="gap-2">
|
||
<CardHeader className="px-3 sm:px-6 pb-2">
|
||
<div className="flex items-center gap-2">
|
||
<GradientIcon icon={Trophy} className="h-5 w-5 shrink-0" />
|
||
<h2 className="text-xl font-bold leading-none tracking-tight">
|
||
{bracketWinner ? "Final Rankings" : "Eliminated Teams"}
|
||
</h2>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="px-3 sm:px-6">
|
||
<div className="space-y-3">
|
||
{bracketWinner && (
|
||
<RankingsRow
|
||
teamId={winnerOwnership?.teamId ?? bracketWinner.id}
|
||
teamName={winnerOwnership?.teamName ?? bracketWinner.name}
|
||
participantName={bracketWinner.name}
|
||
ownerName={winnerOwnership?.ownerName}
|
||
isOwned={winnerIsOwned}
|
||
rankLabel={1}
|
||
points={winnerPts}
|
||
showPoints={pointsMap.size > 0}
|
||
rowClass={rankingRowCls(1)}
|
||
/>
|
||
)}
|
||
|
||
{rankedEntries.filter((entry) => isDraftedOrScoring(entry.participant.id)).map((entry) => {
|
||
const isOwned = userParticipantSet.has(entry.participant.id);
|
||
const pts = pointsMap.get(entry.participant.id);
|
||
const numRank = parseInt(entry.rankLabel.replace("T", ""), 10);
|
||
return (
|
||
<RankingsRow
|
||
key={entry.participant.id}
|
||
teamId={entry.ownership?.teamId ?? entry.participant.id}
|
||
teamName={entry.ownership?.teamName ?? entry.participant.name}
|
||
participantName={entry.participant.name}
|
||
ownerName={entry.ownership?.ownerName}
|
||
isOwned={isOwned}
|
||
rankLabel={entry.rankLabel}
|
||
points={pts}
|
||
showPoints={pointsMap.size > 0}
|
||
rowClass={rankingRowCls(numRank)}
|
||
/>
|
||
);
|
||
})}
|
||
|
||
{filteredPreEliminated.filter((p) => isDraftedOrScoring(p.id)).map((p) => {
|
||
const isOwned = userParticipantSet.has(p.id);
|
||
const ownership = ownershipMap.get(p.id);
|
||
const pts = pointsMap.get(p.id);
|
||
return (
|
||
<RankingsRow
|
||
key={p.id}
|
||
teamId={ownership?.teamId ?? p.id}
|
||
teamName={ownership?.teamName ?? p.name}
|
||
participantName={p.name}
|
||
ownerName={ownership?.ownerName}
|
||
isOwned={isOwned}
|
||
points={pts}
|
||
showPoints={pointsMap.size > 0}
|
||
rowClass={rankingRowCls(undefined)}
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|