Bracket work.
This commit is contained in:
parent
207fc21d67
commit
4d3b214ce5
8 changed files with 583 additions and 344 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
||||
|
|
@ -29,6 +29,7 @@ export interface BracketOwnership {
|
|||
|
||||
const COLUMN_WIDTH = 152;
|
||||
const CONNECTOR_WIDTH = 24;
|
||||
const SLOT_WIDTH = 2 * COLUMN_WIDTH + CONNECTOR_WIDTH; // viewport width for 2 columns
|
||||
const LABEL_HEIGHT = 32;
|
||||
const CARD_GAP = 14; // vertical gap between cards (split top/bottom)
|
||||
const DESIRED_CARD_HEIGHT = 112; // target card height — tall enough to show owner info
|
||||
|
|
@ -265,10 +266,12 @@ function ConnectorColumn({ currentMatches, nextMatches, bracketHeight }: Connect
|
|||
const mid = CONNECTOR_WIDTH / 2;
|
||||
const paths: string[] = [];
|
||||
|
||||
if (nextMatches.length === Math.ceil(currentMatches.length / 2)) {
|
||||
const currentSlotH = bracketHeight / Math.max(currentMatches.length, 1);
|
||||
const nextSlotH = bracketHeight / Math.max(nextMatches.length, 1);
|
||||
const currentSlotH = bracketHeight / Math.max(currentMatches.length, 1);
|
||||
const nextSlotH = bracketHeight / Math.max(nextMatches.length, 1);
|
||||
|
||||
// Use halving U-shapes only when prev > 1 (avoids false-positive 1→1 side branches like 3PG→Finals)
|
||||
if (nextMatches.length === Math.ceil(currentMatches.length / 2) && currentMatches.length > 1) {
|
||||
// Standard single-elimination halving: U-shape connectors
|
||||
for (let k = 0; k < nextMatches.length; k++) {
|
||||
const topY = (2 * k) * currentSlotH + currentSlotH / 2;
|
||||
const midY = k * nextSlotH + nextSlotH / 2;
|
||||
|
|
@ -276,14 +279,29 @@ function ConnectorColumn({ currentMatches, nextMatches, bracketHeight }: Connect
|
|||
|
||||
if (botIdx < currentMatches.length) {
|
||||
const botY = botIdx * currentSlotH + currentSlotH / 2;
|
||||
// U-shape from two source midpoints; horizontal out to next column
|
||||
paths.push(`M 0 ${topY} H ${mid} V ${botY} H 0`);
|
||||
paths.push(`M ${mid} ${midY} H ${CONNECTOR_WIDTH}`);
|
||||
} else {
|
||||
// Odd last match: straight line through
|
||||
paths.push(`M 0 ${topY} H ${CONNECTOR_WIDTH}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Non-standard (byes, play-ins, etc.): trace winners by participantId
|
||||
const winnerToIdx = new Map<string, number>();
|
||||
currentMatches.forEach((m, idx) => {
|
||||
if (m.winnerId) winnerToIdx.set(m.winnerId, idx);
|
||||
});
|
||||
|
||||
nextMatches.forEach((nextMatch, nextIdx) => {
|
||||
const destY = nextIdx * nextSlotH + nextSlotH / 2;
|
||||
for (const pId of [nextMatch.participant1Id, nextMatch.participant2Id]) {
|
||||
if (!pId) continue;
|
||||
const srcIdx = winnerToIdx.get(pId);
|
||||
if (srcIdx === undefined) continue;
|
||||
const srcY = srcIdx * currentSlotH + currentSlotH / 2;
|
||||
paths.push(`M 0 ${srcY} H ${mid} V ${destY} H ${CONNECTOR_WIDTH}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -315,17 +333,20 @@ interface TreeColumnsProps {
|
|||
ownershipMap: Map<string, BracketOwnership>;
|
||||
userParticipantIds: Set<string>;
|
||||
bracketHeight: number;
|
||||
transitionDuration?: number;
|
||||
}
|
||||
|
||||
function TreeColumns({
|
||||
export function TreeColumns({
|
||||
visibleRounds,
|
||||
matchesByRound,
|
||||
ownershipMap,
|
||||
userParticipantIds,
|
||||
bracketHeight,
|
||||
transitionDuration,
|
||||
}: TreeColumnsProps) {
|
||||
const tr = transitionDuration ? `${transitionDuration}ms ease` : undefined;
|
||||
return (
|
||||
<div style={{ display: "flex", width: "100%", height: bracketHeight + LABEL_HEIGHT }}>
|
||||
<div style={{ display: "flex", width: "100%", height: bracketHeight + LABEL_HEIGHT, transition: tr ? `height ${tr}` : undefined }}>
|
||||
{visibleRounds.map((round, ri) => {
|
||||
const roundMatches = matchesByRound.get(round) ?? [];
|
||||
const slotHeight = bracketHeight / Math.max(roundMatches.length, 1);
|
||||
|
|
@ -345,7 +366,7 @@ function TreeColumns({
|
|||
{round}
|
||||
</div>
|
||||
|
||||
<div style={{ position: "relative", height: bracketHeight }}>
|
||||
<div style={{ position: "relative", height: bracketHeight, transition: tr ? `height ${tr}` : undefined }}>
|
||||
{roundMatches.map((match, matchIdx) => (
|
||||
<div
|
||||
key={match.id}
|
||||
|
|
@ -355,6 +376,7 @@ function TreeColumns({
|
|||
left: 0,
|
||||
right: 0,
|
||||
height: cardHeight,
|
||||
transition: tr ? `top ${tr}, height ${tr}` : undefined,
|
||||
}}
|
||||
>
|
||||
<BracketMatchSlot
|
||||
|
|
@ -431,6 +453,13 @@ interface BracketTreePaginatedProps {
|
|||
firstScoringRoundIdx?: number;
|
||||
}
|
||||
|
||||
interface AnimState {
|
||||
fromPage: number;
|
||||
toPage: number;
|
||||
dir: "left" | "right";
|
||||
phase: "sliding" | "settling";
|
||||
}
|
||||
|
||||
export function BracketTreePaginated({
|
||||
rounds,
|
||||
matchesByRound,
|
||||
|
|
@ -438,7 +467,6 @@ export function BracketTreePaginated({
|
|||
userParticipantIds,
|
||||
firstScoringRoundIdx,
|
||||
}: BracketTreePaginatedProps) {
|
||||
// Default to showing around the first scoring round
|
||||
const defaultPage = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
|
|
@ -448,78 +476,149 @@ export function BracketTreePaginated({
|
|||
rounds.length - 2,
|
||||
),
|
||||
);
|
||||
const [pageStart, setPageStart] = useState(defaultPage);
|
||||
const [page, setPage] = useState(defaultPage);
|
||||
const [anim, setAnim] = useState<AnimState | null>(null);
|
||||
const stripRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const visibleRounds = rounds.slice(pageStart, pageStart + 2);
|
||||
const canGoLeft = pageStart > 0;
|
||||
const canGoRight = pageStart + 2 < rounds.length;
|
||||
// Sliding phase: after React paints the strip at its initial offset, trigger the CSS transition
|
||||
useEffect(() => {
|
||||
if (!anim || anim.phase !== "sliding" || !stripRef.current) return;
|
||||
const el = stripRef.current;
|
||||
const finalX = anim.dir === "right" ? -SLOT_WIDTH : 0;
|
||||
const raf = requestAnimationFrame(() => {
|
||||
el.style.transition = "transform 200ms ease";
|
||||
el.style.transform = `translateX(${finalX}px)`;
|
||||
});
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [anim]);
|
||||
|
||||
const maxMatchesInView = Math.max(...visibleRounds.map((r) => matchesByRound.get(r)?.length ?? 0), 1);
|
||||
const bracketHeight = maxMatchesInView * (DESIRED_CARD_HEIGHT + CARD_GAP);
|
||||
// Settling phase: slide done, cards now CSS-transition to toPage layout; clear after transitions
|
||||
useEffect(() => {
|
||||
if (!anim || anim.phase !== "settling") return;
|
||||
const timer = setTimeout(() => setAnim(null), 520);
|
||||
return () => clearTimeout(timer);
|
||||
}, [anim]);
|
||||
|
||||
const navigate = (newPage: number) => {
|
||||
if (anim || newPage < 0 || newPage > rounds.length - 2) return;
|
||||
setAnim({ fromPage: page, toPage: newPage, dir: newPage > page ? "right" : "left", phase: "sliding" });
|
||||
};
|
||||
|
||||
const handleTransitionEnd = () => {
|
||||
if (!anim || anim.phase !== "sliding") return;
|
||||
if (stripRef.current) {
|
||||
stripRef.current.style.transition = "none";
|
||||
stripRef.current.style.transform = "translateX(0)";
|
||||
}
|
||||
setPage(anim.toPage);
|
||||
setAnim(prev => prev ? { ...prev, phase: "settling" } : null);
|
||||
};
|
||||
|
||||
const targetPage = anim ? anim.toPage : page;
|
||||
const labelRounds = rounds.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 max = Math.max(...rs.map((r) => matchesByRound.get(r)?.length ?? 0), 1);
|
||||
return max * (DESIRED_CARD_HEIGHT + CARD_GAP);
|
||||
};
|
||||
|
||||
const pageHeight = calcHeight(page);
|
||||
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;
|
||||
|
||||
// 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
|
||||
// Null: show current page
|
||||
let leftRounds: string[];
|
||||
let rightRounds: string[] = [];
|
||||
let leftHeight: number;
|
||||
let rightHeight = 0;
|
||||
let settlingTransition = false;
|
||||
if (anim?.phase === "sliding") {
|
||||
leftRounds = anim.dir === "right" ? fromRounds : toRounds;
|
||||
rightRounds = anim.dir === "right" ? toRounds : fromRounds;
|
||||
leftHeight = anim.dir === "right" ? animFromHeight : animToHeight;
|
||||
rightHeight = anim.dir === "right" ? animToHeight : animFromHeight;
|
||||
} else if (anim?.phase === "settling") {
|
||||
leftRounds = toRounds;
|
||||
leftHeight = animToHeight;
|
||||
settlingTransition = true;
|
||||
} else {
|
||||
leftRounds = visibleRounds;
|
||||
leftHeight = pageHeight;
|
||||
}
|
||||
|
||||
// During settling, minHeight transitions from animFromHeight to animToHeight in sync with cards
|
||||
const containerMinHeight = anim?.phase === "settling" ? animToHeight : animFromHeight;
|
||||
const initialX = anim?.phase === "sliding" && anim.dir === "left" ? -SLOT_WIDTH : 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Round tab navigation */}
|
||||
<div className="flex items-center gap-1 mb-3 overflow-x-auto pb-1">
|
||||
{rounds.map((round, ri) => {
|
||||
const isActive = ri >= pageStart && ri < pageStart + 2;
|
||||
return (
|
||||
<button
|
||||
key={round}
|
||||
onClick={() => setPageStart(Math.min(ri, rounds.length - 2))}
|
||||
className={[
|
||||
"shrink-0 px-2 py-1 rounded text-[10px] font-medium uppercase tracking-wide transition-colors",
|
||||
isActive
|
||||
? "bg-electric/15 text-electric border border-electric/30"
|
||||
: "text-muted-foreground border border-transparent hover:text-foreground hover:border-border",
|
||||
].join(" ")}
|
||||
>
|
||||
{round}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Bracket columns */}
|
||||
<div style={{ minHeight: bracketHeight + LABEL_HEIGHT + 2 }}>
|
||||
<TreeColumns
|
||||
visibleRounds={visibleRounds}
|
||||
matchesByRound={matchesByRound}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
bracketHeight={bracketHeight}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Prev / Next navigation */}
|
||||
<div className="flex items-center justify-between mt-3">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPageStart((p) => Math.max(0, p - 1))}
|
||||
disabled={!canGoLeft}
|
||||
className="gap-1 text-xs"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(page - 1)}
|
||||
disabled={page === 0 || !!anim}
|
||||
className="h-7 w-7 shrink-0"
|
||||
aria-label="Previous rounds"
|
||||
>
|
||||
<ChevronLeft className="h-3 w-3" />
|
||||
{canGoLeft ? rounds[pageStart - 1] : "—"}
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{visibleRounds[0]}
|
||||
{visibleRounds[1] ? ` → ${visibleRounds[1]}` : ""}
|
||||
<span className="flex-1 text-center text-xs font-medium text-muted-foreground uppercase tracking-wide truncate">
|
||||
{label}
|
||||
</span>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPageStart((p) => Math.min(rounds.length - 2, p + 1))}
|
||||
disabled={!canGoRight}
|
||||
className="gap-1 text-xs"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(page + 1)}
|
||||
disabled={page + 2 >= rounds.length || !!anim}
|
||||
className="h-7 w-7 shrink-0"
|
||||
aria-label="Next rounds"
|
||||
>
|
||||
{canGoRight ? rounds[pageStart + 2] : "—"}
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div style={{ width: SLOT_WIDTH, overflow: "hidden", minHeight: containerMinHeight + LABEL_HEIGHT + 2, transition: settlingTransition ? "min-height 500ms ease" : undefined }}>
|
||||
<div
|
||||
ref={anim?.phase === "sliding" ? stripRef : undefined}
|
||||
style={{
|
||||
display: "flex",
|
||||
width: anim?.phase === "sliding" ? SLOT_WIDTH * 2 : SLOT_WIDTH,
|
||||
transform: anim?.phase === "sliding" ? `translateX(${initialX}px)` : undefined,
|
||||
}}
|
||||
onTransitionEnd={handleTransitionEnd}
|
||||
>
|
||||
<div style={{ width: SLOT_WIDTH, flexShrink: 0 }}>
|
||||
<TreeColumns
|
||||
visibleRounds={leftRounds}
|
||||
matchesByRound={matchesByRound}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
bracketHeight={leftHeight}
|
||||
transitionDuration={settlingTransition ? 500 : undefined}
|
||||
/>
|
||||
</div>
|
||||
{anim?.phase === "sliding" && (
|
||||
<div style={{ width: SLOT_WIDTH, flexShrink: 0 }}>
|
||||
<TreeColumns
|
||||
visibleRounds={rightRounds}
|
||||
matchesByRound={matchesByRound}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
bracketHeight={rightHeight}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
116
app/components/scoring/NbaBracketLayout.tsx
Normal file
116
app/components/scoring/NbaBracketLayout.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import type { ConferenceGroup } from "~/lib/bracket-templates";
|
||||
import {
|
||||
TreeColumns,
|
||||
BracketTreePaginated,
|
||||
type BracketMatch,
|
||||
type BracketOwnership,
|
||||
} from "./BracketTreeView";
|
||||
|
||||
interface NbaBracketLayoutProps {
|
||||
matches: Array<{ round: string; matchNumber: number }>;
|
||||
rounds: string[];
|
||||
matchesByRound: Map<string, BracketMatch[]>;
|
||||
ownershipMap: Map<string, BracketOwnership>;
|
||||
userParticipantIds: Set<string>;
|
||||
conferenceGroups: ConferenceGroup[];
|
||||
scoringRoundIdx: number;
|
||||
}
|
||||
|
||||
const DESIRED_CARD_HEIGHT = 112;
|
||||
const CARD_GAP = 14;
|
||||
|
||||
function splitMatchesByConference(
|
||||
matchesByRound: Map<string, BracketMatch[]>,
|
||||
group: ConferenceGroup
|
||||
): Map<string, BracketMatch[]> {
|
||||
const result = new Map<string, BracketMatch[]>();
|
||||
for (const [round, allowed] of Object.entries(group.roundMatchNumbers)) {
|
||||
const roundMatches = matchesByRound.get(round) ?? [];
|
||||
const filtered = roundMatches.filter((m) => allowed.includes(m.matchNumber));
|
||||
if (filtered.length > 0) result.set(round, filtered);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function bracketHeight(matchesByRound: Map<string, BracketMatch[]>, rounds: string[]): number {
|
||||
const max = Math.max(...rounds.map((r) => matchesByRound.get(r)?.length ?? 0), 1);
|
||||
return max * (DESIRED_CARD_HEIGHT + CARD_GAP);
|
||||
}
|
||||
|
||||
export function NbaBracketLayout({
|
||||
rounds,
|
||||
matchesByRound,
|
||||
ownershipMap,
|
||||
userParticipantIds,
|
||||
conferenceGroups,
|
||||
scoringRoundIdx,
|
||||
}: NbaBracketLayoutProps) {
|
||||
// Rounds that belong to any conference group
|
||||
const conferenceRoundSet = new Set(
|
||||
conferenceGroups.flatMap((g) => Object.keys(g.roundMatchNumbers))
|
||||
);
|
||||
// Rounds not in any group are shared (e.g. NBA Finals)
|
||||
const sharedRounds = rounds.filter((r) => !conferenceRoundSet.has(r));
|
||||
|
||||
// Per-conference round lists (preserving template order)
|
||||
const conferenceRounds = conferenceGroups.map((g) =>
|
||||
rounds.filter((r) => g.roundMatchNumbers[r] !== undefined)
|
||||
);
|
||||
|
||||
// Shared rounds bracket height
|
||||
const sharedMatches = new Map(
|
||||
sharedRounds.map((r) => [r, matchesByRound.get(r) ?? []])
|
||||
);
|
||||
const sharedHeight = bracketHeight(sharedMatches, sharedRounds);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ── Desktop: two-conference stacked layout ── */}
|
||||
<div className="hidden md:flex md:flex-col md:gap-8">
|
||||
{conferenceGroups.map((group, gi) => {
|
||||
const confRounds = conferenceRounds[gi];
|
||||
const confMatches = splitMatchesByConference(matchesByRound, group);
|
||||
const height = bracketHeight(confMatches, confRounds);
|
||||
|
||||
return (
|
||||
<div key={group.name}>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2">
|
||||
{group.name}
|
||||
</p>
|
||||
<TreeColumns
|
||||
visibleRounds={confRounds}
|
||||
matchesByRound={confMatches}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
bracketHeight={height}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{sharedRounds.length > 0 && (
|
||||
<div>
|
||||
<TreeColumns
|
||||
visibleRounds={sharedRounds}
|
||||
matchesByRound={sharedMatches}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
bracketHeight={sharedHeight}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Mobile: paginated across all rounds ── */}
|
||||
<div className="md:hidden">
|
||||
<BracketTreePaginated
|
||||
rounds={rounds}
|
||||
matchesByRound={matchesByRound}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
firstScoringRoundIdx={scoringRoundIdx}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
import { useState } from "react";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
|
|
@ -9,8 +7,7 @@ import {
|
|||
TableHeader,
|
||||
TableRow,
|
||||
} from "~/components/ui/table";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Trophy, Star, Columns2, LayoutList } from "lucide-react";
|
||||
import { Trophy, Star } from "lucide-react";
|
||||
import { TeamOwnerBadge } from "~/components/ui/team-owner-badge";
|
||||
import {
|
||||
BracketTreeView,
|
||||
|
|
@ -18,6 +15,9 @@ import {
|
|||
type BracketMatch,
|
||||
type BracketOwnership,
|
||||
} from "./BracketTreeView";
|
||||
import { getBracketTemplate } from "~/lib/bracket-templates";
|
||||
import { NbaBracketLayout } from "./NbaBracketLayout";
|
||||
import { TabbedBracketLayout } from "./TabbedBracketLayout";
|
||||
|
||||
interface Participant {
|
||||
id: string;
|
||||
|
|
@ -52,6 +52,7 @@ export interface TeamOwnership {
|
|||
interface PlayoffBracketProps {
|
||||
matches: Match[];
|
||||
rounds: string[]; // Ordered list of round names (earliest first)
|
||||
bracketTemplateId?: string | null;
|
||||
preEliminatedParticipants?: { id: string; name: string }[];
|
||||
participantPoints?: { participantId: string; points: number }[];
|
||||
partialScoreParticipantIds?: string[];
|
||||
|
|
@ -173,17 +174,6 @@ export function computeEliminatedByRound(
|
|||
return result;
|
||||
}
|
||||
|
||||
/** Returns true if every adjacent round pair has exactly ceil(prev/2) matches — pure single-elimination. */
|
||||
function isSingleElimination(matchesByRound: Map<string, Match[]>, rounds: string[]): boolean {
|
||||
if (rounds.length < 2) return true;
|
||||
for (let i = 1; i < rounds.length; i++) {
|
||||
const prev = matchesByRound.get(rounds[i - 1])?.length ?? 0;
|
||||
const curr = matchesByRound.get(rounds[i])?.length ?? 0;
|
||||
if (curr !== Math.ceil(prev / 2)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Find the index of the first round that has scoring matches. */
|
||||
function firstScoringRoundIdx(matchesByRound: Map<string, Match[]>, rounds: string[]): number {
|
||||
for (let i = 0; i < rounds.length; i++) {
|
||||
|
|
@ -195,6 +185,7 @@ function firstScoringRoundIdx(matchesByRound: Map<string, Match[]>, rounds: stri
|
|||
export function PlayoffBracket({
|
||||
matches,
|
||||
rounds,
|
||||
bracketTemplateId = null,
|
||||
preEliminatedParticipants = [],
|
||||
participantPoints = [],
|
||||
partialScoreParticipantIds: _partialScoreParticipantIds = [],
|
||||
|
|
@ -210,19 +201,8 @@ export function PlayoffBracket({
|
|||
const pointsMap = new Map(participantPoints.map((p) => [p.participantId, p.points]));
|
||||
|
||||
const matchesByRound = groupMatchesByRound(matches);
|
||||
const feederMap = buildFeederMap(matchesByRound, rounds);
|
||||
|
||||
// Determine if the bracket supports the visual tree view
|
||||
const supportsTreeView = matches.length > 0 && isSingleElimination(matchesByRound, rounds);
|
||||
const scoringRoundIdx = firstScoringRoundIdx(matchesByRound, rounds);
|
||||
|
||||
const [viewMode, setViewMode] = useState<"tree" | "cards">("tree");
|
||||
|
||||
const getTbdLabel = (round: string, matchNumber: number, slot: "p1" | "p2") => {
|
||||
const feeder = feederMap.get(`${round}:${matchNumber}:${slot}`);
|
||||
if (!feeder) return "TBD";
|
||||
return `Winner of ${feeder.round} M${feeder.matchNumber}`;
|
||||
};
|
||||
const template = bracketTemplateId ? getBracketTemplate(bracketTemplateId) : undefined;
|
||||
|
||||
// Build elimination rankings
|
||||
const losersByRound = new Map<string, Array<{ participant: Participant; score: string | null; ownership: TeamOwnership | null }>>();
|
||||
|
|
@ -300,36 +280,10 @@ export function PlayoffBracket({
|
|||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">{title}</h2>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground mt-1">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* View toggle — desktop only, when tree view is supported */}
|
||||
{supportsTreeView && (
|
||||
<div className="hidden md:flex items-center gap-1 rounded-md border border-border p-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`h-7 px-2 ${viewMode === "tree" ? "bg-muted" : ""}`}
|
||||
onClick={() => setViewMode("tree")}
|
||||
title="Bracket tree"
|
||||
>
|
||||
<Columns2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`h-7 px-2 ${viewMode === "cards" ? "bg-muted" : ""}`}
|
||||
onClick={() => setViewMode("cards")}
|
||||
title="Card list"
|
||||
>
|
||||
<LayoutList className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">{title}</h2>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground mt-1">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -342,53 +296,48 @@ export function PlayoffBracket({
|
|||
</div>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{/* ── Desktop: visual tree or card list ── */}
|
||||
{supportsTreeView && viewMode === "tree" ? (
|
||||
<div className="hidden md:block">
|
||||
<BracketTreeView
|
||||
rounds={rounds}
|
||||
matchesByRound={matchesByRound as Map<string, BracketMatch[]>}
|
||||
ownershipMap={ownershipMap as Map<string, BracketOwnership>}
|
||||
userParticipantIds={userParticipantSet}
|
||||
/>
|
||||
</div>
|
||||
{template?.phases ? (
|
||||
<TabbedBracketLayout
|
||||
rounds={rounds}
|
||||
matchesByRound={matchesByRound as Map<string, BracketMatch[]>}
|
||||
ownershipMap={ownershipMap as Map<string, BracketOwnership>}
|
||||
userParticipantIds={userParticipantSet}
|
||||
phases={template.phases}
|
||||
scoringRoundIdx={scoringRoundIdx}
|
||||
/>
|
||||
) : template?.conferenceGroups ? (
|
||||
<NbaBracketLayout
|
||||
matches={matches}
|
||||
rounds={rounds}
|
||||
matchesByRound={matchesByRound as Map<string, BracketMatch[]>}
|
||||
ownershipMap={ownershipMap as Map<string, BracketOwnership>}
|
||||
userParticipantIds={userParticipantSet}
|
||||
conferenceGroups={template.conferenceGroups}
|
||||
scoringRoundIdx={scoringRoundIdx}
|
||||
/>
|
||||
) : (
|
||||
<div className="hidden md:block">
|
||||
<CardListView
|
||||
rounds={rounds}
|
||||
matchesByRound={matchesByRound}
|
||||
<>
|
||||
{/* ── Desktop ── */}
|
||||
<div className="hidden md:block">
|
||||
<BracketTreeView
|
||||
rounds={rounds}
|
||||
matchesByRound={matchesByRound as Map<string, BracketMatch[]>}
|
||||
ownershipMap={ownershipMap as Map<string, BracketOwnership>}
|
||||
userParticipantIds={userParticipantSet}
|
||||
/>
|
||||
</div>
|
||||
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantSet={userParticipantSet}
|
||||
showOwnership={showOwnership}
|
||||
getTbdLabel={getTbdLabel}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Mobile: paginated tree or card list ── */}
|
||||
{supportsTreeView ? (
|
||||
<div className="md:hidden">
|
||||
<BracketTreePaginated
|
||||
rounds={rounds}
|
||||
matchesByRound={matchesByRound as Map<string, BracketMatch[]>}
|
||||
ownershipMap={ownershipMap as Map<string, BracketOwnership>}
|
||||
userParticipantIds={userParticipantSet}
|
||||
firstScoringRoundIdx={scoringRoundIdx}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="md:hidden">
|
||||
<CardListView
|
||||
rounds={rounds}
|
||||
matchesByRound={matchesByRound}
|
||||
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantSet={userParticipantSet}
|
||||
showOwnership={showOwnership}
|
||||
getTbdLabel={getTbdLabel}
|
||||
/>
|
||||
</div>
|
||||
{/* ── Mobile ── */}
|
||||
<div className="md:hidden">
|
||||
<BracketTreePaginated
|
||||
rounds={rounds}
|
||||
matchesByRound={matchesByRound as Map<string, BracketMatch[]>}
|
||||
ownershipMap={ownershipMap as Map<string, BracketOwnership>}
|
||||
userParticipantIds={userParticipantSet}
|
||||
firstScoringRoundIdx={scoringRoundIdx}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── In Contention ── */}
|
||||
|
|
@ -589,176 +538,3 @@ export function PlayoffBracket({
|
|||
);
|
||||
}
|
||||
|
||||
// ─── Card list view (existing style, used as fallback) ───────────────────────
|
||||
|
||||
interface CardListViewProps {
|
||||
rounds: string[];
|
||||
matchesByRound: Map<string, Match[]>;
|
||||
ownershipMap: Map<string, TeamOwnership>;
|
||||
userParticipantSet: Set<string>;
|
||||
showOwnership: boolean;
|
||||
getTbdLabel: (round: string, matchNumber: number, slot: "p1" | "p2") => string;
|
||||
}
|
||||
|
||||
function CardListView({
|
||||
rounds,
|
||||
matchesByRound,
|
||||
ownershipMap,
|
||||
userParticipantSet,
|
||||
showOwnership,
|
||||
getTbdLabel,
|
||||
}: CardListViewProps) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{rounds.map((round) => {
|
||||
const roundMatches = matchesByRound.get(round) || [];
|
||||
if (roundMatches.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div key={round}>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{round}
|
||||
</h3>
|
||||
<div className="flex-1 border-t border-border" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{roundMatches.length}{" "}
|
||||
{roundMatches.length === 1 ? "match" : "matches"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-2">
|
||||
{roundMatches.map((match) => {
|
||||
const p1IsWinner = match.isComplete && match.winnerId === match.participant1Id;
|
||||
const p2IsWinner = match.isComplete && match.winnerId === match.participant2Id;
|
||||
const p1IsLoser = match.isComplete && match.loserId === match.participant1Id;
|
||||
const p2IsLoser = match.isComplete && match.loserId === match.participant2Id;
|
||||
|
||||
const p1Name =
|
||||
match.participant1?.name ||
|
||||
(match.participant1Id ? "TBD" : getTbdLabel(round, match.matchNumber, "p1"));
|
||||
const p2Name =
|
||||
match.participant2?.name ||
|
||||
(match.participant2Id ? "TBD" : getTbdLabel(round, match.matchNumber, "p2"));
|
||||
|
||||
const p1IsTbd = !match.participant1Id;
|
||||
const p2IsTbd = !match.participant2Id;
|
||||
|
||||
const p1IsOwned = !p1IsTbd && userParticipantSet.has(match.participant1Id ?? "");
|
||||
const p2IsOwned = !p2IsTbd && userParticipantSet.has(match.participant2Id ?? "");
|
||||
const matchHasOwned = p1IsOwned || p2IsOwned;
|
||||
|
||||
const p1Ownership = showOwnership && match.participant1Id
|
||||
? ownershipMap.get(match.participant1Id) || null
|
||||
: null;
|
||||
const p2Ownership = showOwnership && match.participant2Id
|
||||
? ownershipMap.get(match.participant2Id) || null
|
||||
: null;
|
||||
|
||||
// Corona: gradient for complete, electric for user's pick, subtle for pending
|
||||
const coronaStyle: React.CSSProperties = match.isComplete
|
||||
? { background: "linear-gradient(to bottom, #adf661, #2ce1c1)" }
|
||||
: matchHasOwned
|
||||
? { background: "rgba(44, 225, 193, 0.4)" }
|
||||
: { background: "rgba(255, 255, 255, 0.07)" };
|
||||
|
||||
return (
|
||||
<div key={match.id} className="relative overflow-hidden rounded-lg">
|
||||
{/* Corona layer — outer overflow-hidden handles all corner rounding */}
|
||||
<div className="absolute top-0.5 bottom-0.5 left-0 right-0" style={coronaStyle} />
|
||||
{/* Inner card — only mr-1 to expose corona on right */}
|
||||
<div className="relative z-10 bg-muted rounded-l-none rounded-r-[5px] px-3 py-2 mr-1">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-mono text-muted-foreground">
|
||||
M{match.matchNumber}
|
||||
</span>
|
||||
{match.isComplete && (
|
||||
<Badge variant="secondary" className="text-xs">Done</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2">
|
||||
{/* Participant 1 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1">
|
||||
{p1IsOwned && !p1IsWinner && (
|
||||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-electric shrink-0" />
|
||||
)}
|
||||
{p1IsWinner && (
|
||||
<Trophy className="h-3.5 w-3.5 text-yellow-500 shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className={[
|
||||
"text-sm font-medium truncate",
|
||||
p1IsLoser ? "text-muted-foreground line-through" : "",
|
||||
p1IsTbd ? "text-muted-foreground italic font-normal" : "",
|
||||
p1IsWinner ? "font-semibold" : "",
|
||||
p1IsOwned && !p1IsLoser ? "text-electric" : "",
|
||||
].filter(Boolean).join(" ")}
|
||||
>
|
||||
{p1Name}
|
||||
</span>
|
||||
</div>
|
||||
{!p1IsTbd && p1Ownership && (
|
||||
<div className="mt-1">
|
||||
<TeamOwnerBadge teamName={p1Ownership.teamName} ownerName={p1Ownership.ownerName} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scores */}
|
||||
<div className="shrink-0 flex sm:flex-col items-center gap-1 sm:gap-0.5 min-w-[2.5rem]">
|
||||
{match.participant1Score ? (
|
||||
<>
|
||||
<span className="text-xs tabular-nums font-medium">
|
||||
{parseFloat(match.participant1Score)}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground/60 uppercase tracking-wider">vs</span>
|
||||
<span className="text-xs tabular-nums font-medium">
|
||||
{match.participant2Score ? parseFloat(match.participant2Score) : "—"}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">vs</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Participant 2 */}
|
||||
<div className="flex-1 min-w-0 flex flex-col sm:items-end">
|
||||
<div className="flex items-center gap-1 sm:justify-end">
|
||||
<span
|
||||
className={[
|
||||
"text-sm font-medium truncate",
|
||||
p2IsLoser ? "text-muted-foreground line-through" : "",
|
||||
p2IsTbd ? "text-muted-foreground italic font-normal" : "",
|
||||
p2IsWinner ? "font-semibold" : "",
|
||||
p2IsOwned && !p2IsLoser ? "text-electric" : "",
|
||||
].filter(Boolean).join(" ")}
|
||||
>
|
||||
{p2Name}
|
||||
</span>
|
||||
{p2IsWinner && (
|
||||
<Trophy className="h-3.5 w-3.5 text-yellow-500 shrink-0" />
|
||||
)}
|
||||
{p2IsOwned && !p2IsWinner && (
|
||||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-electric shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
{!p2IsTbd && p2Ownership && (
|
||||
<div className="mt-1">
|
||||
<TeamOwnerBadge teamName={p2Ownership.teamName} ownerName={p2Ownership.ownerName} align="right" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@ interface SportSeasonDisplayProps {
|
|||
// Playoff data
|
||||
playoffMatches?: Match[];
|
||||
playoffRounds?: string[];
|
||||
bracketTemplateId?: string | null;
|
||||
preEliminatedParticipants?: { id: string; name: string }[];
|
||||
participantPoints?: { participantId: string; points: number }[];
|
||||
partialScoreParticipantIds?: string[];
|
||||
|
|
@ -124,6 +125,7 @@ export function SportSeasonDisplay({
|
|||
sportName: _sportName,
|
||||
playoffMatches = [],
|
||||
playoffRounds = [],
|
||||
bracketTemplateId = null,
|
||||
preEliminatedParticipants = [],
|
||||
participantPoints = [],
|
||||
partialScoreParticipantIds = [],
|
||||
|
|
@ -147,6 +149,7 @@ export function SportSeasonDisplay({
|
|||
<PlayoffBracket
|
||||
matches={playoffMatches}
|
||||
rounds={playoffRounds}
|
||||
bracketTemplateId={bracketTemplateId}
|
||||
preEliminatedParticipants={preEliminatedParticipants}
|
||||
participantPoints={participantPoints}
|
||||
partialScoreParticipantIds={partialScoreParticipantIds}
|
||||
|
|
|
|||
140
app/components/scoring/TabbedBracketLayout.tsx
Normal file
140
app/components/scoring/TabbedBracketLayout.tsx
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import { useState } from "react";
|
||||
import { cn } from "~/lib/utils";
|
||||
import type { BracketPhase, ConferenceGroup } from "~/lib/bracket-templates";
|
||||
import {
|
||||
TreeColumns,
|
||||
BracketTreePaginated,
|
||||
type BracketMatch,
|
||||
type BracketOwnership,
|
||||
} from "./BracketTreeView";
|
||||
|
||||
interface TabbedBracketLayoutProps {
|
||||
rounds: string[];
|
||||
matchesByRound: Map<string, BracketMatch[]>;
|
||||
ownershipMap: Map<string, BracketOwnership>;
|
||||
userParticipantIds: Set<string>;
|
||||
phases: BracketPhase[];
|
||||
scoringRoundIdx: number;
|
||||
}
|
||||
|
||||
const CARD_H = 112;
|
||||
const CARD_GAP = 14;
|
||||
|
||||
function groupMatches(
|
||||
matchesByRound: Map<string, BracketMatch[]>,
|
||||
group: ConferenceGroup
|
||||
): Map<string, BracketMatch[]> {
|
||||
const out = new Map<string, BracketMatch[]>();
|
||||
for (const [round, allowed] of Object.entries(group.roundMatchNumbers)) {
|
||||
const filtered = (matchesByRound.get(round) ?? []).filter((m) =>
|
||||
allowed.includes(m.matchNumber)
|
||||
);
|
||||
if (filtered.length > 0) out.set(round, filtered);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function phaseHeight(matchesByRound: Map<string, BracketMatch[]>, rounds: string[]): number {
|
||||
const max = Math.max(...rounds.map((r) => matchesByRound.get(r)?.length ?? 0), 1);
|
||||
return max * (CARD_H + CARD_GAP);
|
||||
}
|
||||
|
||||
export function TabbedBracketLayout({
|
||||
rounds,
|
||||
matchesByRound,
|
||||
ownershipMap,
|
||||
userParticipantIds,
|
||||
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>
|
||||
|
||||
{/* 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)}
|
||||
/>
|
||||
</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>
|
||||
|
||||
{/* Mobile */}
|
||||
<div className="md:hidden">
|
||||
<BracketTreePaginated
|
||||
rounds={phaseRounds}
|
||||
matchesByRound={phaseMatchesByRound}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
firstScoringRoundIdx={phaseFirstScoringIdx >= 0 ? phaseFirstScoringIdx : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -61,6 +61,35 @@ export interface BracketRegion {
|
|||
playIns: BracketPlayIn[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines a named conference or sub-bracket group within a bracket.
|
||||
* Used to split matches into East/West (NBA) or regional groups (NCAA) for display.
|
||||
*/
|
||||
export interface ConferenceGroup {
|
||||
/** Display name, e.g. "Eastern Conference" */
|
||||
name: string;
|
||||
/**
|
||||
* Maps round name → the match numbers (1-based) that belong to this conference.
|
||||
* Rounds not listed here are either shared (Finals) or not applicable.
|
||||
*/
|
||||
roundMatchNumbers: Record<string, number[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A named tab/section within a bracket display.
|
||||
* Either shows a simple list of rounds, or a set of conference/regional sub-brackets.
|
||||
*/
|
||||
export interface BracketPhase {
|
||||
/** Tab label */
|
||||
name: string;
|
||||
/** Simple ordered list of round names to show in this tab */
|
||||
rounds?: string[];
|
||||
/** Regional/conference sub-groups to show stacked in this tab */
|
||||
groups?: ConferenceGroup[];
|
||||
/** Rounds rendered after groups (e.g. Final Four, Championship) */
|
||||
sharedRounds?: string[];
|
||||
}
|
||||
|
||||
export interface BracketTemplate {
|
||||
/** Unique identifier for this template */
|
||||
id: string;
|
||||
|
|
@ -88,6 +117,17 @@ export interface BracketTemplate {
|
|||
* Length must equal totalTeams.
|
||||
*/
|
||||
participantLabels?: string[];
|
||||
/**
|
||||
* Optional conference/group split for brackets with parallel sub-brackets (NBA).
|
||||
* When present, the UI renders each group as a separate sub-bracket.
|
||||
* Rounds not listed in any group's roundMatchNumbers are treated as shared (e.g., Finals).
|
||||
*/
|
||||
conferenceGroups?: ConferenceGroup[];
|
||||
/**
|
||||
* Optional tabbed phase display (NCAA).
|
||||
* When present, the UI renders a tab switcher with each phase as a section.
|
||||
*/
|
||||
phases?: BracketPhase[];
|
||||
}
|
||||
|
||||
/** All seed numbers in a standard 16-team regional bracket (1–16) */
|
||||
|
|
@ -474,6 +514,55 @@ export const NCAA_68: BracketTemplate = {
|
|||
],
|
||||
},
|
||||
],
|
||||
// Two-tab display: First Four → Bracket (4 regional sub-brackets + Final Four)
|
||||
phases: [
|
||||
{
|
||||
name: "First Four",
|
||||
rounds: ["First Four"],
|
||||
},
|
||||
{
|
||||
name: "Bracket",
|
||||
groups: [
|
||||
{
|
||||
name: "East Region",
|
||||
roundMatchNumbers: {
|
||||
"Round of 64": [1, 2, 3, 4, 5, 6, 7, 8],
|
||||
"Round of 32": [1, 2, 3, 4],
|
||||
"Sweet Sixteen": [1, 2],
|
||||
"Elite Eight": [1],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "South Region",
|
||||
roundMatchNumbers: {
|
||||
"Round of 64": [9, 10, 11, 12, 13, 14, 15, 16],
|
||||
"Round of 32": [5, 6, 7, 8],
|
||||
"Sweet Sixteen": [3, 4],
|
||||
"Elite Eight": [2],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "West Region",
|
||||
roundMatchNumbers: {
|
||||
"Round of 64": [17, 18, 19, 20, 21, 22, 23, 24],
|
||||
"Round of 32": [9, 10, 11, 12],
|
||||
"Sweet Sixteen": [5, 6],
|
||||
"Elite Eight": [3],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Midwest Region",
|
||||
roundMatchNumbers: {
|
||||
"Round of 64": [25, 26, 27, 28, 29, 30, 31, 32],
|
||||
"Round of 32": [13, 14, 15, 16],
|
||||
"Sweet Sixteen": [7, 8],
|
||||
"Elite Eight": [4],
|
||||
},
|
||||
},
|
||||
],
|
||||
sharedRounds: ["Final Four", "Championship"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -767,6 +856,16 @@ export const NBA_20: BracketTemplate = {
|
|||
"West 1", "West 2", "West 3", "West 4", "West 5",
|
||||
"West 6", "West 7", "West 8", "West 9", "West 10",
|
||||
],
|
||||
phases: [
|
||||
{
|
||||
name: "Play-In",
|
||||
rounds: ["Play-In Round 1", "Play-In Round 2"],
|
||||
},
|
||||
{
|
||||
name: "Playoffs",
|
||||
rounds: ["First Round", "Conference Semifinals", "Conference Finals", "NBA Finals"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -166,6 +166,8 @@ export async function loader(args: Route.LoaderArgs) {
|
|||
};
|
||||
let groupStandings: GroupStandingData[] = [];
|
||||
|
||||
let bracketTemplateId: string | null = null;
|
||||
|
||||
if (scoringPattern === "playoff_bracket") {
|
||||
// Fetch playoff matches via scoring events
|
||||
let templateId: string | undefined;
|
||||
|
|
@ -187,6 +189,7 @@ export async function loader(args: Route.LoaderArgs) {
|
|||
playoffMatches = matches as PlayoffMatchWithRelations[];
|
||||
|
||||
templateId = events.find((e) => e.bracketTemplateId)?.bracketTemplateId ?? undefined;
|
||||
bracketTemplateId = templateId ?? null;
|
||||
const template = templateId ? getBracketTemplate(templateId) : undefined;
|
||||
playoffRounds = getOrderedRoundsFromMatches(matches, template);
|
||||
|
||||
|
|
@ -338,5 +341,6 @@ export async function loader(args: Route.LoaderArgs) {
|
|||
regularSeasonStandings,
|
||||
participantEvs,
|
||||
groupStandings,
|
||||
bracketTemplateId,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ export default function SportSeasonDetail({
|
|||
regularSeasonStandings,
|
||||
participantEvs,
|
||||
groupStandings,
|
||||
bracketTemplateId,
|
||||
} = loaderData;
|
||||
|
||||
const hasBracket = playoffMatches && playoffMatches.length > 0;
|
||||
|
|
@ -156,6 +157,7 @@ export default function SportSeasonDetail({
|
|||
sportName={sportsSeason.sport.name}
|
||||
playoffMatches={playoffMatches}
|
||||
playoffRounds={playoffRounds}
|
||||
bracketTemplateId={bracketTemplateId}
|
||||
preEliminatedParticipants={preEliminatedParticipants}
|
||||
participantPoints={participantPoints}
|
||||
partialScoreParticipantIds={partialScoreParticipantIds}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue