brackt/app/components/scoring/PlayoffBracket.tsx

269 lines
8.9 KiB
TypeScript

import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { Badge } from "~/components/ui/badge";
import { Trophy, Users } from "lucide-react";
interface Match {
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;
participant1?: {
id: string;
name: string;
} | null;
participant2?: {
id: string;
name: string;
} | null;
winner?: {
id: string;
name: string;
} | null;
}
interface TeamOwnership {
participantId: string;
teamName: string;
teamId: string;
ownerName?: string; // Optional: user's display name
}
interface PlayoffBracketProps {
matches: Match[];
rounds: string[]; // Ordered list of round names
teamOwnerships?: TeamOwnership[]; // Which teams own which participants
showOwnership?: boolean; // Toggle ownership display
title?: string;
description?: string;
}
/**
* PlayoffBracket component - Displays playoff bracket with optional ownership hints
*
* Features:
* - Visual bracket display grouped by round
* - Shows match winners and scores
* - Ownership hints with team avatars and tooltips (Q15)
* - Responsive layout
*/
export function PlayoffBracket({
matches,
rounds,
teamOwnerships = [],
showOwnership = true,
title = "Playoff Bracket",
description,
}: PlayoffBracketProps) {
// Create a map of participant ID to ownership info for fast lookup
const ownershipMap = new Map<string, TeamOwnership>();
teamOwnerships.forEach((ownership) => {
ownershipMap.set(ownership.participantId, ownership);
});
// Group matches by round
const matchesByRound = rounds.reduce((acc, round) => {
acc[round] = matches.filter((m) => m.round === round);
return acc;
}, {} as Record<string, Match[]>);
// Get ownership info for a participant
const getOwnership = (participantId: string | null): TeamOwnership | null => {
if (!participantId || !showOwnership) return null;
return ownershipMap.get(participantId) || null;
};
// Generate avatar from team name (first letter)
const getTeamAvatar = (teamName: string) => {
const initial = teamName.charAt(0).toUpperCase();
// Use a simple hash to generate a consistent color
const hash = teamName.split("").reduce((acc, char) => acc + char.charCodeAt(0), 0);
const colors = [
"bg-blue-500",
"bg-green-500",
"bg-purple-500",
"bg-pink-500",
"bg-amber-500",
"bg-red-500",
"bg-indigo-500",
"bg-teal-500",
];
const colorClass = colors[hash % colors.length];
return (
<div
className={`h-6 w-6 rounded-full ${colorClass} text-white text-xs font-bold flex items-center justify-center`}
>
{initial}
</div>
);
};
// Render a participant row with optional ownership indicator
const renderParticipant = (
participant: { id: string; name: string } | null | undefined,
participantId: string | null,
isWinner: boolean,
score: string | null
) => {
const ownership = getOwnership(participantId);
const participantName = participant?.name || "TBD";
return (
<div className="flex items-center justify-between gap-2 min-w-0">
<div className="flex items-center gap-2 min-w-0 flex-1">
{isWinner && (
<Trophy className="h-4 w-4 text-yellow-500 shrink-0" />
)}
<span
className={`truncate ${
isWinner ? "font-bold" : "font-medium"
} ${participantName === "TBD" ? "text-muted-foreground italic" : ""}`}
>
{participantName}
</span>
</div>
<div className="flex items-center gap-2 shrink-0">
{score && (
<span className="text-sm text-muted-foreground">
{parseFloat(score)}
</span>
)}
{ownership && (
<div
className="cursor-help"
title={`${ownership.teamName}${ownership.ownerName ? ` (${ownership.ownerName})` : ""}`}
>
{getTeamAvatar(ownership.teamName)}
</div>
)}
</div>
</div>
);
};
// Count how many matches have ownership info
const matchesWithOwnership = matches.filter(
(m) =>
(m.participant1Id && ownershipMap.has(m.participant1Id)) ||
(m.participant2Id && ownershipMap.has(m.participant2Id))
).length;
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5" />
{title}
</CardTitle>
{description && <CardDescription>{description}</CardDescription>}
{showOwnership && matchesWithOwnership > 0 && (
<div className="text-sm text-muted-foreground flex items-center gap-2 mt-2">
<Badge variant="secondary" className="text-xs">
{matchesWithOwnership} match{matchesWithOwnership !== 1 ? "es" : ""} with team ownership
</Badge>
</div>
)}
</CardHeader>
<CardContent>
{matches.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<p className="text-sm">No bracket matches available yet.</p>
<p className="text-xs mt-1">
Matches will appear here once the bracket is set up.
</p>
</div>
) : (
<div className="space-y-6">
{rounds.map((round) => {
const roundMatches = matchesByRound[round] || [];
if (roundMatches.length === 0) return null;
return (
<div key={round} className="space-y-3">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-lg">{round}</h3>
<Badge variant="outline" className="text-xs">
{roundMatches.length} match{roundMatches.length !== 1 ? "es" : ""}
</Badge>
</div>
<div className="grid gap-3">
{roundMatches
.sort((a, b) => a.matchNumber - b.matchNumber)
.map((match) => {
const isComplete = match.isComplete;
const participant1IsWinner =
match.winnerId === match.participant1Id;
const participant2IsWinner =
match.winnerId === match.participant2Id;
return (
<Card
key={match.id}
className={`${
isComplete
? "bg-muted/30"
: "border-primary/20"
}`}
>
<CardContent className="p-4">
<div className="flex items-center justify-between gap-4 mb-2">
<span className="text-xs font-semibold text-muted-foreground">
Match {match.matchNumber}
</span>
{isComplete && (
<Badge variant="secondary" className="text-xs">
Complete
</Badge>
)}
</div>
<div className="space-y-2">
{renderParticipant(
match.participant1,
match.participant1Id,
participant1IsWinner,
match.participant1Score
)}
<div className="flex items-center">
<div className="flex-1 border-t border-muted"></div>
<span className="px-2 text-xs text-muted-foreground">
vs
</span>
<div className="flex-1 border-t border-muted"></div>
</div>
{renderParticipant(
match.participant2,
match.participant2Id,
participant2IsWinner,
match.participant2Score
)}
</div>
</CardContent>
</Card>
);
})}
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
);
}