269 lines
8.9 KiB
TypeScript
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>
|
|
);
|
|
}
|