From 207fc21d67d394a4ef3637b7b05cef9fc834f277 Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Mon, 20 Apr 2026 12:20:51 -0700 Subject: [PATCH] Create bracket look. --- app/components/scoring/BracketTreeView.tsx | 525 ++++++++++++++++++ app/components/scoring/PlayoffBracket.tsx | 487 +++++++++------- ...d.sports-seasons.$sportsSeasonId.server.ts | 3 +- 3 files changed, 820 insertions(+), 195 deletions(-) create mode 100644 app/components/scoring/BracketTreeView.tsx diff --git a/app/components/scoring/BracketTreeView.tsx b/app/components/scoring/BracketTreeView.tsx new file mode 100644 index 0000000..3aae37d --- /dev/null +++ b/app/components/scoring/BracketTreeView.tsx @@ -0,0 +1,525 @@ +import { useState } from "react"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { Button } from "~/components/ui/button"; + +export interface BracketMatch { + id: string; + round: string; + matchNumber: number; + participant1Id: string | null; + participant2Id: string | null; + winnerId: string | null; + loserId: string | null; + isComplete: boolean; + participant1Score: string | null; + participant2Score: string | null; + isScoring?: boolean; + participant1?: { id: string; name: string } | null; + participant2?: { id: string; name: string } | null; + winner?: { id: string; name: string } | null; + loser?: { id: string; name: string } | null; +} + +export interface BracketOwnership { + participantId: string; + teamName: string; + teamId: string; + ownerName?: string; +} + +const COLUMN_WIDTH = 152; +const CONNECTOR_WIDTH = 24; +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 +const MAX_CARD_HEIGHT = 140; // cap for sparse later rounds + +// Avatar color palette (matches TeamOwnerBadge) +const AVATAR_COLORS = ["#adf661", "#2ce1c1", "#8b5cf6", "#f59e0b", "#ef4444", "#3b82f6"]; +function hashName(name: string): number { + let h = 0; + for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) & 0xffff; + return h; +} +function avatarColor(name: string) { + return AVATAR_COLORS[hashName(name) % AVATAR_COLORS.length]; +} + +function formatScore(score: string | null): string | null { + if (!score) return null; + const n = parseFloat(score); + if (isNaN(n)) return null; + return Number.isInteger(n) ? String(n) : n.toFixed(1); +} + +// ─── Match Slot ────────────────────────────────────────────────────────────── + +interface ParticipantRowProps { + name: string | null; + isTbd: boolean; + isWinner: boolean; + isLoser: boolean; + isOwned: boolean; + ownership: BracketOwnership | null; + score: string | null; + rowHeight: number; + showScore: boolean; + showOwner: boolean; + showText: boolean; +} + +function ParticipantRow({ + name, + isTbd, + isWinner, + isLoser, + isOwned, + ownership, + score, + rowHeight, + showScore, + showOwner, + showText, +}: ParticipantRowProps) { + const formattedScore = formatScore(score); + + return ( +
+ {showText && ( +
+ {/* Electric dot for user's pick (when not winner) */} + {isOwned && !isWinner && showText && ( + + )} + +
+ + {name ?? "TBD"} + + + {/* Owner info row */} + {showOwner && !isTbd && ownership && ( +
+
+ {ownership.teamName + .split(/\s+/) + .slice(0, 2) + .map((w) => w[0]?.toUpperCase() ?? "") + .join("")} +
+ + {ownership.ownerName ?? ownership.teamName} + +
+ )} +
+ + {/* Score */} + {showScore && formattedScore && ( + + {formattedScore} + + )} +
+ )} +
+ ); +} + +interface BracketMatchSlotProps { + match: BracketMatch; + slotHeight: number; + ownershipMap: Map; + userParticipantIds: Set; +} + +function BracketMatchSlot({ + match, + slotHeight, + ownershipMap, + userParticipantIds, +}: BracketMatchSlotProps) { + const rowHeight = slotHeight / 2; + const showText = rowHeight >= 10; + const showScore = rowHeight >= 20 && (!!match.participant1Score || match.isComplete); + const showOwner = rowHeight >= 36; + + const p1Id = match.participant1Id; + const p2Id = match.participant2Id; + const p1IsWinner = match.isComplete && match.winnerId === p1Id; + const p2IsWinner = match.isComplete && match.winnerId === p2Id; + const p1IsLoser = match.isComplete && match.loserId === p1Id; + const p2IsLoser = match.isComplete && match.loserId === p2Id; + const p1IsOwned = !!(p1Id && userParticipantIds.has(p1Id)); + const p2IsOwned = !!(p2Id && userParticipantIds.has(p2Id)); + const matchHasOwned = p1IsOwned || p2IsOwned; + const isTbd1 = !p1Id; + const isTbd2 = !p2Id; + + const p1Ownership = p1Id ? ownershipMap.get(p1Id) ?? null : null; + const p2Ownership = p2Id ? ownershipMap.get(p2Id) ?? null : null; + + // Corona glow: gradient for complete matches, electric for user's picks, subtle white 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)" }; + + const INSET = Math.max(1, Math.min(2, Math.floor(slotHeight / 20))); + + return ( +
+ {/* Corona layer — left-2 matches card borderRadius to prevent gradient bleed at left corners */} +
+ + {/* Inner card, inset on right to expose corona glow strip */} +
+ + +
+
+ ); +} + +// ─── Per-pair connector column ──────────────────────────────────────────────── + +interface ConnectorColumnProps { + currentMatches: BracketMatch[]; + nextMatches: BracketMatch[]; + bracketHeight: number; +} + +function ConnectorColumn({ currentMatches, nextMatches, bracketHeight }: ConnectorColumnProps) { + 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); + + for (let k = 0; k < nextMatches.length; k++) { + const topY = (2 * k) * currentSlotH + currentSlotH / 2; + const midY = k * nextSlotH + nextSlotH / 2; + const botIdx = 2 * k + 1; + + 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}`); + } + } + } + + return ( +
+ + {paths.map((d) => ( + + ))} + +
+ ); +} + +// ─── Tree columns (shared by full + paginated) ─────────────────────────────── + +interface TreeColumnsProps { + visibleRounds: string[]; + matchesByRound: Map; + ownershipMap: Map; + userParticipantIds: Set; + bracketHeight: number; +} + +function TreeColumns({ + visibleRounds, + matchesByRound, + ownershipMap, + userParticipantIds, + bracketHeight, +}: TreeColumnsProps) { + return ( +
+ {visibleRounds.map((round, ri) => { + const roundMatches = matchesByRound.get(round) ?? []; + const slotHeight = bracketHeight / Math.max(roundMatches.length, 1); + const cardHeight = Math.min(slotHeight - CARD_GAP, MAX_CARD_HEIGHT); + const cardTop = (slotHeight - cardHeight) / 2; + const nextRound = ri < visibleRounds.length - 1 ? visibleRounds[ri + 1] : null; + const nextMatches = nextRound ? (matchesByRound.get(nextRound) ?? []) : []; + + return ( +
+ {/* Round column */} +
+
+ {round} +
+ +
+ {roundMatches.map((match, matchIdx) => ( +
+ +
+ ))} +
+
+ + {/* Connector between this column and the next */} + {nextRound && ( + + )} +
+ ); + })} +
+ ); +} + +// ─── Full desktop tree ──────────────────────────────────────────────────────── + +interface BracketTreeViewProps { + rounds: string[]; + matchesByRound: Map; + ownershipMap: Map; + userParticipantIds: Set; +} + +export function BracketTreeView({ + rounds, + matchesByRound, + ownershipMap, + userParticipantIds, +}: BracketTreeViewProps) { + const maxMatches = Math.max(...rounds.map((r) => matchesByRound.get(r)?.length ?? 0), 1); + const bracketHeight = maxMatches * (DESIRED_CARD_HEIGHT + CARD_GAP); + const minWidth = rounds.length * COLUMN_WIDTH + Math.max(0, rounds.length - 1) * CONNECTOR_WIDTH; + + return ( +
+
+ +
+
+ ); +} + +// ─── Paginated mobile tree ──────────────────────────────────────────────────── + +interface BracketTreePaginatedProps { + rounds: string[]; + matchesByRound: Map; + ownershipMap: Map; + userParticipantIds: Set; + /** Index of the first scoring round — default page starts here */ + firstScoringRoundIdx?: number; +} + +export function BracketTreePaginated({ + rounds, + matchesByRound, + ownershipMap, + userParticipantIds, + firstScoringRoundIdx, +}: BracketTreePaginatedProps) { + // Default to showing around the first scoring round + const defaultPage = Math.max( + 0, + Math.min( + firstScoringRoundIdx !== undefined + ? Math.max(0, firstScoringRoundIdx - 1) + : rounds.length - 2, + rounds.length - 2, + ), + ); + const [pageStart, setPageStart] = useState(defaultPage); + + const visibleRounds = rounds.slice(pageStart, pageStart + 2); + const canGoLeft = pageStart > 0; + const canGoRight = pageStart + 2 < rounds.length; + + const maxMatchesInView = Math.max(...visibleRounds.map((r) => matchesByRound.get(r)?.length ?? 0), 1); + const bracketHeight = maxMatchesInView * (DESIRED_CARD_HEIGHT + CARD_GAP); + + return ( +
+ {/* Round tab navigation */} +
+ {rounds.map((round, ri) => { + const isActive = ri >= pageStart && ri < pageStart + 2; + return ( + + ); + })} +
+ + {/* Bracket columns */} +
+ +
+ + {/* Prev / Next navigation */} +
+ + + + {visibleRounds[0]} + {visibleRounds[1] ? ` → ${visibleRounds[1]}` : ""} + + + +
+
+ ); +} diff --git a/app/components/scoring/PlayoffBracket.tsx b/app/components/scoring/PlayoffBracket.tsx index cd5238e..7d9cc93 100644 --- a/app/components/scoring/PlayoffBracket.tsx +++ b/app/components/scoring/PlayoffBracket.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { Badge } from "~/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { @@ -8,15 +9,22 @@ import { TableHeader, TableRow, } from "~/components/ui/table"; -import { Trophy, Star } from "lucide-react"; +import { Button } from "~/components/ui/button"; +import { Trophy, Star, Columns2, LayoutList } from "lucide-react"; import { TeamOwnerBadge } from "~/components/ui/team-owner-badge"; +import { + BracketTreeView, + BracketTreePaginated, + type BracketMatch, + type BracketOwnership, +} from "./BracketTreeView"; interface Participant { id: string; name: string; } -interface Match { +export interface Match { id: string; round: string; matchNumber: number; @@ -27,13 +35,14 @@ interface Match { isComplete: boolean; participant1Score: string | null; participant2Score: string | null; + isScoring?: boolean; participant1?: Participant | null; participant2?: Participant | null; winner?: Participant | null; loser?: Participant | null; } -interface TeamOwnership { +export interface TeamOwnership { participantId: string; teamName: string; teamId: string; @@ -43,9 +52,9 @@ interface TeamOwnership { interface PlayoffBracketProps { matches: Match[]; rounds: string[]; // Ordered list of round names (earliest first) - preEliminatedParticipants?: { id: string; name: string }[]; // Eliminated before bracket (e.g. group stage) - participantPoints?: { participantId: string; points: number }[]; // Computed fantasy points per participant - partialScoreParticipantIds?: string[]; // Still-competing participants with provisional floor scores + preEliminatedParticipants?: { id: string; name: string }[]; + participantPoints?: { participantId: string; points: number }[]; + partialScoreParticipantIds?: string[]; teamOwnerships?: TeamOwnership[]; userParticipantIds?: string[]; showOwnership?: boolean; @@ -164,6 +173,25 @@ 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, 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, rounds: string[]): number { + for (let i = 0; i < rounds.length; i++) { + if (matchesByRound.get(rounds[i])?.some((m) => m.isScoring)) return i; + } + return 0; +} + export function PlayoffBracket({ matches, rounds, @@ -181,21 +209,23 @@ export function PlayoffBracket({ teamOwnerships.forEach((o) => ownershipMap.set(o.participantId, o)); const pointsMap = new Map(participantPoints.map((p) => [p.participantId, p.points])); - // Group matches once; reused for feeder map and rendering 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}`; }; - // Build elimination rankings: collect losers per round, then assign rank labels. - // In double-chance brackets (e.g. AFL), a participant may lose one round but - // win a later one — only count them as eliminated at their FINAL losing match. + // Build elimination rankings const losersByRound = new Map>(); - let _hasScore = false; let bracketWinner: Participant | null = null; const lastRound = rounds[rounds.length - 1]; @@ -204,7 +234,6 @@ export function PlayoffBracket({ : null; if (finalMatch?.winner) bracketWinner = finalMatch.winner; - // Compute which participants are eliminated in which round, handling double-chance. const eliminatedByRound = computeEliminatedByRound(matches, rounds); for (const match of matches) { @@ -215,7 +244,6 @@ export function PlayoffBracket({ match.loserId === match.participant1Id ? match.participant1Score : match.participant2Score; - if (loserScore) _hasScore = true; if (!losersByRound.has(match.round)) losersByRound.set(match.round, []); losersByRound.get(match.round)?.push({ participant: match.loser, @@ -224,17 +252,13 @@ export function PlayoffBracket({ }); } - // Walk rounds latest→earliest to assign rank labels. - // Use TOTAL match count per round (not eliminated-so-far) so that partial scoring - // shows the correct eventual rank. E.g. R64 losers in NCAAM 68 always show T33 - // even while other R64 games are still pending. const allBracketParticipantIds = new Set(); for (const match of matches) { if (match.participant1Id) allBracketParticipantIds.add(match.participant1Id); if (match.participant2Id) allBracketParticipantIds.add(match.participant2Id); } const rankedEntries: EliminatedEntry[] = []; - let nextRank = 2; // rank 1 = champion (even if not yet decided) + let nextRank = 2; for (let ri = rounds.length - 1; ri >= 0; ri--) { const roundName = rounds[ri]; const roundLosers = losersByRound.get(roundName) || []; @@ -248,7 +272,6 @@ export function PlayoffBracket({ nextRank += totalMatchesInRound; } - // Exclude pre-eliminated participants already ranked via bracket match losers const rankedParticipantIds = new Set(rankedEntries.map((e) => e.participant.id)); if (bracketWinner) rankedParticipantIds.add(bracketWinner.id); const filteredPreEliminated = preEliminatedParticipants.filter( @@ -257,7 +280,6 @@ export function PlayoffBracket({ const showRankings = rankedEntries.length > 0 || bracketWinner !== null || filteredPreEliminated.length > 0; - // Build participant lookup from match data for the "In Contention" table const participantMap = new Map(); for (const match of matches) { if (match.participant1) participantMap.set(match.participant1.id, match.participant1); @@ -268,21 +290,46 @@ export function PlayoffBracket({ .map((id) => participantMap.get(id)) .filter((p): p is Participant => p !== undefined); - // Hide participants with 0 points that nobody drafted — they add noise without value. const isDraftedOrScoring = (participantId: string) => ownershipMap.has(participantId) || (pointsMap.get(participantId) ?? 0) > 0; - // Hoist winner row lookups so we don't need an IIFE in JSX const winnerIsOwned = bracketWinner ? userParticipantSet.has(bracketWinner.id) : false; const winnerOwnership = bracketWinner ? ownershipMap.get(bracketWinner.id) : undefined; const winnerPts = bracketWinner ? pointsMap.get(bracketWinner.id) : undefined; return (
-
-

{title}

- {description && ( -

{description}

+ {/* Header */} +
+
+

{title}

+ {description && ( +

{description}

+ )} +
+ + {/* View toggle — desktop only, when tree view is supported */} + {supportsTreeView && ( +
+ + +
)}
@@ -295,176 +342,56 @@ export function PlayoffBracket({
) : (
- {rounds.map((round) => { - const roundMatches = matchesByRound.get(round) || []; - if (roundMatches.length === 0) return null; + {/* ── Desktop: visual tree or card list ── */} + {supportsTreeView && viewMode === "tree" ? ( +
+ } + ownershipMap={ownershipMap as Map} + userParticipantIds={userParticipantSet} + /> +
+ ) : ( +
+ -
-

- {round} -

-
- - {roundMatches.length}{" "} - {roundMatches.length === 1 ? "match" : "matches"} - -
+ ownershipMap={ownershipMap} + userParticipantSet={userParticipantSet} + showOwnership={showOwnership} + getTbdLabel={getTbdLabel} + /> +
+ )} -
- {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; + {/* ── Mobile: paginated tree or card list ── */} + {supportsTreeView ? ( +
+ } + ownershipMap={ownershipMap as Map} + userParticipantIds={userParticipantSet} + firstScoringRoundIdx={scoringRoundIdx} + /> +
+ ) : ( +
+ +
+ )} - 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; - - return ( -
- {/* Match label row */} -
- - M{match.matchNumber} - - {match.isComplete && ( - - Done - - )} -
- - {/* Participants: left vs right (stacks on mobile) */} -
- {/* Participant 1 — left-aligned */} -
-
- {p1IsOwned && !p1IsWinner && ( - - )} - {p1IsWinner && ( - - )} - - {p1Name} - -
- {!p1IsTbd && p1Ownership && ( -
- -
- )} -
- - {/* Center: scores + vs */} -
- {match.participant1Score ? ( - <> - - {parseFloat(match.participant1Score)} - - - vs - - - {match.participant2Score - ? parseFloat(match.participant2Score) - : "—"} - - - ) : ( - vs - )} -
- - {/* Participant 2 — right-aligned on sm+, left-aligned on mobile */} -
-
- - {p2Name} - - {p2IsWinner && ( - - )} - {p2IsOwned && !p2IsWinner && ( - - )} -
- {!p2IsTbd && p2Ownership && ( -
- -
- )} -
-
-
- ); - })} -
-
- ); - })} - - {/* In Contention */} + {/* ── In Contention ── */} {activeParticipants.length > 0 && ( @@ -525,7 +452,7 @@ export function PlayoffBracket({ )} - {/* Final Rankings / Eliminated Teams */} + {/* ── Final Rankings ── */} {showRankings && ( @@ -661,3 +588,177 @@ export function PlayoffBracket({
); } + +// ─── Card list view (existing style, used as fallback) ─────────────────────── + +interface CardListViewProps { + rounds: string[]; + matchesByRound: Map; + ownershipMap: Map; + userParticipantSet: Set; + showOwnership: boolean; + getTbdLabel: (round: string, matchNumber: number, slot: "p1" | "p2") => string; +} + +function CardListView({ + rounds, + matchesByRound, + ownershipMap, + userParticipantSet, + showOwnership, + getTbdLabel, +}: CardListViewProps) { + return ( +
+ {rounds.map((round) => { + const roundMatches = matchesByRound.get(round) || []; + if (roundMatches.length === 0) return null; + + return ( +
+
+

+ {round} +

+
+ + {roundMatches.length}{" "} + {roundMatches.length === 1 ? "match" : "matches"} + +
+ +
+ {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 ( +
+ {/* Corona layer — outer overflow-hidden handles all corner rounding */} +
+ {/* Inner card — only mr-1 to expose corona on right */} +
+
+ + M{match.matchNumber} + + {match.isComplete && ( + Done + )} +
+ +
+ {/* Participant 1 */} +
+
+ {p1IsOwned && !p1IsWinner && ( + + )} + {p1IsWinner && ( + + )} + + {p1Name} + +
+ {!p1IsTbd && p1Ownership && ( +
+ +
+ )} +
+ + {/* Scores */} +
+ {match.participant1Score ? ( + <> + + {parseFloat(match.participant1Score)} + + vs + + {match.participant2Score ? parseFloat(match.participant2Score) : "—"} + + + ) : ( + vs + )} +
+ + {/* Participant 2 */} +
+
+ + {p2Name} + + {p2IsWinner && ( + + )} + {p2IsOwned && !p2IsWinner && ( + + )} +
+ {!p2IsTbd && p2Ownership && ( +
+ +
+ )} +
+
+
+
+ ); + })} +
+
+ ); + })} +
+ ); +} diff --git a/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.server.ts b/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.server.ts index 3ba1b6c..80e7e03 100644 --- a/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.server.ts +++ b/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.server.ts @@ -168,12 +168,11 @@ export async function loader(args: Route.LoaderArgs) { if (scoringPattern === "playoff_bracket") { // Fetch playoff matches via scoring events + let templateId: string | undefined; const events = await db.query.scoringEvents.findMany({ where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId), }); - let templateId: string | undefined; - if (events.length > 0) { const eventIds = events.map((e) => e.id); const matches = await db.query.playoffMatches.findMany({