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 && (
|
||||
<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" : "",
|
||||
].filter(Boolean).join(" ")}>
|
||||
{/* Electric dot for user's pick (when not winner) */}
|
||||
{isOwned && !isWinner && showText && (
|
||||
<span
|
||||
className="shrink-0 rounded-full"
|
||||
style={{ width: 5, height: 5, background: "#2ce1c1" }}
|
||||
/>
|
||||
)}
|
||||
{/* Manager avatar — always present for alignment; empty box when unowned */}
|
||||
<div
|
||||
className="shrink-0 rounded-[3px] flex items-center justify-center text-[8px] font-bold"
|
||||
style={{
|
||||
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">
|
||||
<span
|
||||
className={[
|
||||
"text-[13px] leading-tight block truncate",
|
||||
isTbd ? "text-muted-foreground/50 italic" : "",
|
||||
isLoser && isOwned ? "text-electric/50 line-through" :
|
||||
isLoser ? "text-muted-foreground line-through" : "",
|
||||
isOwned && !isLoser ? "text-electric font-medium" : "",
|
||||
isWinner ? "font-semibold" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
|
|
@ -117,28 +128,11 @@ function ParticipantRow({
|
|||
{name ?? "TBD"}
|
||||
</span>
|
||||
|
||||
{/* Owner info row */}
|
||||
{/* Owner name below participant name */}
|
||||
{showOwner && !isTbd && ownership && (
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
<div
|
||||
className="shrink-0 rounded-[2px] flex items-center justify-center text-[8px] font-bold"
|
||||
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>
|
||||
<span className="text-[10px] text-muted-foreground truncate block leading-none">
|
||||
{ownership.ownerName ?? ownership.teamName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -169,7 +163,7 @@ interface BracketMatchSlotProps {
|
|||
userParticipantIds: Set<string>;
|
||||
}
|
||||
|
||||
function BracketMatchSlot({
|
||||
export function BracketMatchSlot({
|
||||
match,
|
||||
slotHeight,
|
||||
ownershipMap,
|
||||
|
|
@ -229,7 +223,7 @@ function BracketMatchSlot({
|
|||
isWinner={p1IsWinner}
|
||||
isLoser={p1IsLoser}
|
||||
isOwned={p1IsOwned}
|
||||
ownership={showOwner ? p1Ownership : null}
|
||||
ownership={p1Ownership}
|
||||
score={match.participant1Score}
|
||||
rowHeight={rowHeight - INSET}
|
||||
showScore={showScore}
|
||||
|
|
@ -242,7 +236,7 @@ function BracketMatchSlot({
|
|||
isWinner={p2IsWinner}
|
||||
isLoser={p2IsLoser}
|
||||
isOwned={p2IsOwned}
|
||||
ownership={showOwner ? p2Ownership : null}
|
||||
ownership={p2Ownership}
|
||||
score={match.participant2Score}
|
||||
rowHeight={rowHeight - INSET}
|
||||
showScore={showScore}
|
||||
|
|
@ -318,7 +312,7 @@ function ConnectorColumn({ currentMatches, nextMatches, bracketHeight }: Connect
|
|||
}}
|
||||
>
|
||||
{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>
|
||||
</div>
|
||||
|
|
@ -412,6 +406,7 @@ interface BracketTreeViewProps {
|
|||
matchesByRound: Map<string, BracketMatch[]>;
|
||||
ownershipMap: Map<string, BracketOwnership>;
|
||||
userParticipantIds: Set<string>;
|
||||
thirdPlaceRound?: string;
|
||||
}
|
||||
|
||||
export function BracketTreeView({
|
||||
|
|
@ -419,10 +414,14 @@ export function BracketTreeView({
|
|||
matchesByRound,
|
||||
ownershipMap,
|
||||
userParticipantIds,
|
||||
thirdPlaceRound,
|
||||
}: 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 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 (
|
||||
<div
|
||||
|
|
@ -431,12 +430,33 @@ export function BracketTreeView({
|
|||
>
|
||||
<div style={{ minWidth }}>
|
||||
<TreeColumns
|
||||
visibleRounds={rounds}
|
||||
visibleRounds={mainRounds}
|
||||
matchesByRound={matchesByRound}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
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>
|
||||
);
|
||||
|
|
@ -451,6 +471,7 @@ interface BracketTreePaginatedProps {
|
|||
userParticipantIds: Set<string>;
|
||||
/** Index of the first scoring round — default page starts here */
|
||||
firstScoringRoundIdx?: number;
|
||||
thirdPlaceRound?: string;
|
||||
}
|
||||
|
||||
interface AnimState {
|
||||
|
|
@ -466,14 +487,18 @@ export function BracketTreePaginated({
|
|||
ownershipMap,
|
||||
userParticipantIds,
|
||||
firstScoringRoundIdx,
|
||||
thirdPlaceRound,
|
||||
}: BracketTreePaginatedProps) {
|
||||
const mainRounds = thirdPlaceRound ? rounds.filter((r) => r !== thirdPlaceRound) : rounds;
|
||||
const thirdPlaceMatch = thirdPlaceRound ? (matchesByRound.get(thirdPlaceRound) ?? [])[0] : undefined;
|
||||
|
||||
const defaultPage = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
firstScoringRoundIdx !== undefined
|
||||
? Math.max(0, firstScoringRoundIdx - 1)
|
||||
: rounds.length - 2,
|
||||
rounds.length - 2,
|
||||
: mainRounds.length - 2,
|
||||
mainRounds.length - 2,
|
||||
),
|
||||
);
|
||||
const [page, setPage] = useState(defaultPage);
|
||||
|
|
@ -500,7 +525,7 @@ export function BracketTreePaginated({
|
|||
}, [anim]);
|
||||
|
||||
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" });
|
||||
};
|
||||
|
||||
|
|
@ -515,11 +540,11 @@ export function BracketTreePaginated({
|
|||
};
|
||||
|
||||
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 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);
|
||||
return max * (DESIRED_CARD_HEIGHT + CARD_GAP);
|
||||
};
|
||||
|
|
@ -528,9 +553,9 @@ export function BracketTreePaginated({
|
|||
const animFromHeight = anim ? calcHeight(anim.fromPage) : pageHeight;
|
||||
const animToHeight = anim ? calcHeight(anim.toPage) : pageHeight;
|
||||
|
||||
const visibleRounds = rounds.slice(page, page + 2);
|
||||
const fromRounds = anim ? rounds.slice(anim.fromPage, anim.fromPage + 2) : visibleRounds;
|
||||
const toRounds = anim ? rounds.slice(anim.toPage, anim.toPage + 2) : visibleRounds;
|
||||
const visibleRounds = mainRounds.slice(page, page + 2);
|
||||
const fromRounds = anim ? mainRounds.slice(anim.fromPage, anim.fromPage + 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
|
||||
// Settling: left slot shows toRounds at animToHeight with CSS transitions so cards animate smoothly
|
||||
|
|
@ -578,7 +603,7 @@ export function BracketTreePaginated({
|
|||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(page + 1)}
|
||||
disabled={page + 2 >= rounds.length || !!anim}
|
||||
disabled={page + 2 >= mainRounds.length || !!anim}
|
||||
className="h-7 w-7 shrink-0"
|
||||
aria-label="Next rounds"
|
||||
>
|
||||
|
|
@ -619,6 +644,23 @@ export function BracketTreePaginated({
|
|||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import {
|
|||
} 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 { TeamAvatar } from "~/components/TeamAvatar";
|
||||
import {
|
||||
BracketTreeView,
|
||||
BracketTreePaginated,
|
||||
|
|
@ -61,6 +63,8 @@ interface PlayoffBracketProps {
|
|||
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. */
|
||||
|
|
@ -182,6 +186,17 @@ function firstScoringRoundIdx(matchesByRound: Map<string, Match[]>, rounds: stri
|
|||
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,
|
||||
|
|
@ -194,6 +209,7 @@ export function PlayoffBracket({
|
|||
showOwnership = true,
|
||||
title = "Playoff Bracket",
|
||||
description,
|
||||
mode = "bracket",
|
||||
}: PlayoffBracketProps) {
|
||||
const userParticipantSet = new Set(userParticipantIds);
|
||||
const ownershipMap = new Map<string, TeamOwnership>();
|
||||
|
|
@ -204,6 +220,10 @@ export function PlayoffBracket({
|
|||
const scoringRoundIdx = firstScoringRoundIdx(matchesByRound, rounds);
|
||||
const template = bracketTemplateId ? getBracketTemplate(bracketTemplateId) : undefined;
|
||||
|
||||
const thirdPlaceRound = template?.rounds
|
||||
.find((r) => template.rounds.some((other) => other.loserFeedsInto === r.name))
|
||||
?.name;
|
||||
|
||||
// Build elimination rankings
|
||||
const losersByRound = new Map<string, Array<{ participant: Participant; score: string | null; ownership: TeamOwnership | null }>>();
|
||||
let bracketWinner: Participant | null = null;
|
||||
|
|
@ -296,7 +316,7 @@ export function PlayoffBracket({
|
|||
</div>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{template?.phases ? (
|
||||
{mode === "bracket" && (template?.phases ? (
|
||||
<TabbedBracketLayout
|
||||
rounds={rounds}
|
||||
matchesByRound={matchesByRound as Map<string, BracketMatch[]>}
|
||||
|
|
@ -324,6 +344,7 @@ export function PlayoffBracket({
|
|||
matchesByRound={matchesByRound as Map<string, BracketMatch[]>}
|
||||
ownershipMap={ownershipMap as Map<string, BracketOwnership>}
|
||||
userParticipantIds={userParticipantSet}
|
||||
thirdPlaceRound={thirdPlaceRound}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -335,13 +356,14 @@ export function PlayoffBracket({
|
|||
ownershipMap={ownershipMap as Map<string, BracketOwnership>}
|
||||
userParticipantIds={userParticipantSet}
|
||||
firstScoringRoundIdx={scoringRoundIdx}
|
||||
thirdPlaceRound={thirdPlaceRound}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
))}
|
||||
|
||||
{/* ── In Contention ── */}
|
||||
{activeParticipants.length > 0 && (
|
||||
{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">
|
||||
|
|
@ -402,133 +424,126 @@ export function PlayoffBracket({
|
|||
)}
|
||||
|
||||
{/* ── Final Rankings ── */}
|
||||
{showRankings && (
|
||||
<Card className="border-electric/30">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-electric flex items-center gap-2">
|
||||
<Trophy className="h-5 w-5" />
|
||||
{bracketWinner ? "Final Rankings" : "Eliminated Teams"}
|
||||
</CardTitle>
|
||||
{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>
|
||||
<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" />
|
||||
<CardContent className="px-3 sm:px-6">
|
||||
<div className="space-y-3">
|
||||
{bracketWinner && (
|
||||
<div className={rankingRowCls(1)}>
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<TeamAvatar
|
||||
teamId={winnerOwnership?.teamId ?? bracketWinner.id}
|
||||
teamName={winnerOwnership?.teamName ?? bracketWinner.name}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className={`font-medium text-sm leading-tight truncate${winnerIsOwned ? " text-electric" : ""}`}>
|
||||
{bracketWinner.name}
|
||||
{winnerIsOwned && <Star className="inline ml-1.5 h-3 w-3 fill-current text-electric" />}
|
||||
</p>
|
||||
{winnerOwnership?.ownerName && (
|
||||
<p className="text-xs text-muted-foreground truncate">{winnerOwnership.ownerName}</p>
|
||||
)}
|
||||
</TableCell>
|
||||
{showOwnership && (
|
||||
<TableCell className="pl-6">
|
||||
{winnerOwnership ? (
|
||||
<TeamOwnerBadge teamName={winnerOwnership.teamName} ownerName={winnerOwnership.ownerName} />
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<div className="text-right shrink-0 flex-1 sm:flex-none">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Rank</p>
|
||||
<span className="text-2xl font-bold leading-none">1</span>
|
||||
</div>
|
||||
{pointsMap.size > 0 && (
|
||||
<TableCell className="text-right font-semibold text-electric">
|
||||
{winnerPts ?? "—"}
|
||||
</TableCell>
|
||||
<>
|
||||
<div className="h-8 w-px bg-border shrink-0 self-center" />
|
||||
<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) => {
|
||||
const isOwned = userParticipantSet.has(entry.participant.id);
|
||||
const pts = pointsMap.get(entry.participant.id);
|
||||
return (
|
||||
<TableRow
|
||||
key={entry.participant.id}
|
||||
className={isOwned ? "bg-electric/5 border-l-2 border-l-electric opacity-80" : "opacity-60"}
|
||||
>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{entry.rankLabel}
|
||||
</TableCell>
|
||||
<TableCell className={isOwned ? "text-electric font-medium" : "text-muted-foreground"}>
|
||||
{entry.participant.name}
|
||||
{isOwned && (
|
||||
<Star className="inline ml-1.5 h-3 w-3 fill-current text-electric" />
|
||||
{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 (
|
||||
<div key={entry.participant.id} className={rankingRowCls(numRank)}>
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<TeamAvatar
|
||||
teamId={entry.ownership?.teamId ?? entry.participant.id}
|
||||
teamName={entry.ownership?.teamName ?? entry.participant.name}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className={`font-medium text-sm leading-tight truncate${isOwned ? " text-electric" : ""}`}>
|
||||
{entry.participant.name}
|
||||
{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>
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<div className="text-right shrink-0 flex-1 sm:flex-none">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Rank</p>
|
||||
<span className="text-2xl font-bold leading-none">{entry.rankLabel}</span>
|
||||
</div>
|
||||
{pointsMap.size > 0 && (
|
||||
<TableCell className="text-right tabular-nums text-muted-foreground">
|
||||
{pts ?? "—"}
|
||||
</TableCell>
|
||||
<>
|
||||
<div className="h-8 w-px bg-border shrink-0 self-center" />
|
||||
<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) => {
|
||||
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" />
|
||||
{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 (
|
||||
<div key={p.id} className={rankingRowCls(undefined)}>
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<TeamAvatar
|
||||
teamId={ownership?.teamId ?? p.id}
|
||||
teamName={ownership?.teamName ?? p.name}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className={`font-medium text-sm leading-tight truncate${isOwned ? " 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>
|
||||
{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>
|
||||
</div>
|
||||
</div>
|
||||
{pointsMap.size > 0 && (
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -92,6 +92,7 @@ interface SportSeasonDisplayProps {
|
|||
scoringPattern: ScoringPattern;
|
||||
sportSeasonName: string;
|
||||
sportName: string;
|
||||
bracketMode?: "bracket" | "rankings";
|
||||
|
||||
// Playoff data
|
||||
playoffMatches?: Match[];
|
||||
|
|
@ -123,6 +124,7 @@ export function SportSeasonDisplay({
|
|||
scoringPattern,
|
||||
sportSeasonName,
|
||||
sportName: _sportName,
|
||||
bracketMode = "bracket",
|
||||
playoffMatches = [],
|
||||
playoffRounds = [],
|
||||
bracketTemplateId = null,
|
||||
|
|
@ -157,6 +159,7 @@ export function SportSeasonDisplay({
|
|||
userParticipantIds={userParticipantIds}
|
||||
showOwnership={showOwnership}
|
||||
title="Playoff Bracket"
|
||||
mode={bracketMode}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { useState } from "react";
|
||||
import { cn } from "~/lib/utils";
|
||||
import type { BracketPhase, ConferenceGroup } from "~/lib/bracket-templates";
|
||||
import {
|
||||
TreeColumns,
|
||||
BracketTreePaginated,
|
||||
BracketMatchSlot,
|
||||
type BracketMatch,
|
||||
type BracketOwnership,
|
||||
} from "./BracketTreeView";
|
||||
|
|
@ -39,6 +39,106 @@ function phaseHeight(matchesByRound: Map<string, BracketMatch[]>, rounds: string
|
|||
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({
|
||||
rounds,
|
||||
matchesByRound,
|
||||
|
|
@ -47,94 +147,100 @@ export function TabbedBracketLayout({
|
|||
phases,
|
||||
scoringRoundIdx,
|
||||
}: 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 (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-1 rounded-lg bg-muted p-1 w-fit">
|
||||
{phases.map((p, i) => (
|
||||
<button
|
||||
key={p.name}
|
||||
type="button"
|
||||
onClick={() => setActiveIdx(i)}
|
||||
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>
|
||||
<div className="space-y-10">
|
||||
{phases.map((phase) => {
|
||||
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));
|
||||
|
||||
{/* Desktop */}
|
||||
<div className="hidden md:block">
|
||||
{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)}
|
||||
/>
|
||||
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 (
|
||||
<div key={phase.name}>
|
||||
<p className={cn(
|
||||
"text-sm font-semibold uppercase tracking-wider mb-4",
|
||||
phases.length > 1 ? "text-muted-foreground" : "hidden"
|
||||
)}>
|
||||
{phase.name}
|
||||
</p>
|
||||
|
||||
{/* Desktop */}
|
||||
<div className="hidden md:block">
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
{sharedRounds.length > 0 && (
|
||||
<TreeColumns
|
||||
visibleRounds={sharedRounds}
|
||||
matchesByRound={sharedMatchesByRound}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
bracketHeight={phaseHeight(sharedMatchesByRound, sharedRounds)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<TreeColumns
|
||||
visibleRounds={phaseRounds}
|
||||
matchesByRound={phaseMatchesByRound}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
bracketHeight={phaseHeight(phaseMatchesByRound, phaseRounds)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<TreeColumns
|
||||
visibleRounds={phaseRounds}
|
||||
matchesByRound={phaseMatchesByRound}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
bracketHeight={phaseHeight(phaseMatchesByRound, phaseRounds)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile */}
|
||||
<div className="md:hidden">
|
||||
<BracketTreePaginated
|
||||
rounds={phaseRounds}
|
||||
matchesByRound={phaseMatchesByRound}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
firstScoringRoundIdx={phaseFirstScoringIdx >= 0 ? phaseFirstScoringIdx : undefined}
|
||||
/>
|
||||
</div>
|
||||
{/* Mobile */}
|
||||
<div className="md:hidden">
|
||||
{phase.layout === "play-in" ? (
|
||||
<PlayInLayout
|
||||
matchesByRound={phaseMatchesByRound}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
/>
|
||||
) : (
|
||||
<BracketTreePaginated
|
||||
rounds={phaseRounds}
|
||||
matchesByRound={phaseMatchesByRound}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
firstScoringRoundIdx={phaseFirstScoringIdx >= 0 ? phaseFirstScoringIdx : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,6 @@ interface Props {
|
|||
teamOwnerships: Record<string, TeamOwnership>;
|
||||
userParticipantIds: string[];
|
||||
showOtLosses?: boolean;
|
||||
participantEvs?: Record<string, string>;
|
||||
/** How many teams per conference qualify for playoffs (flat mode only). 0 = no line. */
|
||||
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. */
|
||||
|
|
@ -223,19 +222,15 @@ function StandingsTable({
|
|||
teamOwnerships,
|
||||
userParticipantIds,
|
||||
showOtLosses,
|
||||
participantEvs,
|
||||
hasEvs,
|
||||
}: {
|
||||
sections: TableSection[];
|
||||
teamOwnerships: Record<string, TeamOwnership>;
|
||||
userParticipantIds: string[];
|
||||
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)
|
||||
const totalCols = 10 + (showOtLosses ? 2 : 0) + (hasEvs ? 1 : 0);
|
||||
const totalCols = 10 + (showOtLosses ? 2 : 0);
|
||||
|
||||
return (
|
||||
<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">STK</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>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -299,7 +293,6 @@ function StandingsTable({
|
|||
|
||||
const isUserTeam = userParticipantIds.includes(row.participantId);
|
||||
const ownership = teamOwnerships[row.participantId];
|
||||
const ev = participantEvs[row.participantId];
|
||||
|
||||
sectionRows.push(
|
||||
<tr
|
||||
|
|
@ -369,17 +362,6 @@ function StandingsTable({
|
|||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
});
|
||||
|
|
@ -399,13 +381,11 @@ export function RegularSeasonStandings({
|
|||
teamOwnerships,
|
||||
userParticipantIds,
|
||||
showOtLosses = false,
|
||||
participantEvs = {},
|
||||
playoffSpots = 8,
|
||||
displayMode = "flat",
|
||||
}: Props) {
|
||||
if (standings.length === 0) return null;
|
||||
|
||||
const hasEvs = Object.keys(participantEvs).length > 0;
|
||||
const lastSyncedAt = standings
|
||||
.map((s) => s.syncedAt)
|
||||
.filter(Boolean)
|
||||
|
|
@ -419,7 +399,7 @@ export function RegularSeasonStandings({
|
|||
? buildMlbSections(standings)
|
||||
: buildFlatSections(standings, playoffSpots);
|
||||
|
||||
const tableProps = { teamOwnerships, userParticipantIds, showOtLosses, participantEvs, hasEvs };
|
||||
const tableProps = { teamOwnerships, userParticipantIds, showOtLosses };
|
||||
|
||||
return (
|
||||
<Card>
|
||||
|
|
|
|||
|
|
@ -88,6 +88,8 @@ export interface BracketPhase {
|
|||
groups?: ConferenceGroup[];
|
||||
/** Rounds rendered after groups (e.g. Final Four, Championship) */
|
||||
sharedRounds?: string[];
|
||||
/** Custom rendering layout for special phases */
|
||||
layout?: "play-in";
|
||||
}
|
||||
|
||||
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
|
||||
* 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
|
||||
* South — 15 direct seeds, 16-seed play-in
|
||||
* West — 15 direct seeds, 11-seed play-in
|
||||
|
|
@ -860,6 +862,7 @@ export const NBA_20: BracketTemplate = {
|
|||
{
|
||||
name: "Play-In",
|
||||
rounds: ["Play-In Round 1", "Play-In Round 2"],
|
||||
layout: "play-in" as const,
|
||||
},
|
||||
{
|
||||
name: "Playoffs",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Link } from "react-router";
|
||||
import { useState } from "react";
|
||||
import type { Route } from "./+types/$leagueId.sports-seasons.$sportsSeasonId";
|
||||
|
||||
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 { GroupStageStandings } from "~/components/sport-season/GroupStageStandings";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { ArrowLeft, CheckCircle2, Clock, Zap } from "lucide-react";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
|
||||
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||
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 };
|
||||
|
||||
function getStatusBadge(status: string) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
type BracketView = "standings" | "playoffs" | "finished";
|
||||
|
||||
export default function SportSeasonDetail({
|
||||
loaderData,
|
||||
|
|
@ -65,7 +40,7 @@ export default function SportSeasonDetail({
|
|||
recentEvents,
|
||||
seasonIsFinalized,
|
||||
regularSeasonStandings,
|
||||
participantEvs,
|
||||
|
||||
groupStandings,
|
||||
bracketTemplateId,
|
||||
} = loaderData;
|
||||
|
|
@ -79,14 +54,9 @@ export default function SportSeasonDetail({
|
|||
simulatorType === "nhl_bracket" ? "nhl-divisions" :
|
||||
simulatorType === "mlb_bracket" ? "mlb-divisions" :
|
||||
"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 =
|
||||
simulatorType === "nba_bracket" || simulatorType === "afl_bracket" ? 10 : 8;
|
||||
|
||||
// Build ownership map for RegularSeasonStandings
|
||||
const ownershipMap = Object.fromEntries(
|
||||
teamOwnerships.map((o) => [
|
||||
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 (
|
||||
<div className="container mx-auto py-8 px-4">
|
||||
<div className="mb-6">
|
||||
|
|
@ -104,7 +130,7 @@ export default function SportSeasonDetail({
|
|||
</Link>
|
||||
</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>
|
||||
<h1 className="text-3xl font-bold mb-1">
|
||||
{sportsSeason.sport.name}
|
||||
|
|
@ -113,84 +139,68 @@ export default function SportSeasonDetail({
|
|||
{sportsSeason.name} • {league.name} • {season.year} Season
|
||||
</p>
|
||||
</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>
|
||||
|
||||
{/* Regular season standings — show above bracket when no bracket exists yet */}
|
||||
{hasStandings && !hasBracket && (
|
||||
<div className="mb-6">
|
||||
<RegularSeasonStandings
|
||||
standings={regularSeasonStandings}
|
||||
teamOwnerships={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
showOtLosses={showOtLosses}
|
||||
participantEvs={participantEvs}
|
||||
displayMode={standingsDisplayMode as "flat" | "nhl-divisions" | "mlb-divisions"}
|
||||
playoffSpots={playoffSpots}
|
||||
/>
|
||||
{showToggle ? (
|
||||
<div>
|
||||
{view === "standings" && standingsDisplay}
|
||||
{view === "playoffs" && bracketDisplay("bracket")}
|
||||
{view === "finished" && bracketDisplay("rankings")}
|
||||
</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) */}
|
||||
{scoringPattern !== "playoff_bracket" &&
|
||||
(upcomingEvents.length > 0 || recentEvents.length > 0) && (
|
||||
<div className="mb-6">
|
||||
<EventSchedule
|
||||
upcomingEvents={upcomingEvents}
|
||||
recentEvents={recentEvents}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Event schedule — hidden for bracket sports */}
|
||||
{scoringPattern !== "playoff_bracket" &&
|
||||
(upcomingEvents.length > 0 || recentEvents.length > 0) && (
|
||||
<div className="mb-6">
|
||||
<EventSchedule
|
||||
upcomingEvents={upcomingEvents}
|
||||
recentEvents={recentEvents}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Group stage standings — shown for FIFA-style tournaments before/during group phase */}
|
||||
{groupStandings.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<GroupStageStandings groups={groupStandings} ownershipMap={ownershipMap} showEmpty={false} />
|
||||
</div>
|
||||
)}
|
||||
{/* Group stage standings */}
|
||||
{groupStandings.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<GroupStageStandings groups={groupStandings} ownershipMap={ownershipMap} showEmpty={false} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hide bracket display when standings exist but no bracket matches have been set yet */}
|
||||
{!(scoringPattern === "playoff_bracket" && hasStandings && !hasBracket) && <SportSeasonDisplay
|
||||
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}
|
||||
/>}
|
||||
{/* Bracket — hidden when standings exist but bracket is empty */}
|
||||
{!(scoringPattern === "playoff_bracket" && hasStandings && !hasBracket) && bracketDisplay("bracket")}
|
||||
|
||||
{/* Regular season standings — show below bracket once bracket exists */}
|
||||
{hasStandings && hasBracket && (
|
||||
<div className="mt-8">
|
||||
<RegularSeasonStandings
|
||||
standings={regularSeasonStandings}
|
||||
teamOwnerships={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
showOtLosses={showOtLosses}
|
||||
participantEvs={participantEvs}
|
||||
displayMode={standingsDisplayMode as "flat" | "nhl-divisions" | "mlb-divisions"}
|
||||
playoffSpots={playoffSpots}
|
||||
/>
|
||||
</div>
|
||||
{/* Regular season standings below bracket once bracket exists */}
|
||||
{hasStandings && hasBracket && (
|
||||
<div className="mt-8">{standingsDisplay}</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue