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 { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
|
|
||||||
|
|
@ -29,6 +29,7 @@ export interface BracketOwnership {
|
||||||
|
|
||||||
const COLUMN_WIDTH = 152;
|
const COLUMN_WIDTH = 152;
|
||||||
const CONNECTOR_WIDTH = 24;
|
const CONNECTOR_WIDTH = 24;
|
||||||
|
const SLOT_WIDTH = 2 * COLUMN_WIDTH + CONNECTOR_WIDTH; // viewport width for 2 columns
|
||||||
const LABEL_HEIGHT = 32;
|
const LABEL_HEIGHT = 32;
|
||||||
const CARD_GAP = 14; // vertical gap between cards (split top/bottom)
|
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
|
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 mid = CONNECTOR_WIDTH / 2;
|
||||||
const paths: string[] = [];
|
const paths: string[] = [];
|
||||||
|
|
||||||
if (nextMatches.length === Math.ceil(currentMatches.length / 2)) {
|
|
||||||
const currentSlotH = bracketHeight / Math.max(currentMatches.length, 1);
|
const currentSlotH = bracketHeight / Math.max(currentMatches.length, 1);
|
||||||
const nextSlotH = bracketHeight / Math.max(nextMatches.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++) {
|
for (let k = 0; k < nextMatches.length; k++) {
|
||||||
const topY = (2 * k) * currentSlotH + currentSlotH / 2;
|
const topY = (2 * k) * currentSlotH + currentSlotH / 2;
|
||||||
const midY = k * nextSlotH + nextSlotH / 2;
|
const midY = k * nextSlotH + nextSlotH / 2;
|
||||||
|
|
@ -276,14 +279,29 @@ function ConnectorColumn({ currentMatches, nextMatches, bracketHeight }: Connect
|
||||||
|
|
||||||
if (botIdx < currentMatches.length) {
|
if (botIdx < currentMatches.length) {
|
||||||
const botY = botIdx * currentSlotH + currentSlotH / 2;
|
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 0 ${topY} H ${mid} V ${botY} H 0`);
|
||||||
paths.push(`M ${mid} ${midY} H ${CONNECTOR_WIDTH}`);
|
paths.push(`M ${mid} ${midY} H ${CONNECTOR_WIDTH}`);
|
||||||
} else {
|
} else {
|
||||||
// Odd last match: straight line through
|
|
||||||
paths.push(`M 0 ${topY} H ${CONNECTOR_WIDTH}`);
|
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 (
|
return (
|
||||||
|
|
@ -315,17 +333,20 @@ interface TreeColumnsProps {
|
||||||
ownershipMap: Map<string, BracketOwnership>;
|
ownershipMap: Map<string, BracketOwnership>;
|
||||||
userParticipantIds: Set<string>;
|
userParticipantIds: Set<string>;
|
||||||
bracketHeight: number;
|
bracketHeight: number;
|
||||||
|
transitionDuration?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function TreeColumns({
|
export function TreeColumns({
|
||||||
visibleRounds,
|
visibleRounds,
|
||||||
matchesByRound,
|
matchesByRound,
|
||||||
ownershipMap,
|
ownershipMap,
|
||||||
userParticipantIds,
|
userParticipantIds,
|
||||||
bracketHeight,
|
bracketHeight,
|
||||||
|
transitionDuration,
|
||||||
}: TreeColumnsProps) {
|
}: TreeColumnsProps) {
|
||||||
|
const tr = transitionDuration ? `${transitionDuration}ms ease` : undefined;
|
||||||
return (
|
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) => {
|
{visibleRounds.map((round, ri) => {
|
||||||
const roundMatches = matchesByRound.get(round) ?? [];
|
const roundMatches = matchesByRound.get(round) ?? [];
|
||||||
const slotHeight = bracketHeight / Math.max(roundMatches.length, 1);
|
const slotHeight = bracketHeight / Math.max(roundMatches.length, 1);
|
||||||
|
|
@ -345,7 +366,7 @@ function TreeColumns({
|
||||||
{round}
|
{round}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ position: "relative", height: bracketHeight }}>
|
<div style={{ position: "relative", height: bracketHeight, transition: tr ? `height ${tr}` : undefined }}>
|
||||||
{roundMatches.map((match, matchIdx) => (
|
{roundMatches.map((match, matchIdx) => (
|
||||||
<div
|
<div
|
||||||
key={match.id}
|
key={match.id}
|
||||||
|
|
@ -355,6 +376,7 @@ function TreeColumns({
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
height: cardHeight,
|
height: cardHeight,
|
||||||
|
transition: tr ? `top ${tr}, height ${tr}` : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<BracketMatchSlot
|
<BracketMatchSlot
|
||||||
|
|
@ -431,6 +453,13 @@ interface BracketTreePaginatedProps {
|
||||||
firstScoringRoundIdx?: number;
|
firstScoringRoundIdx?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AnimState {
|
||||||
|
fromPage: number;
|
||||||
|
toPage: number;
|
||||||
|
dir: "left" | "right";
|
||||||
|
phase: "sliding" | "settling";
|
||||||
|
}
|
||||||
|
|
||||||
export function BracketTreePaginated({
|
export function BracketTreePaginated({
|
||||||
rounds,
|
rounds,
|
||||||
matchesByRound,
|
matchesByRound,
|
||||||
|
|
@ -438,7 +467,6 @@ export function BracketTreePaginated({
|
||||||
userParticipantIds,
|
userParticipantIds,
|
||||||
firstScoringRoundIdx,
|
firstScoringRoundIdx,
|
||||||
}: BracketTreePaginatedProps) {
|
}: BracketTreePaginatedProps) {
|
||||||
// Default to showing around the first scoring round
|
|
||||||
const defaultPage = Math.max(
|
const defaultPage = Math.max(
|
||||||
0,
|
0,
|
||||||
Math.min(
|
Math.min(
|
||||||
|
|
@ -448,77 +476,148 @@ export function BracketTreePaginated({
|
||||||
rounds.length - 2,
|
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);
|
// Sliding phase: after React paints the strip at its initial offset, trigger the CSS transition
|
||||||
const canGoLeft = pageStart > 0;
|
useEffect(() => {
|
||||||
const canGoRight = pageStart + 2 < rounds.length;
|
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);
|
// Settling phase: slide done, cards now CSS-transition to toPage layout; clear after transitions
|
||||||
const bracketHeight = maxMatchesInView * (DESIRED_CARD_HEIGHT + CARD_GAP);
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Round tab navigation */}
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<div className="flex items-center gap-1 mb-3 overflow-x-auto pb-1">
|
<Button
|
||||||
{rounds.map((round, ri) => {
|
variant="ghost"
|
||||||
const isActive = ri >= pageStart && ri < pageStart + 2;
|
size="icon"
|
||||||
return (
|
onClick={() => navigate(page - 1)}
|
||||||
<button
|
disabled={page === 0 || !!anim}
|
||||||
key={round}
|
className="h-7 w-7 shrink-0"
|
||||||
onClick={() => setPageStart(Math.min(ri, rounds.length - 2))}
|
aria-label="Previous rounds"
|
||||||
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}
|
<ChevronLeft className="h-4 w-4" />
|
||||||
</button>
|
</Button>
|
||||||
);
|
<span className="flex-1 text-center text-xs font-medium text-muted-foreground uppercase tracking-wide truncate">
|
||||||
})}
|
{label}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bracket columns */}
|
<div style={{ width: SLOT_WIDTH, overflow: "hidden", minHeight: containerMinHeight + LABEL_HEIGHT + 2, transition: settlingTransition ? "min-height 500ms ease" : undefined }}>
|
||||||
<div style={{ minHeight: bracketHeight + LABEL_HEIGHT + 2 }}>
|
<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
|
<TreeColumns
|
||||||
visibleRounds={visibleRounds}
|
visibleRounds={leftRounds}
|
||||||
matchesByRound={matchesByRound}
|
matchesByRound={matchesByRound}
|
||||||
ownershipMap={ownershipMap}
|
ownershipMap={ownershipMap}
|
||||||
userParticipantIds={userParticipantIds}
|
userParticipantIds={userParticipantIds}
|
||||||
bracketHeight={bracketHeight}
|
bracketHeight={leftHeight}
|
||||||
|
transitionDuration={settlingTransition ? 500 : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{anim?.phase === "sliding" && (
|
||||||
{/* Prev / Next navigation */}
|
<div style={{ width: SLOT_WIDTH, flexShrink: 0 }}>
|
||||||
<div className="flex items-center justify-between mt-3">
|
<TreeColumns
|
||||||
<Button
|
visibleRounds={rightRounds}
|
||||||
variant="outline"
|
matchesByRound={matchesByRound}
|
||||||
size="sm"
|
ownershipMap={ownershipMap}
|
||||||
onClick={() => setPageStart((p) => Math.max(0, p - 1))}
|
userParticipantIds={userParticipantIds}
|
||||||
disabled={!canGoLeft}
|
bracketHeight={rightHeight}
|
||||||
className="gap-1 text-xs"
|
/>
|
||||||
>
|
</div>
|
||||||
<ChevronLeft className="h-3 w-3" />
|
)}
|
||||||
{canGoLeft ? rounds[pageStart - 1] : "—"}
|
</div>
|
||||||
</Button>
|
|
||||||
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{visibleRounds[0]}
|
|
||||||
{visibleRounds[1] ? ` → ${visibleRounds[1]}` : ""}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setPageStart((p) => Math.min(rounds.length - 2, p + 1))}
|
|
||||||
disabled={!canGoRight}
|
|
||||||
className="gap-1 text-xs"
|
|
||||||
>
|
|
||||||
{canGoRight ? rounds[pageStart + 2] : "—"}
|
|
||||||
<ChevronRight className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</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 { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
|
|
@ -9,8 +7,7 @@ import {
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "~/components/ui/table";
|
} from "~/components/ui/table";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Trophy, Star } from "lucide-react";
|
||||||
import { Trophy, Star, Columns2, LayoutList } from "lucide-react";
|
|
||||||
import { TeamOwnerBadge } from "~/components/ui/team-owner-badge";
|
import { TeamOwnerBadge } from "~/components/ui/team-owner-badge";
|
||||||
import {
|
import {
|
||||||
BracketTreeView,
|
BracketTreeView,
|
||||||
|
|
@ -18,6 +15,9 @@ import {
|
||||||
type BracketMatch,
|
type BracketMatch,
|
||||||
type BracketOwnership,
|
type BracketOwnership,
|
||||||
} from "./BracketTreeView";
|
} from "./BracketTreeView";
|
||||||
|
import { getBracketTemplate } from "~/lib/bracket-templates";
|
||||||
|
import { NbaBracketLayout } from "./NbaBracketLayout";
|
||||||
|
import { TabbedBracketLayout } from "./TabbedBracketLayout";
|
||||||
|
|
||||||
interface Participant {
|
interface Participant {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -52,6 +52,7 @@ export interface TeamOwnership {
|
||||||
interface PlayoffBracketProps {
|
interface PlayoffBracketProps {
|
||||||
matches: Match[];
|
matches: Match[];
|
||||||
rounds: string[]; // Ordered list of round names (earliest first)
|
rounds: string[]; // Ordered list of round names (earliest first)
|
||||||
|
bracketTemplateId?: string | null;
|
||||||
preEliminatedParticipants?: { id: string; name: string }[];
|
preEliminatedParticipants?: { id: string; name: string }[];
|
||||||
participantPoints?: { participantId: string; points: number }[];
|
participantPoints?: { participantId: string; points: number }[];
|
||||||
partialScoreParticipantIds?: string[];
|
partialScoreParticipantIds?: string[];
|
||||||
|
|
@ -173,17 +174,6 @@ export function computeEliminatedByRound(
|
||||||
return result;
|
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. */
|
/** Find the index of the first round that has scoring matches. */
|
||||||
function firstScoringRoundIdx(matchesByRound: Map<string, Match[]>, rounds: string[]): number {
|
function firstScoringRoundIdx(matchesByRound: Map<string, Match[]>, rounds: string[]): number {
|
||||||
for (let i = 0; i < rounds.length; i++) {
|
for (let i = 0; i < rounds.length; i++) {
|
||||||
|
|
@ -195,6 +185,7 @@ function firstScoringRoundIdx(matchesByRound: Map<string, Match[]>, rounds: stri
|
||||||
export function PlayoffBracket({
|
export function PlayoffBracket({
|
||||||
matches,
|
matches,
|
||||||
rounds,
|
rounds,
|
||||||
|
bracketTemplateId = null,
|
||||||
preEliminatedParticipants = [],
|
preEliminatedParticipants = [],
|
||||||
participantPoints = [],
|
participantPoints = [],
|
||||||
partialScoreParticipantIds: _partialScoreParticipantIds = [],
|
partialScoreParticipantIds: _partialScoreParticipantIds = [],
|
||||||
|
|
@ -210,19 +201,8 @@ export function PlayoffBracket({
|
||||||
const pointsMap = new Map(participantPoints.map((p) => [p.participantId, p.points]));
|
const pointsMap = new Map(participantPoints.map((p) => [p.participantId, p.points]));
|
||||||
|
|
||||||
const matchesByRound = groupMatchesByRound(matches);
|
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 scoringRoundIdx = firstScoringRoundIdx(matchesByRound, rounds);
|
||||||
|
const template = bracketTemplateId ? getBracketTemplate(bracketTemplateId) : undefined;
|
||||||
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}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Build elimination rankings
|
// Build elimination rankings
|
||||||
const losersByRound = new Map<string, Array<{ participant: Participant; score: string | null; ownership: TeamOwnership | null }>>();
|
const losersByRound = new Map<string, Array<{ participant: Participant; score: string | null; ownership: TeamOwnership | null }>>();
|
||||||
|
|
@ -300,7 +280,6 @@ export function PlayoffBracket({
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-semibold">{title}</h2>
|
<h2 className="text-xl font-semibold">{title}</h2>
|
||||||
{description && (
|
{description && (
|
||||||
|
|
@ -308,31 +287,6 @@ export function PlayoffBracket({
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
|
||||||
|
|
||||||
{matches.length === 0 ? (
|
{matches.length === 0 ? (
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
<p className="text-sm">No bracket matches available yet.</p>
|
<p className="text-sm">No bracket matches available yet.</p>
|
||||||
|
|
@ -342,8 +296,28 @@ export function PlayoffBracket({
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* ── Desktop: visual tree or card list ── */}
|
{template?.phases ? (
|
||||||
{supportsTreeView && viewMode === "tree" ? (
|
<TabbedBracketLayout
|
||||||
|
rounds={rounds}
|
||||||
|
matchesByRound={matchesByRound as Map<string, BracketMatch[]>}
|
||||||
|
ownershipMap={ownershipMap as Map<string, BracketOwnership>}
|
||||||
|
userParticipantIds={userParticipantSet}
|
||||||
|
phases={template.phases}
|
||||||
|
scoringRoundIdx={scoringRoundIdx}
|
||||||
|
/>
|
||||||
|
) : template?.conferenceGroups ? (
|
||||||
|
<NbaBracketLayout
|
||||||
|
matches={matches}
|
||||||
|
rounds={rounds}
|
||||||
|
matchesByRound={matchesByRound as Map<string, BracketMatch[]>}
|
||||||
|
ownershipMap={ownershipMap as Map<string, BracketOwnership>}
|
||||||
|
userParticipantIds={userParticipantSet}
|
||||||
|
conferenceGroups={template.conferenceGroups}
|
||||||
|
scoringRoundIdx={scoringRoundIdx}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* ── Desktop ── */}
|
||||||
<div className="hidden md:block">
|
<div className="hidden md:block">
|
||||||
<BracketTreeView
|
<BracketTreeView
|
||||||
rounds={rounds}
|
rounds={rounds}
|
||||||
|
|
@ -352,22 +326,8 @@ export function PlayoffBracket({
|
||||||
userParticipantIds={userParticipantSet}
|
userParticipantIds={userParticipantSet}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className="hidden md:block">
|
|
||||||
<CardListView
|
|
||||||
rounds={rounds}
|
|
||||||
matchesByRound={matchesByRound}
|
|
||||||
|
|
||||||
ownershipMap={ownershipMap}
|
{/* ── Mobile ── */}
|
||||||
userParticipantSet={userParticipantSet}
|
|
||||||
showOwnership={showOwnership}
|
|
||||||
getTbdLabel={getTbdLabel}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Mobile: paginated tree or card list ── */}
|
|
||||||
{supportsTreeView ? (
|
|
||||||
<div className="md:hidden">
|
<div className="md:hidden">
|
||||||
<BracketTreePaginated
|
<BracketTreePaginated
|
||||||
rounds={rounds}
|
rounds={rounds}
|
||||||
|
|
@ -377,18 +337,7 @@ export function PlayoffBracket({
|
||||||
firstScoringRoundIdx={scoringRoundIdx}
|
firstScoringRoundIdx={scoringRoundIdx}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</>
|
||||||
<div className="md:hidden">
|
|
||||||
<CardListView
|
|
||||||
rounds={rounds}
|
|
||||||
matchesByRound={matchesByRound}
|
|
||||||
|
|
||||||
ownershipMap={ownershipMap}
|
|
||||||
userParticipantSet={userParticipantSet}
|
|
||||||
showOwnership={showOwnership}
|
|
||||||
getTbdLabel={getTbdLabel}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── In Contention ── */}
|
{/* ── 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
|
// Playoff data
|
||||||
playoffMatches?: Match[];
|
playoffMatches?: Match[];
|
||||||
playoffRounds?: string[];
|
playoffRounds?: string[];
|
||||||
|
bracketTemplateId?: string | null;
|
||||||
preEliminatedParticipants?: { id: string; name: string }[];
|
preEliminatedParticipants?: { id: string; name: string }[];
|
||||||
participantPoints?: { participantId: string; points: number }[];
|
participantPoints?: { participantId: string; points: number }[];
|
||||||
partialScoreParticipantIds?: string[];
|
partialScoreParticipantIds?: string[];
|
||||||
|
|
@ -124,6 +125,7 @@ export function SportSeasonDisplay({
|
||||||
sportName: _sportName,
|
sportName: _sportName,
|
||||||
playoffMatches = [],
|
playoffMatches = [],
|
||||||
playoffRounds = [],
|
playoffRounds = [],
|
||||||
|
bracketTemplateId = null,
|
||||||
preEliminatedParticipants = [],
|
preEliminatedParticipants = [],
|
||||||
participantPoints = [],
|
participantPoints = [],
|
||||||
partialScoreParticipantIds = [],
|
partialScoreParticipantIds = [],
|
||||||
|
|
@ -147,6 +149,7 @@ export function SportSeasonDisplay({
|
||||||
<PlayoffBracket
|
<PlayoffBracket
|
||||||
matches={playoffMatches}
|
matches={playoffMatches}
|
||||||
rounds={playoffRounds}
|
rounds={playoffRounds}
|
||||||
|
bracketTemplateId={bracketTemplateId}
|
||||||
preEliminatedParticipants={preEliminatedParticipants}
|
preEliminatedParticipants={preEliminatedParticipants}
|
||||||
participantPoints={participantPoints}
|
participantPoints={participantPoints}
|
||||||
partialScoreParticipantIds={partialScoreParticipantIds}
|
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[];
|
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 {
|
export interface BracketTemplate {
|
||||||
/** Unique identifier for this template */
|
/** Unique identifier for this template */
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -88,6 +117,17 @@ export interface BracketTemplate {
|
||||||
* Length must equal totalTeams.
|
* Length must equal totalTeams.
|
||||||
*/
|
*/
|
||||||
participantLabels?: string[];
|
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) */
|
/** 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 1", "West 2", "West 3", "West 4", "West 5",
|
||||||
"West 6", "West 7", "West 8", "West 9", "West 10",
|
"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 groupStandings: GroupStandingData[] = [];
|
||||||
|
|
||||||
|
let bracketTemplateId: string | null = null;
|
||||||
|
|
||||||
if (scoringPattern === "playoff_bracket") {
|
if (scoringPattern === "playoff_bracket") {
|
||||||
// Fetch playoff matches via scoring events
|
// Fetch playoff matches via scoring events
|
||||||
let templateId: string | undefined;
|
let templateId: string | undefined;
|
||||||
|
|
@ -187,6 +189,7 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
playoffMatches = matches as PlayoffMatchWithRelations[];
|
playoffMatches = matches as PlayoffMatchWithRelations[];
|
||||||
|
|
||||||
templateId = events.find((e) => e.bracketTemplateId)?.bracketTemplateId ?? undefined;
|
templateId = events.find((e) => e.bracketTemplateId)?.bracketTemplateId ?? undefined;
|
||||||
|
bracketTemplateId = templateId ?? null;
|
||||||
const template = templateId ? getBracketTemplate(templateId) : undefined;
|
const template = templateId ? getBracketTemplate(templateId) : undefined;
|
||||||
playoffRounds = getOrderedRoundsFromMatches(matches, template);
|
playoffRounds = getOrderedRoundsFromMatches(matches, template);
|
||||||
|
|
||||||
|
|
@ -338,5 +341,6 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
regularSeasonStandings,
|
regularSeasonStandings,
|
||||||
participantEvs,
|
participantEvs,
|
||||||
groupStandings,
|
groupStandings,
|
||||||
|
bracketTemplateId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,7 @@ export default function SportSeasonDetail({
|
||||||
regularSeasonStandings,
|
regularSeasonStandings,
|
||||||
participantEvs,
|
participantEvs,
|
||||||
groupStandings,
|
groupStandings,
|
||||||
|
bracketTemplateId,
|
||||||
} = loaderData;
|
} = loaderData;
|
||||||
|
|
||||||
const hasBracket = playoffMatches && playoffMatches.length > 0;
|
const hasBracket = playoffMatches && playoffMatches.length > 0;
|
||||||
|
|
@ -156,6 +157,7 @@ export default function SportSeasonDetail({
|
||||||
sportName={sportsSeason.sport.name}
|
sportName={sportsSeason.sport.name}
|
||||||
playoffMatches={playoffMatches}
|
playoffMatches={playoffMatches}
|
||||||
playoffRounds={playoffRounds}
|
playoffRounds={playoffRounds}
|
||||||
|
bracketTemplateId={bracketTemplateId}
|
||||||
preEliminatedParticipants={preEliminatedParticipants}
|
preEliminatedParticipants={preEliminatedParticipants}
|
||||||
participantPoints={participantPoints}
|
participantPoints={participantPoints}
|
||||||
partialScoreParticipantIds={partialScoreParticipantIds}
|
partialScoreParticipantIds={partialScoreParticipantIds}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue