Finish bracket page.
This commit is contained in:
parent
4d3b214ce5
commit
1309ab2ece
7 changed files with 540 additions and 381 deletions
|
|
@ -91,24 +91,35 @@ function ParticipantRow({
|
||||||
>
|
>
|
||||||
{showText && (
|
{showText && (
|
||||||
<div className={[
|
<div className={[
|
||||||
"flex items-center flex-1 min-w-0 gap-1 px-2 pl-[7px] ml-2",
|
"flex items-center flex-1 min-w-0 gap-1.5 px-2 pl-[7px] ml-2",
|
||||||
|
isWinner && isOwned ? "border border-electric/50 bg-electric/8 rounded-md mr-2 my-0.5 self-stretch py-1" :
|
||||||
isWinner ? "border border-white/15 bg-white/5 rounded-md mr-2 my-0.5 self-stretch py-1" : "",
|
isWinner ? "border border-white/15 bg-white/5 rounded-md mr-2 my-0.5 self-stretch py-1" : "",
|
||||||
].filter(Boolean).join(" ")}>
|
].filter(Boolean).join(" ")}>
|
||||||
{/* Electric dot for user's pick (when not winner) */}
|
{/* Manager avatar — always present for alignment; empty box when unowned */}
|
||||||
{isOwned && !isWinner && showText && (
|
<div
|
||||||
<span
|
className="shrink-0 rounded-[3px] flex items-center justify-center text-[8px] font-bold"
|
||||||
className="shrink-0 rounded-full"
|
style={{
|
||||||
style={{ width: 5, height: 5, background: "#2ce1c1" }}
|
width: 18,
|
||||||
/>
|
height: 18,
|
||||||
)}
|
background: ownership ? "#000" : "transparent",
|
||||||
|
color: ownership ? avatarColor(ownership.teamName) : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ownership &&
|
||||||
|
ownership.teamName
|
||||||
|
.split(/\s+/)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((w) => w[0]?.toUpperCase() ?? "")
|
||||||
|
.join("")}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<span
|
<span
|
||||||
className={[
|
className={[
|
||||||
"text-[13px] leading-tight block truncate",
|
"text-[13px] leading-tight block truncate",
|
||||||
isTbd ? "text-muted-foreground/50 italic" : "",
|
isTbd ? "text-muted-foreground/50 italic" : "",
|
||||||
|
isLoser && isOwned ? "text-electric/50 line-through" :
|
||||||
isLoser ? "text-muted-foreground line-through" : "",
|
isLoser ? "text-muted-foreground line-through" : "",
|
||||||
isOwned && !isLoser ? "text-electric font-medium" : "",
|
|
||||||
isWinner ? "font-semibold" : "",
|
isWinner ? "font-semibold" : "",
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
|
|
@ -117,28 +128,11 @@ function ParticipantRow({
|
||||||
{name ?? "TBD"}
|
{name ?? "TBD"}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Owner info row */}
|
{/* Owner name below participant name */}
|
||||||
{showOwner && !isTbd && ownership && (
|
{showOwner && !isTbd && ownership && (
|
||||||
<div className="flex items-center gap-1 mt-0.5">
|
<span className="text-[10px] text-muted-foreground truncate block leading-none">
|
||||||
<div
|
{ownership.ownerName ?? ownership.teamName}
|
||||||
className="shrink-0 rounded-[2px] flex items-center justify-center text-[8px] font-bold"
|
</span>
|
||||||
style={{
|
|
||||||
width: 14,
|
|
||||||
height: 14,
|
|
||||||
background: "#000",
|
|
||||||
color: avatarColor(ownership.teamName),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{ownership.teamName
|
|
||||||
.split(/\s+/)
|
|
||||||
.slice(0, 2)
|
|
||||||
.map((w) => w[0]?.toUpperCase() ?? "")
|
|
||||||
.join("")}
|
|
||||||
</div>
|
|
||||||
<span className="text-[10px] text-muted-foreground truncate">
|
|
||||||
{ownership.ownerName ?? ownership.teamName}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -169,7 +163,7 @@ interface BracketMatchSlotProps {
|
||||||
userParticipantIds: Set<string>;
|
userParticipantIds: Set<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function BracketMatchSlot({
|
export function BracketMatchSlot({
|
||||||
match,
|
match,
|
||||||
slotHeight,
|
slotHeight,
|
||||||
ownershipMap,
|
ownershipMap,
|
||||||
|
|
@ -229,7 +223,7 @@ function BracketMatchSlot({
|
||||||
isWinner={p1IsWinner}
|
isWinner={p1IsWinner}
|
||||||
isLoser={p1IsLoser}
|
isLoser={p1IsLoser}
|
||||||
isOwned={p1IsOwned}
|
isOwned={p1IsOwned}
|
||||||
ownership={showOwner ? p1Ownership : null}
|
ownership={p1Ownership}
|
||||||
score={match.participant1Score}
|
score={match.participant1Score}
|
||||||
rowHeight={rowHeight - INSET}
|
rowHeight={rowHeight - INSET}
|
||||||
showScore={showScore}
|
showScore={showScore}
|
||||||
|
|
@ -242,7 +236,7 @@ function BracketMatchSlot({
|
||||||
isWinner={p2IsWinner}
|
isWinner={p2IsWinner}
|
||||||
isLoser={p2IsLoser}
|
isLoser={p2IsLoser}
|
||||||
isOwned={p2IsOwned}
|
isOwned={p2IsOwned}
|
||||||
ownership={showOwner ? p2Ownership : null}
|
ownership={p2Ownership}
|
||||||
score={match.participant2Score}
|
score={match.participant2Score}
|
||||||
rowHeight={rowHeight - INSET}
|
rowHeight={rowHeight - INSET}
|
||||||
showScore={showScore}
|
showScore={showScore}
|
||||||
|
|
@ -318,7 +312,7 @@ function ConnectorColumn({ currentMatches, nextMatches, bracketHeight }: Connect
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{paths.map((d) => (
|
{paths.map((d) => (
|
||||||
<path key={d} d={d} fill="none" stroke="hsl(var(--border))" strokeWidth={1} />
|
<path key={d} d={d} fill="none" stroke="rgb(255 255 255 / 22%)" strokeWidth={1.5} />
|
||||||
))}
|
))}
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -412,6 +406,7 @@ interface BracketTreeViewProps {
|
||||||
matchesByRound: Map<string, BracketMatch[]>;
|
matchesByRound: Map<string, BracketMatch[]>;
|
||||||
ownershipMap: Map<string, BracketOwnership>;
|
ownershipMap: Map<string, BracketOwnership>;
|
||||||
userParticipantIds: Set<string>;
|
userParticipantIds: Set<string>;
|
||||||
|
thirdPlaceRound?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BracketTreeView({
|
export function BracketTreeView({
|
||||||
|
|
@ -419,10 +414,14 @@ export function BracketTreeView({
|
||||||
matchesByRound,
|
matchesByRound,
|
||||||
ownershipMap,
|
ownershipMap,
|
||||||
userParticipantIds,
|
userParticipantIds,
|
||||||
|
thirdPlaceRound,
|
||||||
}: BracketTreeViewProps) {
|
}: BracketTreeViewProps) {
|
||||||
const maxMatches = Math.max(...rounds.map((r) => matchesByRound.get(r)?.length ?? 0), 1);
|
const mainRounds = thirdPlaceRound ? rounds.filter((r) => r !== thirdPlaceRound) : rounds;
|
||||||
|
const thirdPlaceMatch = thirdPlaceRound ? (matchesByRound.get(thirdPlaceRound) ?? [])[0] : undefined;
|
||||||
|
|
||||||
|
const maxMatches = Math.max(...mainRounds.map((r) => matchesByRound.get(r)?.length ?? 0), 1);
|
||||||
const bracketHeight = maxMatches * (DESIRED_CARD_HEIGHT + CARD_GAP);
|
const bracketHeight = maxMatches * (DESIRED_CARD_HEIGHT + CARD_GAP);
|
||||||
const minWidth = rounds.length * COLUMN_WIDTH + Math.max(0, rounds.length - 1) * CONNECTOR_WIDTH;
|
const minWidth = mainRounds.length * COLUMN_WIDTH + Math.max(0, mainRounds.length - 1) * CONNECTOR_WIDTH;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -431,12 +430,33 @@ export function BracketTreeView({
|
||||||
>
|
>
|
||||||
<div style={{ minWidth }}>
|
<div style={{ minWidth }}>
|
||||||
<TreeColumns
|
<TreeColumns
|
||||||
visibleRounds={rounds}
|
visibleRounds={mainRounds}
|
||||||
matchesByRound={matchesByRound}
|
matchesByRound={matchesByRound}
|
||||||
ownershipMap={ownershipMap}
|
ownershipMap={ownershipMap}
|
||||||
userParticipantIds={userParticipantIds}
|
userParticipantIds={userParticipantIds}
|
||||||
bracketHeight={bracketHeight}
|
bracketHeight={bracketHeight}
|
||||||
/>
|
/>
|
||||||
|
{thirdPlaceMatch && (
|
||||||
|
<div style={{ display: "flex", paddingTop: 20 }}>
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
<div style={{ minWidth: COLUMN_WIDTH }}>
|
||||||
|
<div
|
||||||
|
className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground text-center"
|
||||||
|
style={{ height: LABEL_HEIGHT, display: "flex", alignItems: "center", justifyContent: "center" }}
|
||||||
|
>
|
||||||
|
3rd Place
|
||||||
|
</div>
|
||||||
|
<div style={{ height: Math.min(DESIRED_CARD_HEIGHT, MAX_CARD_HEIGHT) }}>
|
||||||
|
<BracketMatchSlot
|
||||||
|
match={thirdPlaceMatch}
|
||||||
|
slotHeight={Math.min(DESIRED_CARD_HEIGHT, MAX_CARD_HEIGHT)}
|
||||||
|
ownershipMap={ownershipMap}
|
||||||
|
userParticipantIds={userParticipantIds}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -451,6 +471,7 @@ interface BracketTreePaginatedProps {
|
||||||
userParticipantIds: Set<string>;
|
userParticipantIds: Set<string>;
|
||||||
/** Index of the first scoring round — default page starts here */
|
/** Index of the first scoring round — default page starts here */
|
||||||
firstScoringRoundIdx?: number;
|
firstScoringRoundIdx?: number;
|
||||||
|
thirdPlaceRound?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AnimState {
|
interface AnimState {
|
||||||
|
|
@ -466,14 +487,18 @@ export function BracketTreePaginated({
|
||||||
ownershipMap,
|
ownershipMap,
|
||||||
userParticipantIds,
|
userParticipantIds,
|
||||||
firstScoringRoundIdx,
|
firstScoringRoundIdx,
|
||||||
|
thirdPlaceRound,
|
||||||
}: BracketTreePaginatedProps) {
|
}: BracketTreePaginatedProps) {
|
||||||
|
const mainRounds = thirdPlaceRound ? rounds.filter((r) => r !== thirdPlaceRound) : rounds;
|
||||||
|
const thirdPlaceMatch = thirdPlaceRound ? (matchesByRound.get(thirdPlaceRound) ?? [])[0] : undefined;
|
||||||
|
|
||||||
const defaultPage = Math.max(
|
const defaultPage = Math.max(
|
||||||
0,
|
0,
|
||||||
Math.min(
|
Math.min(
|
||||||
firstScoringRoundIdx !== undefined
|
firstScoringRoundIdx !== undefined
|
||||||
? Math.max(0, firstScoringRoundIdx - 1)
|
? Math.max(0, firstScoringRoundIdx - 1)
|
||||||
: rounds.length - 2,
|
: mainRounds.length - 2,
|
||||||
rounds.length - 2,
|
mainRounds.length - 2,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const [page, setPage] = useState(defaultPage);
|
const [page, setPage] = useState(defaultPage);
|
||||||
|
|
@ -500,7 +525,7 @@ export function BracketTreePaginated({
|
||||||
}, [anim]);
|
}, [anim]);
|
||||||
|
|
||||||
const navigate = (newPage: number) => {
|
const navigate = (newPage: number) => {
|
||||||
if (anim || newPage < 0 || newPage > rounds.length - 2) return;
|
if (anim || newPage < 0 || newPage > mainRounds.length - 2) return;
|
||||||
setAnim({ fromPage: page, toPage: newPage, dir: newPage > page ? "right" : "left", phase: "sliding" });
|
setAnim({ fromPage: page, toPage: newPage, dir: newPage > page ? "right" : "left", phase: "sliding" });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -515,11 +540,11 @@ export function BracketTreePaginated({
|
||||||
};
|
};
|
||||||
|
|
||||||
const targetPage = anim ? anim.toPage : page;
|
const targetPage = anim ? anim.toPage : page;
|
||||||
const labelRounds = rounds.slice(targetPage, targetPage + 2);
|
const labelRounds = mainRounds.slice(targetPage, targetPage + 2);
|
||||||
const label = labelRounds[1] ? `${labelRounds[0]} → ${labelRounds[1]}` : labelRounds[0];
|
const label = labelRounds[1] ? `${labelRounds[0]} → ${labelRounds[1]}` : labelRounds[0];
|
||||||
|
|
||||||
const calcHeight = (p: number) => {
|
const calcHeight = (p: number) => {
|
||||||
const rs = rounds.slice(p, p + 2);
|
const rs = mainRounds.slice(p, p + 2);
|
||||||
const max = Math.max(...rs.map((r) => matchesByRound.get(r)?.length ?? 0), 1);
|
const max = Math.max(...rs.map((r) => matchesByRound.get(r)?.length ?? 0), 1);
|
||||||
return max * (DESIRED_CARD_HEIGHT + CARD_GAP);
|
return max * (DESIRED_CARD_HEIGHT + CARD_GAP);
|
||||||
};
|
};
|
||||||
|
|
@ -528,9 +553,9 @@ export function BracketTreePaginated({
|
||||||
const animFromHeight = anim ? calcHeight(anim.fromPage) : pageHeight;
|
const animFromHeight = anim ? calcHeight(anim.fromPage) : pageHeight;
|
||||||
const animToHeight = anim ? calcHeight(anim.toPage) : pageHeight;
|
const animToHeight = anim ? calcHeight(anim.toPage) : pageHeight;
|
||||||
|
|
||||||
const visibleRounds = rounds.slice(page, page + 2);
|
const visibleRounds = mainRounds.slice(page, page + 2);
|
||||||
const fromRounds = anim ? rounds.slice(anim.fromPage, anim.fromPage + 2) : visibleRounds;
|
const fromRounds = anim ? mainRounds.slice(anim.fromPage, anim.fromPage + 2) : visibleRounds;
|
||||||
const toRounds = anim ? rounds.slice(anim.toPage, anim.toPage + 2) : visibleRounds;
|
const toRounds = anim ? mainRounds.slice(anim.toPage, anim.toPage + 2) : visibleRounds;
|
||||||
|
|
||||||
// Sliding: show from/to slots side-by-side at their respective heights, no transitions
|
// Sliding: show from/to slots side-by-side at their respective heights, no transitions
|
||||||
// Settling: left slot shows toRounds at animToHeight with CSS transitions so cards animate smoothly
|
// Settling: left slot shows toRounds at animToHeight with CSS transitions so cards animate smoothly
|
||||||
|
|
@ -578,7 +603,7 @@ export function BracketTreePaginated({
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => navigate(page + 1)}
|
onClick={() => navigate(page + 1)}
|
||||||
disabled={page + 2 >= rounds.length || !!anim}
|
disabled={page + 2 >= mainRounds.length || !!anim}
|
||||||
className="h-7 w-7 shrink-0"
|
className="h-7 w-7 shrink-0"
|
||||||
aria-label="Next rounds"
|
aria-label="Next rounds"
|
||||||
>
|
>
|
||||||
|
|
@ -619,6 +644,23 @@ export function BracketTreePaginated({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{thirdPlaceMatch && (
|
||||||
|
<div style={{ marginTop: 20, width: SLOT_WIDTH }}>
|
||||||
|
<div
|
||||||
|
className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground text-center"
|
||||||
|
style={{ height: LABEL_HEIGHT, display: "flex", alignItems: "center", justifyContent: "center" }}
|
||||||
|
>
|
||||||
|
3rd Place
|
||||||
|
</div>
|
||||||
|
<BracketMatchSlot
|
||||||
|
match={thirdPlaceMatch}
|
||||||
|
slotHeight={Math.min(DESIRED_CARD_HEIGHT, MAX_CARD_HEIGHT)}
|
||||||
|
ownershipMap={ownershipMap}
|
||||||
|
userParticipantIds={userParticipantIds}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ import {
|
||||||
} from "~/components/ui/table";
|
} from "~/components/ui/table";
|
||||||
import { Trophy, Star } from "lucide-react";
|
import { Trophy, Star } from "lucide-react";
|
||||||
import { TeamOwnerBadge } from "~/components/ui/team-owner-badge";
|
import { TeamOwnerBadge } from "~/components/ui/team-owner-badge";
|
||||||
|
import { GradientIcon } from "~/components/ui/GradientIcon";
|
||||||
|
import { TeamAvatar } from "~/components/TeamAvatar";
|
||||||
import {
|
import {
|
||||||
BracketTreeView,
|
BracketTreeView,
|
||||||
BracketTreePaginated,
|
BracketTreePaginated,
|
||||||
|
|
@ -61,6 +63,8 @@ interface PlayoffBracketProps {
|
||||||
showOwnership?: boolean;
|
showOwnership?: boolean;
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: 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. */
|
/** Group matches by round, sorted by matchNumber, in one pass. */
|
||||||
|
|
@ -182,6 +186,17 @@ function firstScoringRoundIdx(matchesByRound: Map<string, Match[]>, rounds: stri
|
||||||
return 0;
|
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({
|
export function PlayoffBracket({
|
||||||
matches,
|
matches,
|
||||||
rounds,
|
rounds,
|
||||||
|
|
@ -194,6 +209,7 @@ export function PlayoffBracket({
|
||||||
showOwnership = true,
|
showOwnership = true,
|
||||||
title = "Playoff Bracket",
|
title = "Playoff Bracket",
|
||||||
description,
|
description,
|
||||||
|
mode = "bracket",
|
||||||
}: PlayoffBracketProps) {
|
}: PlayoffBracketProps) {
|
||||||
const userParticipantSet = new Set(userParticipantIds);
|
const userParticipantSet = new Set(userParticipantIds);
|
||||||
const ownershipMap = new Map<string, TeamOwnership>();
|
const ownershipMap = new Map<string, TeamOwnership>();
|
||||||
|
|
@ -204,6 +220,10 @@ export function PlayoffBracket({
|
||||||
const scoringRoundIdx = firstScoringRoundIdx(matchesByRound, rounds);
|
const scoringRoundIdx = firstScoringRoundIdx(matchesByRound, rounds);
|
||||||
const template = bracketTemplateId ? getBracketTemplate(bracketTemplateId) : undefined;
|
const template = bracketTemplateId ? getBracketTemplate(bracketTemplateId) : undefined;
|
||||||
|
|
||||||
|
const thirdPlaceRound = template?.rounds
|
||||||
|
.find((r) => template.rounds.some((other) => other.loserFeedsInto === r.name))
|
||||||
|
?.name;
|
||||||
|
|
||||||
// Build elimination rankings
|
// Build elimination rankings
|
||||||
const losersByRound = new Map<string, Array<{ participant: Participant; score: string | null; ownership: TeamOwnership | null }>>();
|
const losersByRound = new Map<string, Array<{ participant: Participant; score: string | null; ownership: TeamOwnership | null }>>();
|
||||||
let bracketWinner: Participant | null = null;
|
let bracketWinner: Participant | null = null;
|
||||||
|
|
@ -296,7 +316,7 @@ export function PlayoffBracket({
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{template?.phases ? (
|
{mode === "bracket" && (template?.phases ? (
|
||||||
<TabbedBracketLayout
|
<TabbedBracketLayout
|
||||||
rounds={rounds}
|
rounds={rounds}
|
||||||
matchesByRound={matchesByRound as Map<string, BracketMatch[]>}
|
matchesByRound={matchesByRound as Map<string, BracketMatch[]>}
|
||||||
|
|
@ -324,6 +344,7 @@ export function PlayoffBracket({
|
||||||
matchesByRound={matchesByRound as Map<string, BracketMatch[]>}
|
matchesByRound={matchesByRound as Map<string, BracketMatch[]>}
|
||||||
ownershipMap={ownershipMap as Map<string, BracketOwnership>}
|
ownershipMap={ownershipMap as Map<string, BracketOwnership>}
|
||||||
userParticipantIds={userParticipantSet}
|
userParticipantIds={userParticipantSet}
|
||||||
|
thirdPlaceRound={thirdPlaceRound}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -335,13 +356,14 @@ export function PlayoffBracket({
|
||||||
ownershipMap={ownershipMap as Map<string, BracketOwnership>}
|
ownershipMap={ownershipMap as Map<string, BracketOwnership>}
|
||||||
userParticipantIds={userParticipantSet}
|
userParticipantIds={userParticipantSet}
|
||||||
firstScoringRoundIdx={scoringRoundIdx}
|
firstScoringRoundIdx={scoringRoundIdx}
|
||||||
|
thirdPlaceRound={thirdPlaceRound}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
))}
|
||||||
|
|
||||||
{/* ── In Contention ── */}
|
{/* ── In Contention ── */}
|
||||||
{activeParticipants.length > 0 && (
|
{mode === "bracket" && activeParticipants.length > 0 && (
|
||||||
<Card className="border-green-500/30">
|
<Card className="border-green-500/30">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-green-600 dark:text-green-400 flex items-center gap-2">
|
<CardTitle className="text-green-600 dark:text-green-400 flex items-center gap-2">
|
||||||
|
|
@ -402,133 +424,126 @@ export function PlayoffBracket({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Final Rankings ── */}
|
{/* ── Final Rankings ── */}
|
||||||
{showRankings && (
|
{mode === "rankings" && showRankings && (
|
||||||
<Card className="border-electric/30">
|
<Card className="gap-2">
|
||||||
<CardHeader>
|
<CardHeader className="px-3 sm:px-6 pb-2">
|
||||||
<CardTitle className="text-electric flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Trophy className="h-5 w-5" />
|
<GradientIcon icon={Trophy} className="h-5 w-5 shrink-0" />
|
||||||
{bracketWinner ? "Final Rankings" : "Eliminated Teams"}
|
<h2 className="text-xl font-bold leading-none tracking-tight">
|
||||||
</CardTitle>
|
{bracketWinner ? "Final Rankings" : "Eliminated Teams"}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="px-3 sm:px-6">
|
||||||
<Table>
|
<div className="space-y-3">
|
||||||
<TableHeader>
|
{bracketWinner && (
|
||||||
<TableRow>
|
<div className={rankingRowCls(1)}>
|
||||||
<TableHead className="w-20">Rank</TableHead>
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
<TableHead>Participant</TableHead>
|
<TeamAvatar
|
||||||
{showOwnership && (
|
teamId={winnerOwnership?.teamId ?? bracketWinner.id}
|
||||||
<TableHead className="w-40 pl-6">Drafted By</TableHead>
|
teamName={winnerOwnership?.teamName ?? bracketWinner.name}
|
||||||
)}
|
/>
|
||||||
{pointsMap.size > 0 && (
|
<div className="min-w-0">
|
||||||
<TableHead className="text-right w-16">Pts</TableHead>
|
<p className={`font-medium text-sm leading-tight truncate${winnerIsOwned ? " text-electric" : ""}`}>
|
||||||
)}
|
{bracketWinner.name}
|
||||||
</TableRow>
|
{winnerIsOwned && <Star className="inline ml-1.5 h-3 w-3 fill-current text-electric" />}
|
||||||
</TableHeader>
|
</p>
|
||||||
<TableBody>
|
{winnerOwnership?.ownerName && (
|
||||||
{bracketWinner && (
|
<p className="text-xs text-muted-foreground truncate">{winnerOwnership.ownerName}</p>
|
||||||
<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>
|
</div>
|
||||||
{showOwnership && (
|
</div>
|
||||||
<TableCell className="pl-6">
|
<div className="flex items-center gap-4 w-full border-t border-border/30 pt-2 mt-1 sm:w-auto sm:border-0 sm:pt-0 sm:mt-0 sm:shrink-0">
|
||||||
{winnerOwnership ? (
|
<div className="text-right shrink-0 flex-1 sm:flex-none">
|
||||||
<TeamOwnerBadge teamName={winnerOwnership.teamName} ownerName={winnerOwnership.ownerName} />
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Rank</p>
|
||||||
) : (
|
<span className="text-2xl font-bold leading-none">1</span>
|
||||||
<span className="text-xs text-muted-foreground">-</span>
|
</div>
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
)}
|
|
||||||
{pointsMap.size > 0 && (
|
{pointsMap.size > 0 && (
|
||||||
<TableCell className="text-right font-semibold text-electric">
|
<>
|
||||||
{winnerPts ?? "—"}
|
<div className="h-8 w-px bg-border shrink-0 self-center" />
|
||||||
</TableCell>
|
<div className="text-right shrink-0 flex-1 sm:flex-none">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Points</p>
|
||||||
|
<span className="text-2xl font-bold leading-none text-electric">{winnerPts ?? 0}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</TableRow>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{rankedEntries.filter((entry) => isDraftedOrScoring(entry.participant.id)).map((entry) => {
|
{rankedEntries.filter((entry) => isDraftedOrScoring(entry.participant.id)).map((entry) => {
|
||||||
const isOwned = userParticipantSet.has(entry.participant.id);
|
const isOwned = userParticipantSet.has(entry.participant.id);
|
||||||
const pts = pointsMap.get(entry.participant.id);
|
const pts = pointsMap.get(entry.participant.id);
|
||||||
return (
|
const numRank = parseInt(entry.rankLabel.replace("T", ""), 10);
|
||||||
<TableRow
|
return (
|
||||||
key={entry.participant.id}
|
<div key={entry.participant.id} className={rankingRowCls(numRank)}>
|
||||||
className={isOwned ? "bg-electric/5 border-l-2 border-l-electric opacity-80" : "opacity-60"}
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
>
|
<TeamAvatar
|
||||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
teamId={entry.ownership?.teamId ?? entry.participant.id}
|
||||||
{entry.rankLabel}
|
teamName={entry.ownership?.teamName ?? entry.participant.name}
|
||||||
</TableCell>
|
/>
|
||||||
<TableCell className={isOwned ? "text-electric font-medium" : "text-muted-foreground"}>
|
<div className="min-w-0">
|
||||||
{entry.participant.name}
|
<p className={`font-medium text-sm leading-tight truncate${isOwned ? " text-electric" : ""}`}>
|
||||||
{isOwned && (
|
{entry.participant.name}
|
||||||
<Star className="inline ml-1.5 h-3 w-3 fill-current text-electric" />
|
{isOwned && <Star className="inline ml-1.5 h-3 w-3 fill-current text-electric" />}
|
||||||
|
</p>
|
||||||
|
{entry.ownership?.ownerName && (
|
||||||
|
<p className="text-xs text-muted-foreground truncate">{entry.ownership.ownerName}</p>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</div>
|
||||||
{showOwnership && (
|
</div>
|
||||||
<TableCell className="pl-6">
|
<div className="flex items-center gap-4 w-full border-t border-border/30 pt-2 mt-1 sm:w-auto sm:border-0 sm:pt-0 sm:mt-0 sm:shrink-0">
|
||||||
{entry.ownership ? (
|
<div className="text-right shrink-0 flex-1 sm:flex-none">
|
||||||
<TeamOwnerBadge
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Rank</p>
|
||||||
teamName={entry.ownership.teamName}
|
<span className="text-2xl font-bold leading-none">{entry.rankLabel}</span>
|
||||||
ownerName={entry.ownership.ownerName}
|
</div>
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-muted-foreground">-</span>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
)}
|
|
||||||
{pointsMap.size > 0 && (
|
{pointsMap.size > 0 && (
|
||||||
<TableCell className="text-right tabular-nums text-muted-foreground">
|
<>
|
||||||
{pts ?? "—"}
|
<div className="h-8 w-px bg-border shrink-0 self-center" />
|
||||||
</TableCell>
|
<div className="text-right shrink-0 flex-1 sm:flex-none">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Points</p>
|
||||||
|
<span className="text-2xl font-bold leading-none text-electric">{pts ?? 0}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</TableRow>
|
</div>
|
||||||
);
|
</div>
|
||||||
})}
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
{filteredPreEliminated.filter((p) => isDraftedOrScoring(p.id)).map((p) => {
|
{filteredPreEliminated.filter((p) => isDraftedOrScoring(p.id)).map((p) => {
|
||||||
const isOwned = userParticipantSet.has(p.id);
|
const isOwned = userParticipantSet.has(p.id);
|
||||||
const ownership = showOwnership ? ownershipMap.get(p.id) || null : null;
|
const ownership = ownershipMap.get(p.id);
|
||||||
const pts = pointsMap.get(p.id);
|
const pts = pointsMap.get(p.id);
|
||||||
return (
|
return (
|
||||||
<TableRow
|
<div key={p.id} className={rankingRowCls(undefined)}>
|
||||||
key={p.id}
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
className={isOwned ? "bg-electric/5 border-l-2 border-l-electric opacity-60" : "opacity-40"}
|
<TeamAvatar
|
||||||
>
|
teamId={ownership?.teamId ?? p.id}
|
||||||
<TableCell className="font-mono text-xs text-muted-foreground">—</TableCell>
|
teamName={ownership?.teamName ?? p.name}
|
||||||
<TableCell className={isOwned ? "text-electric font-medium" : "text-muted-foreground"}>
|
/>
|
||||||
{p.name}
|
<div className="min-w-0">
|
||||||
{isOwned && (
|
<p className={`font-medium text-sm leading-tight truncate${isOwned ? " text-electric" : ""}`}>
|
||||||
<Star className="inline ml-1.5 h-3 w-3 fill-current text-electric" />
|
{p.name}
|
||||||
|
{isOwned && <Star className="inline ml-1.5 h-3 w-3 fill-current text-electric" />}
|
||||||
|
</p>
|
||||||
|
{ownership?.ownerName && (
|
||||||
|
<p className="text-xs text-muted-foreground truncate">{ownership.ownerName}</p>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</div>
|
||||||
{showOwnership && (
|
</div>
|
||||||
<TableCell className="pl-6">
|
{pointsMap.size > 0 && (
|
||||||
{ownership ? (
|
<div className="flex items-center gap-4 w-full border-t border-border/30 pt-2 mt-1 sm:w-auto sm:border-0 sm:pt-0 sm:mt-0 sm:shrink-0">
|
||||||
<TeamOwnerBadge teamName={ownership.teamName} ownerName={ownership.ownerName} />
|
<div className="text-right shrink-0 flex-1 sm:flex-none">
|
||||||
) : (
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Points</p>
|
||||||
<span className="text-xs text-muted-foreground">-</span>
|
<span className="text-2xl font-bold leading-none text-electric">{pts ?? 0}</span>
|
||||||
)}
|
</div>
|
||||||
</TableCell>
|
</div>
|
||||||
)}
|
)}
|
||||||
{pointsMap.size > 0 && (
|
</div>
|
||||||
<TableCell className="text-right tabular-nums text-muted-foreground">
|
);
|
||||||
{pts ?? "—"}
|
})}
|
||||||
</TableCell>
|
</div>
|
||||||
)}
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,7 @@ interface SportSeasonDisplayProps {
|
||||||
scoringPattern: ScoringPattern;
|
scoringPattern: ScoringPattern;
|
||||||
sportSeasonName: string;
|
sportSeasonName: string;
|
||||||
sportName: string;
|
sportName: string;
|
||||||
|
bracketMode?: "bracket" | "rankings";
|
||||||
|
|
||||||
// Playoff data
|
// Playoff data
|
||||||
playoffMatches?: Match[];
|
playoffMatches?: Match[];
|
||||||
|
|
@ -123,6 +124,7 @@ export function SportSeasonDisplay({
|
||||||
scoringPattern,
|
scoringPattern,
|
||||||
sportSeasonName,
|
sportSeasonName,
|
||||||
sportName: _sportName,
|
sportName: _sportName,
|
||||||
|
bracketMode = "bracket",
|
||||||
playoffMatches = [],
|
playoffMatches = [],
|
||||||
playoffRounds = [],
|
playoffRounds = [],
|
||||||
bracketTemplateId = null,
|
bracketTemplateId = null,
|
||||||
|
|
@ -157,6 +159,7 @@ export function SportSeasonDisplay({
|
||||||
userParticipantIds={userParticipantIds}
|
userParticipantIds={userParticipantIds}
|
||||||
showOwnership={showOwnership}
|
showOwnership={showOwnership}
|
||||||
title="Playoff Bracket"
|
title="Playoff Bracket"
|
||||||
|
mode={bracketMode}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { useState } from "react";
|
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
import type { BracketPhase, ConferenceGroup } from "~/lib/bracket-templates";
|
import type { BracketPhase, ConferenceGroup } from "~/lib/bracket-templates";
|
||||||
import {
|
import {
|
||||||
TreeColumns,
|
TreeColumns,
|
||||||
BracketTreePaginated,
|
BracketTreePaginated,
|
||||||
|
BracketMatchSlot,
|
||||||
type BracketMatch,
|
type BracketMatch,
|
||||||
type BracketOwnership,
|
type BracketOwnership,
|
||||||
} from "./BracketTreeView";
|
} from "./BracketTreeView";
|
||||||
|
|
@ -39,6 +39,106 @@ function phaseHeight(matchesByRound: Map<string, BracketMatch[]>, rounds: string
|
||||||
return max * (CARD_H + CARD_GAP);
|
return max * (CARD_H + CARD_GAP);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Play-In Layout ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface PlayInColumnProps {
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
match: BracketMatch | undefined;
|
||||||
|
ownershipMap: Map<string, BracketOwnership>;
|
||||||
|
userParticipantIds: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlayInColumn({
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
match,
|
||||||
|
ownershipMap,
|
||||||
|
userParticipantIds,
|
||||||
|
}: PlayInColumnProps) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground text-center mb-2">
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
{match ? (
|
||||||
|
<BracketMatchSlot
|
||||||
|
match={match}
|
||||||
|
slotHeight={CARD_H}
|
||||||
|
ownershipMap={ownershipMap}
|
||||||
|
userParticipantIds={userParticipantIds}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div style={{ height: CARD_H }} />
|
||||||
|
)}
|
||||||
|
<p className="text-[10px] text-muted-foreground text-center mt-2">{description}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlayInLayoutProps {
|
||||||
|
matchesByRound: Map<string, BracketMatch[]>;
|
||||||
|
ownershipMap: Map<string, BracketOwnership>;
|
||||||
|
userParticipantIds: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlayInLayout({ matchesByRound, ownershipMap, userParticipantIds }: PlayInLayoutProps) {
|
||||||
|
const pir1 = matchesByRound.get("Play-In Round 1") ?? [];
|
||||||
|
const pir2 = matchesByRound.get("Play-In Round 2") ?? [];
|
||||||
|
|
||||||
|
const conferences = [
|
||||||
|
{
|
||||||
|
name: "Eastern Conference",
|
||||||
|
match78: pir1.find((m) => m.matchNumber === 1),
|
||||||
|
match910: pir1.find((m) => m.matchNumber === 2),
|
||||||
|
matchFor8: pir2.find((m) => m.matchNumber === 1),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Western Conference",
|
||||||
|
match78: pir1.find((m) => m.matchNumber === 3),
|
||||||
|
match910: pir1.find((m) => m.matchNumber === 4),
|
||||||
|
matchFor8: pir2.find((m) => m.matchNumber === 2),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{conferences.map((conf) => (
|
||||||
|
<div key={conf.name}>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-3">
|
||||||
|
{conf.name}
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<PlayInColumn
|
||||||
|
label="7 VS 8"
|
||||||
|
description="Winner → 7 seed · Loser plays again"
|
||||||
|
match={conf.match78}
|
||||||
|
ownershipMap={ownershipMap}
|
||||||
|
userParticipantIds={userParticipantIds}
|
||||||
|
/>
|
||||||
|
<PlayInColumn
|
||||||
|
label="9 VS 10"
|
||||||
|
description="Winner plays again · Loser eliminated"
|
||||||
|
match={conf.match910}
|
||||||
|
ownershipMap={ownershipMap}
|
||||||
|
userParticipantIds={userParticipantIds}
|
||||||
|
/>
|
||||||
|
<PlayInColumn
|
||||||
|
label="For 8 Seed"
|
||||||
|
description="Winner → 8 seed · Loser eliminated"
|
||||||
|
match={conf.matchFor8}
|
||||||
|
ownershipMap={ownershipMap}
|
||||||
|
userParticipantIds={userParticipantIds}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main component ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function TabbedBracketLayout({
|
export function TabbedBracketLayout({
|
||||||
rounds,
|
rounds,
|
||||||
matchesByRound,
|
matchesByRound,
|
||||||
|
|
@ -47,94 +147,100 @@ export function TabbedBracketLayout({
|
||||||
phases,
|
phases,
|
||||||
scoringRoundIdx,
|
scoringRoundIdx,
|
||||||
}: TabbedBracketLayoutProps) {
|
}: TabbedBracketLayoutProps) {
|
||||||
const [activeIdx, setActiveIdx] = useState(0);
|
|
||||||
const phase = phases[activeIdx];
|
|
||||||
|
|
||||||
const groupRounds = phase.groups
|
|
||||||
? rounds.filter((r) => phase.groups?.some((g) => g.roundMatchNumbers[r] !== undefined))
|
|
||||||
: [];
|
|
||||||
const sharedRounds = (phase.sharedRounds ?? []).filter((r) => rounds.includes(r));
|
|
||||||
const simpleRounds = phase.groups ? [] : (phase.rounds ?? []).filter((r) => rounds.includes(r));
|
|
||||||
|
|
||||||
const phaseRounds = phase.groups ? [...groupRounds, ...sharedRounds] : simpleRounds;
|
|
||||||
const phaseMatchesByRound = new Map(phaseRounds.map((r) => [r, matchesByRound.get(r) ?? []]));
|
|
||||||
const sharedMatchesByRound = new Map(sharedRounds.map((r) => [r, matchesByRound.get(r) ?? []]));
|
|
||||||
|
|
||||||
const phaseFirstScoringIdx = phaseRounds.findIndex((r) => rounds.indexOf(r) >= scoringRoundIdx);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-10">
|
||||||
<div className="flex gap-1 rounded-lg bg-muted p-1 w-fit">
|
{phases.map((phase) => {
|
||||||
{phases.map((p, i) => (
|
const groupRounds = phase.groups
|
||||||
<button
|
? rounds.filter((r) => phase.groups?.some((g) => g.roundMatchNumbers[r] !== undefined))
|
||||||
key={p.name}
|
: [];
|
||||||
type="button"
|
const sharedRounds = (phase.sharedRounds ?? []).filter((r) => rounds.includes(r));
|
||||||
onClick={() => setActiveIdx(i)}
|
const simpleRounds = phase.groups ? [] : (phase.rounds ?? []).filter((r) => rounds.includes(r));
|
||||||
className={cn(
|
|
||||||
"px-3 py-1.5 text-sm font-medium rounded-md transition-colors",
|
|
||||||
activeIdx === i
|
|
||||||
? "bg-background shadow-sm text-foreground"
|
|
||||||
: "text-muted-foreground hover:text-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{p.name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Desktop */}
|
const phaseRounds = phase.groups ? [...groupRounds, ...sharedRounds] : simpleRounds;
|
||||||
<div className="hidden md:block">
|
const phaseMatchesByRound = new Map(phaseRounds.map((r) => [r, matchesByRound.get(r) ?? []]));
|
||||||
{phase.groups ? (
|
const sharedMatchesByRound = new Map(sharedRounds.map((r) => [r, matchesByRound.get(r) ?? []]));
|
||||||
<div className="space-y-8">
|
|
||||||
{phase.groups.map((group) => {
|
const phaseFirstScoringIdx = phaseRounds.findIndex((r) => rounds.indexOf(r) >= scoringRoundIdx);
|
||||||
const gMatches = groupMatches(matchesByRound, group);
|
|
||||||
const gRounds = groupRounds.filter((r) => group.roundMatchNumbers[r] !== undefined);
|
return (
|
||||||
return (
|
<div key={phase.name}>
|
||||||
<div key={group.name}>
|
<p className={cn(
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2">
|
"text-sm font-semibold uppercase tracking-wider mb-4",
|
||||||
{group.name}
|
phases.length > 1 ? "text-muted-foreground" : "hidden"
|
||||||
</p>
|
)}>
|
||||||
<TreeColumns
|
{phase.name}
|
||||||
visibleRounds={gRounds}
|
</p>
|
||||||
matchesByRound={gMatches}
|
|
||||||
ownershipMap={ownershipMap}
|
{/* Desktop */}
|
||||||
userParticipantIds={userParticipantIds}
|
<div className="hidden md:block">
|
||||||
bracketHeight={phaseHeight(gMatches, gRounds)}
|
{phase.layout === "play-in" ? (
|
||||||
/>
|
<PlayInLayout
|
||||||
|
matchesByRound={phaseMatchesByRound}
|
||||||
|
ownershipMap={ownershipMap}
|
||||||
|
userParticipantIds={userParticipantIds}
|
||||||
|
/>
|
||||||
|
) : phase.groups ? (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{phase.groups.map((group) => {
|
||||||
|
const gMatches = groupMatches(matchesByRound, group);
|
||||||
|
const gRounds = groupRounds.filter((r) => group.roundMatchNumbers[r] !== undefined);
|
||||||
|
return (
|
||||||
|
<div key={group.name}>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2">
|
||||||
|
{group.name}
|
||||||
|
</p>
|
||||||
|
<TreeColumns
|
||||||
|
visibleRounds={gRounds}
|
||||||
|
matchesByRound={gMatches}
|
||||||
|
ownershipMap={ownershipMap}
|
||||||
|
userParticipantIds={userParticipantIds}
|
||||||
|
bracketHeight={phaseHeight(gMatches, gRounds)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{sharedRounds.length > 0 && (
|
||||||
|
<TreeColumns
|
||||||
|
visibleRounds={sharedRounds}
|
||||||
|
matchesByRound={sharedMatchesByRound}
|
||||||
|
ownershipMap={ownershipMap}
|
||||||
|
userParticipantIds={userParticipantIds}
|
||||||
|
bracketHeight={phaseHeight(sharedMatchesByRound, sharedRounds)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
) : (
|
||||||
})}
|
<TreeColumns
|
||||||
{sharedRounds.length > 0 && (
|
visibleRounds={phaseRounds}
|
||||||
<TreeColumns
|
matchesByRound={phaseMatchesByRound}
|
||||||
visibleRounds={sharedRounds}
|
ownershipMap={ownershipMap}
|
||||||
matchesByRound={sharedMatchesByRound}
|
userParticipantIds={userParticipantIds}
|
||||||
ownershipMap={ownershipMap}
|
bracketHeight={phaseHeight(phaseMatchesByRound, phaseRounds)}
|
||||||
userParticipantIds={userParticipantIds}
|
/>
|
||||||
bracketHeight={phaseHeight(sharedMatchesByRound, sharedRounds)}
|
)}
|
||||||
/>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<TreeColumns
|
|
||||||
visibleRounds={phaseRounds}
|
|
||||||
matchesByRound={phaseMatchesByRound}
|
|
||||||
ownershipMap={ownershipMap}
|
|
||||||
userParticipantIds={userParticipantIds}
|
|
||||||
bracketHeight={phaseHeight(phaseMatchesByRound, phaseRounds)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile */}
|
{/* Mobile */}
|
||||||
<div className="md:hidden">
|
<div className="md:hidden">
|
||||||
<BracketTreePaginated
|
{phase.layout === "play-in" ? (
|
||||||
rounds={phaseRounds}
|
<PlayInLayout
|
||||||
matchesByRound={phaseMatchesByRound}
|
matchesByRound={phaseMatchesByRound}
|
||||||
ownershipMap={ownershipMap}
|
ownershipMap={ownershipMap}
|
||||||
userParticipantIds={userParticipantIds}
|
userParticipantIds={userParticipantIds}
|
||||||
firstScoringRoundIdx={phaseFirstScoringIdx >= 0 ? phaseFirstScoringIdx : undefined}
|
/>
|
||||||
/>
|
) : (
|
||||||
</div>
|
<BracketTreePaginated
|
||||||
|
rounds={phaseRounds}
|
||||||
|
matchesByRound={phaseMatchesByRound}
|
||||||
|
ownershipMap={ownershipMap}
|
||||||
|
userParticipantIds={userParticipantIds}
|
||||||
|
firstScoringRoundIdx={phaseFirstScoringIdx >= 0 ? phaseFirstScoringIdx : undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,6 @@ interface Props {
|
||||||
teamOwnerships: Record<string, TeamOwnership>;
|
teamOwnerships: Record<string, TeamOwnership>;
|
||||||
userParticipantIds: string[];
|
userParticipantIds: string[];
|
||||||
showOtLosses?: boolean;
|
showOtLosses?: boolean;
|
||||||
participantEvs?: Record<string, string>;
|
|
||||||
/** How many teams per conference qualify for playoffs (flat mode only). 0 = no line. */
|
/** How many teams per conference qualify for playoffs (flat mode only). 0 = no line. */
|
||||||
playoffSpots?: number;
|
playoffSpots?: number;
|
||||||
/** "flat" = single ranked list per conference (NBA). "nhl-divisions" = div top-3 + wild card. "mlb-divisions" = same structure with 3 WC spots, uses "League" label. */
|
/** "flat" = single ranked list per conference (NBA). "nhl-divisions" = div top-3 + wild card. "mlb-divisions" = same structure with 3 WC spots, uses "League" label. */
|
||||||
|
|
@ -223,19 +222,15 @@ function StandingsTable({
|
||||||
teamOwnerships,
|
teamOwnerships,
|
||||||
userParticipantIds,
|
userParticipantIds,
|
||||||
showOtLosses,
|
showOtLosses,
|
||||||
participantEvs,
|
|
||||||
hasEvs,
|
|
||||||
}: {
|
}: {
|
||||||
sections: TableSection[];
|
sections: TableSection[];
|
||||||
teamOwnerships: Record<string, TeamOwnership>;
|
teamOwnerships: Record<string, TeamOwnership>;
|
||||||
userParticipantIds: string[];
|
userParticipantIds: string[];
|
||||||
showOtLosses: boolean;
|
showOtLosses: boolean;
|
||||||
participantEvs: Record<string, string>;
|
|
||||||
hasEvs: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
// # Team GP W L [OTL] [PTS] PCT GB L10 STK Mgr [PROJ]
|
// # Team GP W L [OTL] [PTS] PCT GB L10 STK Mgr
|
||||||
// OTL and PTS are both shown for hockey (showOtLosses = true)
|
// OTL and PTS are both shown for hockey (showOtLosses = true)
|
||||||
const totalCols = 10 + (showOtLosses ? 2 : 0) + (hasEvs ? 1 : 0);
|
const totalCols = 10 + (showOtLosses ? 2 : 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-x-auto -mx-6 px-6">
|
<div className="overflow-x-auto -mx-6 px-6">
|
||||||
|
|
@ -254,7 +249,6 @@ function StandingsTable({
|
||||||
<th className="text-right py-1.5 px-2 w-12">L10</th>
|
<th className="text-right py-1.5 px-2 w-12">L10</th>
|
||||||
<th className="text-right py-1.5 px-2 w-12">STK</th>
|
<th className="text-right py-1.5 px-2 w-12">STK</th>
|
||||||
<th className="text-right py-1.5 pl-4 w-40">Mgr</th>
|
<th className="text-right py-1.5 pl-4 w-40">Mgr</th>
|
||||||
{hasEvs && <th className="text-right py-1.5 pl-2 w-14">PROJ</th>}
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
@ -299,7 +293,6 @@ function StandingsTable({
|
||||||
|
|
||||||
const isUserTeam = userParticipantIds.includes(row.participantId);
|
const isUserTeam = userParticipantIds.includes(row.participantId);
|
||||||
const ownership = teamOwnerships[row.participantId];
|
const ownership = teamOwnerships[row.participantId];
|
||||||
const ev = participantEvs[row.participantId];
|
|
||||||
|
|
||||||
sectionRows.push(
|
sectionRows.push(
|
||||||
<tr
|
<tr
|
||||||
|
|
@ -369,17 +362,6 @@ function StandingsTable({
|
||||||
<span className="text-xs text-muted-foreground">—</span>
|
<span className="text-xs text-muted-foreground">—</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
{hasEvs && (
|
|
||||||
<td className="py-2 pl-2 text-right tabular-nums">
|
|
||||||
{ev !== null ? (
|
|
||||||
<span className="text-blue-500 dark:text-blue-400 font-medium">
|
|
||||||
{parseFloat(ev).toFixed(1)}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-muted-foreground">—</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
)}
|
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -399,13 +381,11 @@ export function RegularSeasonStandings({
|
||||||
teamOwnerships,
|
teamOwnerships,
|
||||||
userParticipantIds,
|
userParticipantIds,
|
||||||
showOtLosses = false,
|
showOtLosses = false,
|
||||||
participantEvs = {},
|
|
||||||
playoffSpots = 8,
|
playoffSpots = 8,
|
||||||
displayMode = "flat",
|
displayMode = "flat",
|
||||||
}: Props) {
|
}: Props) {
|
||||||
if (standings.length === 0) return null;
|
if (standings.length === 0) return null;
|
||||||
|
|
||||||
const hasEvs = Object.keys(participantEvs).length > 0;
|
|
||||||
const lastSyncedAt = standings
|
const lastSyncedAt = standings
|
||||||
.map((s) => s.syncedAt)
|
.map((s) => s.syncedAt)
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
|
|
@ -419,7 +399,7 @@ export function RegularSeasonStandings({
|
||||||
? buildMlbSections(standings)
|
? buildMlbSections(standings)
|
||||||
: buildFlatSections(standings, playoffSpots);
|
: buildFlatSections(standings, playoffSpots);
|
||||||
|
|
||||||
const tableProps = { teamOwnerships, userParticipantIds, showOtLosses, participantEvs, hasEvs };
|
const tableProps = { teamOwnerships, userParticipantIds, showOtLosses };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,8 @@ export interface BracketPhase {
|
||||||
groups?: ConferenceGroup[];
|
groups?: ConferenceGroup[];
|
||||||
/** Rounds rendered after groups (e.g. Final Four, Championship) */
|
/** Rounds rendered after groups (e.g. Final Four, Championship) */
|
||||||
sharedRounds?: string[];
|
sharedRounds?: string[];
|
||||||
|
/** Custom rendering layout for special phases */
|
||||||
|
layout?: "play-in";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BracketTemplate {
|
export interface BracketTemplate {
|
||||||
|
|
@ -420,7 +422,7 @@ export const DARTS_128: BracketTemplate = {
|
||||||
* First Four (play-in) → Round of 64 → Round of 32 → Sweet Sixteen → Elite Eight → Final Four → Championship
|
* First Four (play-in) → Round of 64 → Round of 32 → Sweet Sixteen → Elite Eight → Final Four → Championship
|
||||||
* Only Elite Eight and beyond score points per Q18
|
* Only Elite Eight and beyond score points per Q18
|
||||||
*
|
*
|
||||||
* 2026 region config:
|
* Region config (year-specific — update each March when the First Four matchups are announced):
|
||||||
* East — 16 direct seeds, no play-ins
|
* East — 16 direct seeds, no play-ins
|
||||||
* South — 15 direct seeds, 16-seed play-in
|
* South — 15 direct seeds, 16-seed play-in
|
||||||
* West — 15 direct seeds, 11-seed play-in
|
* West — 15 direct seeds, 11-seed play-in
|
||||||
|
|
@ -860,6 +862,7 @@ export const NBA_20: BracketTemplate = {
|
||||||
{
|
{
|
||||||
name: "Play-In",
|
name: "Play-In",
|
||||||
rounds: ["Play-In Round 1", "Play-In Round 2"],
|
rounds: ["Play-In Round 1", "Play-In Round 2"],
|
||||||
|
layout: "play-in" as const,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Playoffs",
|
name: "Playoffs",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
|
import { useState } from "react";
|
||||||
import type { Route } from "./+types/$leagueId.sports-seasons.$sportsSeasonId";
|
import type { Route } from "./+types/$leagueId.sports-seasons.$sportsSeasonId";
|
||||||
|
|
||||||
import { loader } from "./$leagueId.sports-seasons.$sportsSeasonId.server";
|
import { loader } from "./$leagueId.sports-seasons.$sportsSeasonId.server";
|
||||||
|
|
@ -7,8 +8,8 @@ import { EventSchedule } from "~/components/sport-season/EventSchedule";
|
||||||
import { RegularSeasonStandings } from "~/components/sport-season/RegularSeasonStandings";
|
import { RegularSeasonStandings } from "~/components/sport-season/RegularSeasonStandings";
|
||||||
import { GroupStageStandings } from "~/components/sport-season/GroupStageStandings";
|
import { GroupStageStandings } from "~/components/sport-season/GroupStageStandings";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { cn } from "~/lib/utils";
|
||||||
import { ArrowLeft, CheckCircle2, Clock, Zap } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
|
||||||
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||||
return [{ title: `${data?.sportsSeason?.name ?? "Sport Season"} — ${data?.league?.name ?? "League"} - Brackt` }];
|
return [{ title: `${data?.sportsSeason?.name ?? "Sport Season"} — ${data?.league?.name ?? "League"} - Brackt` }];
|
||||||
|
|
@ -16,33 +17,7 @@ export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||||
|
|
||||||
export { loader };
|
export { loader };
|
||||||
|
|
||||||
function getStatusBadge(status: string) {
|
type BracketView = "standings" | "playoffs" | "finished";
|
||||||
switch (status) {
|
|
||||||
case "active":
|
|
||||||
return (
|
|
||||||
<Badge variant="outline" className="bg-emerald-500/15 text-emerald-400 border-emerald-500/30">
|
|
||||||
<Zap className="mr-1 h-3 w-3" />
|
|
||||||
Active
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
case "upcoming":
|
|
||||||
return (
|
|
||||||
<Badge variant="outline" className="bg-electric/15 text-electric border-electric/30">
|
|
||||||
<Clock className="mr-1 h-3 w-3" />
|
|
||||||
Upcoming
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
case "completed":
|
|
||||||
return (
|
|
||||||
<Badge variant="outline" className="bg-muted text-muted-foreground border-border">
|
|
||||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
|
||||||
Completed
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SportSeasonDetail({
|
export default function SportSeasonDetail({
|
||||||
loaderData,
|
loaderData,
|
||||||
|
|
@ -65,7 +40,7 @@ export default function SportSeasonDetail({
|
||||||
recentEvents,
|
recentEvents,
|
||||||
seasonIsFinalized,
|
seasonIsFinalized,
|
||||||
regularSeasonStandings,
|
regularSeasonStandings,
|
||||||
participantEvs,
|
|
||||||
groupStandings,
|
groupStandings,
|
||||||
bracketTemplateId,
|
bracketTemplateId,
|
||||||
} = loaderData;
|
} = loaderData;
|
||||||
|
|
@ -79,14 +54,9 @@ export default function SportSeasonDetail({
|
||||||
simulatorType === "nhl_bracket" ? "nhl-divisions" :
|
simulatorType === "nhl_bracket" ? "nhl-divisions" :
|
||||||
simulatorType === "mlb_bracket" ? "mlb-divisions" :
|
simulatorType === "mlb_bracket" ? "mlb-divisions" :
|
||||||
"flat";
|
"flat";
|
||||||
// NBA: 10 teams make the postseason (6 auto-qualify + 4 play-in)
|
|
||||||
// AFL: top 10 advance to the finals series (Wildcard + QF/EF + SF + PF + GF)
|
|
||||||
// NHL: handled by nhl-divisions mode (3 per div + 2 wild cards)
|
|
||||||
// MLB: handled by mlb-divisions mode (1 per div + 3 wild cards)
|
|
||||||
const playoffSpots =
|
const playoffSpots =
|
||||||
simulatorType === "nba_bracket" || simulatorType === "afl_bracket" ? 10 : 8;
|
simulatorType === "nba_bracket" || simulatorType === "afl_bracket" ? 10 : 8;
|
||||||
|
|
||||||
// Build ownership map for RegularSeasonStandings
|
|
||||||
const ownershipMap = Object.fromEntries(
|
const ownershipMap = Object.fromEntries(
|
||||||
teamOwnerships.map((o) => [
|
teamOwnerships.map((o) => [
|
||||||
o.participantId,
|
o.participantId,
|
||||||
|
|
@ -94,6 +64,62 @@ export default function SportSeasonDetail({
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Show the 3-way toggle only for bracket sports that also have regular season standings
|
||||||
|
const showToggle = hasStandings && scoringPattern === "playoff_bracket";
|
||||||
|
|
||||||
|
const [view, setView] = useState<BracketView>(() => {
|
||||||
|
if (!hasBracket) return "standings";
|
||||||
|
if (sportsSeason.status === "completed") return "finished";
|
||||||
|
return "playoffs";
|
||||||
|
});
|
||||||
|
|
||||||
|
const TOGGLE_VIEWS: { value: BracketView; label: string }[] = [
|
||||||
|
{ value: "standings", label: "Standings" },
|
||||||
|
{ value: "playoffs", label: "Playoffs" },
|
||||||
|
{ value: "finished", label: "Finished" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const bracketDisplay = (bracketMode: "bracket" | "rankings") => (
|
||||||
|
<SportSeasonDisplay
|
||||||
|
scoringPattern={scoringPattern as "playoff_bracket" | "season_standings" | "qualifying_points"}
|
||||||
|
sportSeasonName={sportsSeason.name}
|
||||||
|
sportName={sportsSeason.sport.name}
|
||||||
|
bracketMode={bracketMode}
|
||||||
|
playoffMatches={playoffMatches}
|
||||||
|
playoffRounds={playoffRounds}
|
||||||
|
bracketTemplateId={bracketTemplateId}
|
||||||
|
preEliminatedParticipants={preEliminatedParticipants}
|
||||||
|
participantPoints={participantPoints}
|
||||||
|
partialScoreParticipantIds={partialScoreParticipantIds}
|
||||||
|
seasonStandings={seasonStandings}
|
||||||
|
seasonIsFinalized={seasonIsFinalized}
|
||||||
|
qpStandings={qpStandings}
|
||||||
|
qpIsFinalized={sportsSeason.qualifyingPointsFinalized || false}
|
||||||
|
totalMajors={sportsSeason.totalMajors}
|
||||||
|
majorsCompleted={sportsSeason.majorsCompleted || 0}
|
||||||
|
canFinalize={
|
||||||
|
(sportsSeason.majorsCompleted || 0) >=
|
||||||
|
(sportsSeason.totalMajors || 0) &&
|
||||||
|
!sportsSeason.qualifyingPointsFinalized
|
||||||
|
}
|
||||||
|
teamOwnerships={teamOwnerships}
|
||||||
|
userParticipantIds={userParticipantIds}
|
||||||
|
scoringRules={season}
|
||||||
|
showOwnership={true}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const standingsDisplay = (
|
||||||
|
<RegularSeasonStandings
|
||||||
|
standings={regularSeasonStandings}
|
||||||
|
teamOwnerships={ownershipMap}
|
||||||
|
userParticipantIds={userParticipantIds}
|
||||||
|
showOtLosses={showOtLosses}
|
||||||
|
displayMode={standingsDisplayMode as "flat" | "nhl-divisions" | "mlb-divisions"}
|
||||||
|
playoffSpots={playoffSpots}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto py-8 px-4">
|
<div className="container mx-auto py-8 px-4">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
|
|
@ -104,7 +130,7 @@ export default function SportSeasonDetail({
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="flex items-start justify-between gap-4 mb-4">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold mb-1">
|
<h1 className="text-3xl font-bold mb-1">
|
||||||
{sportsSeason.sport.name}
|
{sportsSeason.sport.name}
|
||||||
|
|
@ -113,84 +139,68 @@ export default function SportSeasonDetail({
|
||||||
{sportsSeason.name} • {league.name} • {season.year} Season
|
{sportsSeason.name} • {league.name} • {season.year} Season
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{getStatusBadge(sportsSeason.status)}
|
|
||||||
|
{showToggle && (
|
||||||
|
<div className="flex gap-1 rounded-lg bg-muted p-1 sm:shrink-0">
|
||||||
|
{TOGGLE_VIEWS.map(({ value, label }) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setView(value)}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 sm:flex-none px-3 py-1.5 text-sm font-medium rounded-md transition-colors",
|
||||||
|
view === value
|
||||||
|
? "bg-background shadow-sm text-foreground"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Regular season standings — show above bracket when no bracket exists yet */}
|
{showToggle ? (
|
||||||
{hasStandings && !hasBracket && (
|
<div>
|
||||||
<div className="mb-6">
|
{view === "standings" && standingsDisplay}
|
||||||
<RegularSeasonStandings
|
{view === "playoffs" && bracketDisplay("bracket")}
|
||||||
standings={regularSeasonStandings}
|
{view === "finished" && bracketDisplay("rankings")}
|
||||||
teamOwnerships={ownershipMap}
|
|
||||||
userParticipantIds={userParticipantIds}
|
|
||||||
showOtLosses={showOtLosses}
|
|
||||||
participantEvs={participantEvs}
|
|
||||||
displayMode={standingsDisplayMode as "flat" | "nhl-divisions" | "mlb-divisions"}
|
|
||||||
playoffSpots={playoffSpots}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Regular season standings above bracket when no bracket exists yet */}
|
||||||
|
{hasStandings && !hasBracket && (
|
||||||
|
<div className="mb-6">{standingsDisplay}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Event schedule - hidden for bracket sports (the bracket itself is the event) */}
|
{/* Event schedule — hidden for bracket sports */}
|
||||||
{scoringPattern !== "playoff_bracket" &&
|
{scoringPattern !== "playoff_bracket" &&
|
||||||
(upcomingEvents.length > 0 || recentEvents.length > 0) && (
|
(upcomingEvents.length > 0 || recentEvents.length > 0) && (
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<EventSchedule
|
<EventSchedule
|
||||||
upcomingEvents={upcomingEvents}
|
upcomingEvents={upcomingEvents}
|
||||||
recentEvents={recentEvents}
|
recentEvents={recentEvents}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Group stage standings — shown for FIFA-style tournaments before/during group phase */}
|
{/* Group stage standings */}
|
||||||
{groupStandings.length > 0 && (
|
{groupStandings.length > 0 && (
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<GroupStageStandings groups={groupStandings} ownershipMap={ownershipMap} showEmpty={false} />
|
<GroupStageStandings groups={groupStandings} ownershipMap={ownershipMap} showEmpty={false} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Hide bracket display when standings exist but no bracket matches have been set yet */}
|
{/* Bracket — hidden when standings exist but bracket is empty */}
|
||||||
{!(scoringPattern === "playoff_bracket" && hasStandings && !hasBracket) && <SportSeasonDisplay
|
{!(scoringPattern === "playoff_bracket" && hasStandings && !hasBracket) && bracketDisplay("bracket")}
|
||||||
scoringPattern={scoringPattern as "playoff_bracket" | "season_standings" | "qualifying_points"}
|
|
||||||
sportSeasonName={sportsSeason.name}
|
|
||||||
sportName={sportsSeason.sport.name}
|
|
||||||
playoffMatches={playoffMatches}
|
|
||||||
playoffRounds={playoffRounds}
|
|
||||||
bracketTemplateId={bracketTemplateId}
|
|
||||||
preEliminatedParticipants={preEliminatedParticipants}
|
|
||||||
participantPoints={participantPoints}
|
|
||||||
partialScoreParticipantIds={partialScoreParticipantIds}
|
|
||||||
seasonStandings={seasonStandings}
|
|
||||||
seasonIsFinalized={seasonIsFinalized}
|
|
||||||
qpStandings={qpStandings}
|
|
||||||
qpIsFinalized={sportsSeason.qualifyingPointsFinalized || false}
|
|
||||||
totalMajors={sportsSeason.totalMajors}
|
|
||||||
majorsCompleted={sportsSeason.majorsCompleted || 0}
|
|
||||||
canFinalize={
|
|
||||||
(sportsSeason.majorsCompleted || 0) >=
|
|
||||||
(sportsSeason.totalMajors || 0) &&
|
|
||||||
!sportsSeason.qualifyingPointsFinalized
|
|
||||||
}
|
|
||||||
teamOwnerships={teamOwnerships}
|
|
||||||
userParticipantIds={userParticipantIds}
|
|
||||||
scoringRules={season}
|
|
||||||
showOwnership={true}
|
|
||||||
/>}
|
|
||||||
|
|
||||||
{/* Regular season standings — show below bracket once bracket exists */}
|
{/* Regular season standings below bracket once bracket exists */}
|
||||||
{hasStandings && hasBracket && (
|
{hasStandings && hasBracket && (
|
||||||
<div className="mt-8">
|
<div className="mt-8">{standingsDisplay}</div>
|
||||||
<RegularSeasonStandings
|
)}
|
||||||
standings={regularSeasonStandings}
|
</>
|
||||||
teamOwnerships={ownershipMap}
|
|
||||||
userParticipantIds={userParticipantIds}
|
|
||||||
showOtLosses={showOtLosses}
|
|
||||||
participantEvs={participantEvs}
|
|
||||||
displayMode={standingsDisplayMode as "flat" | "nhl-divisions" | "mlb-divisions"}
|
|
||||||
playoffSpots={playoffSpots}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue