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 (
+
+
+
+ );
+}
+
+// ─── 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({