feat: Add routes and components for sports season display, including playoff brackets and standings
This commit is contained in:
parent
4d5b4efc23
commit
604eb6980c
7 changed files with 1080 additions and 14 deletions
269
app/components/scoring/PlayoffBracket.tsx
Normal file
269
app/components/scoring/PlayoffBracket.tsx
Normal file
|
|
@ -0,0 +1,269 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
327
app/components/scoring/SeasonStandings.tsx
Normal file
327
app/components/scoring/SeasonStandings.tsx
Normal file
|
|
@ -0,0 +1,327 @@
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "~/components/ui/card";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "~/components/ui/table";
|
||||||
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
import { TrendingUp, TrendingDown, Minus, Flag, CheckCircle2 } from "lucide-react";
|
||||||
|
|
||||||
|
interface SeasonStanding {
|
||||||
|
id: string;
|
||||||
|
championshipPoints: string; // Decimal as string
|
||||||
|
position: number;
|
||||||
|
previousPosition?: number | null; // For showing movement
|
||||||
|
participant: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TeamOwnership {
|
||||||
|
participantId: string;
|
||||||
|
teamName: string;
|
||||||
|
teamId: string;
|
||||||
|
ownerName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SeasonStandingsProps {
|
||||||
|
standings: SeasonStanding[];
|
||||||
|
teamOwnerships?: TeamOwnership[]; // Which teams own which participants
|
||||||
|
showOwnership?: boolean;
|
||||||
|
isFinalized?: boolean; // Whether season is complete
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SeasonStandings component - Displays F1-style championship standings
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Shows participants ranked by championship points
|
||||||
|
* - Positions auto-calculated from points (highest = 1st)
|
||||||
|
* - Optional ownership hints with team avatars
|
||||||
|
* - Movement indicators (position changes)
|
||||||
|
* - Handles ties (same points = same position)
|
||||||
|
*/
|
||||||
|
export function SeasonStandings({
|
||||||
|
standings,
|
||||||
|
teamOwnerships = [],
|
||||||
|
showOwnership = true,
|
||||||
|
isFinalized = false,
|
||||||
|
title = "Championship Standings",
|
||||||
|
description,
|
||||||
|
}: SeasonStandingsProps) {
|
||||||
|
// Create ownership map for fast lookup
|
||||||
|
const ownershipMap = new Map<string, TeamOwnership>();
|
||||||
|
teamOwnerships.forEach((ownership) => {
|
||||||
|
ownershipMap.set(ownership.participantId, ownership);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get ownership info for a participant
|
||||||
|
const getOwnership = (participantId: string): TeamOwnership | null => {
|
||||||
|
if (!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();
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get movement indicator
|
||||||
|
const getMovementIndicator = (
|
||||||
|
currentPosition: number,
|
||||||
|
previousPosition?: number | null
|
||||||
|
) => {
|
||||||
|
if (!previousPosition) return null;
|
||||||
|
|
||||||
|
const change = previousPosition - currentPosition; // Positive means moved up
|
||||||
|
|
||||||
|
if (change > 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1 text-green-600 dark:text-green-400">
|
||||||
|
<TrendingUp className="h-3 w-3" />
|
||||||
|
<span className="text-xs">+{change}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (change < 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1 text-red-600 dark:text-red-400">
|
||||||
|
<TrendingDown className="h-3 w-3" />
|
||||||
|
<span className="text-xs">{change}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1 text-muted-foreground">
|
||||||
|
<Minus className="h-3 w-3" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get position badge with medals for top 3
|
||||||
|
const getPositionBadge = (position: number, isTied: boolean) => {
|
||||||
|
const positionText = isTied ? `T${position}` : position.toString();
|
||||||
|
|
||||||
|
if (position === 1 && !isTied) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xl">🥇</span>
|
||||||
|
<span className="font-bold text-lg">{positionText}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (position === 2 && !isTied) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xl">🥈</span>
|
||||||
|
<span className="font-semibold">{positionText}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (position === 3 && !isTied) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xl">🥉</span>
|
||||||
|
<span className="font-semibold">{positionText}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<span className="font-medium">
|
||||||
|
{positionText}
|
||||||
|
{position === 1
|
||||||
|
? "st"
|
||||||
|
: position === 2
|
||||||
|
? "nd"
|
||||||
|
: position === 3
|
||||||
|
? "rd"
|
||||||
|
: "th"}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if multiple participants share the same position
|
||||||
|
const checkIfTied = (standing: SeasonStanding): boolean => {
|
||||||
|
return standings.some(
|
||||||
|
(other) =>
|
||||||
|
other.id !== standing.id && other.position === standing.position
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Count top 8 finishers (those who will get fantasy points)
|
||||||
|
const top8Count = standings.filter((s) => s.position <= 8).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className={
|
||||||
|
isFinalized
|
||||||
|
? "border-green-200 dark:border-green-800"
|
||||||
|
: "border-blue-200 dark:border-blue-800"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle
|
||||||
|
className={
|
||||||
|
isFinalized
|
||||||
|
? "text-green-600 dark:text-green-400"
|
||||||
|
: "text-blue-600 dark:text-blue-400"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Flag className="inline mr-2 h-5 w-5" />
|
||||||
|
{title}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{isFinalized ? (
|
||||||
|
<span className="text-green-600 dark:text-green-400 font-semibold">
|
||||||
|
<CheckCircle2 className="inline h-4 w-4 mr-1" />
|
||||||
|
Season complete - Fantasy points assigned to top 8 finishers
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Current championship standings. Positions calculated from points
|
||||||
|
(highest = 1st).
|
||||||
|
{top8Count > 0 && (
|
||||||
|
<span className="block mt-1">
|
||||||
|
Top 8 finishers will receive fantasy points when season completes.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{standings.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
<p className="text-sm">No standings data available yet.</p>
|
||||||
|
<p className="text-xs mt-1">
|
||||||
|
Championship points will appear here once results are entered.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-20">Pos</TableHead>
|
||||||
|
{standings.some((s) => s.previousPosition) && (
|
||||||
|
<TableHead className="w-20">Change</TableHead>
|
||||||
|
)}
|
||||||
|
<TableHead>Participant</TableHead>
|
||||||
|
<TableHead className="text-right">Points</TableHead>
|
||||||
|
{showOwnership && (
|
||||||
|
<TableHead className="w-24 text-center">Team</TableHead>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{standings
|
||||||
|
.sort((a, b) => a.position - b.position)
|
||||||
|
.map((standing) => {
|
||||||
|
const ownership = getOwnership(standing.participant.id);
|
||||||
|
const isTied = checkIfTied(standing);
|
||||||
|
const isTop8 = standing.position <= 8;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={standing.id}
|
||||||
|
className={
|
||||||
|
isTop8
|
||||||
|
? standing.position <= 3
|
||||||
|
? "bg-muted/30 font-medium"
|
||||||
|
: ""
|
||||||
|
: "opacity-60"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TableCell>
|
||||||
|
{getPositionBadge(standing.position, isTied)}
|
||||||
|
</TableCell>
|
||||||
|
{standings.some((s) => s.previousPosition) && (
|
||||||
|
<TableCell>
|
||||||
|
{getMovementIndicator(
|
||||||
|
standing.position,
|
||||||
|
standing.previousPosition
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{standing.participant.name}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<span
|
||||||
|
className={`font-semibold ${
|
||||||
|
isTop8
|
||||||
|
? "text-blue-600 dark:text-blue-400"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{parseFloat(standing.championshipPoints).toFixed(1)} pts
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
{showOwnership && (
|
||||||
|
<TableCell className="text-center">
|
||||||
|
{ownership ? (
|
||||||
|
<div
|
||||||
|
className="cursor-help inline-block"
|
||||||
|
title={`${ownership.teamName}${ownership.ownerName ? ` (${ownership.ownerName})` : ""}`}
|
||||||
|
>
|
||||||
|
{getTeamAvatar(ownership.teamName)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
{!isFinalized && top8Count > 0 && (
|
||||||
|
<div className="mt-4 p-3 border-t">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{top8Count}
|
||||||
|
</Badge>{" "}
|
||||||
|
participant{top8Count !== 1 ? "s" : ""} currently in top 8 will
|
||||||
|
receive fantasy points when season completes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
196
app/components/scoring/SportSeasonDisplay.tsx
Normal file
196
app/components/scoring/SportSeasonDisplay.tsx
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
import { PlayoffBracket } from "./PlayoffBracket";
|
||||||
|
import { SeasonStandings } from "./SeasonStandings";
|
||||||
|
import { QualifyingPointsStandings } from "./QualifyingPointsStandings";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
|
import { AlertCircle } from "lucide-react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SportSeasonDisplay - Pattern detection and display component
|
||||||
|
*
|
||||||
|
* Detects the scoring pattern of a sports season and displays the appropriate
|
||||||
|
* component (PlayoffBracket, SeasonStandings, or QualifyingPointsStandings).
|
||||||
|
*
|
||||||
|
* This is the main integration point for displaying sport-specific results
|
||||||
|
* to league members.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type ScoringPattern =
|
||||||
|
| "single_elimination_playoff"
|
||||||
|
| "page_playoff"
|
||||||
|
| "season_standings"
|
||||||
|
| "qualifying_points";
|
||||||
|
|
||||||
|
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 SeasonStanding {
|
||||||
|
id: string;
|
||||||
|
championshipPoints: string;
|
||||||
|
position: number;
|
||||||
|
previousPosition?: number | null;
|
||||||
|
participant: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QPStanding {
|
||||||
|
id: string;
|
||||||
|
totalQualifyingPoints: string;
|
||||||
|
eventsScored: number;
|
||||||
|
finalRanking: number | null;
|
||||||
|
participant: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TeamOwnership {
|
||||||
|
participantId: string;
|
||||||
|
teamName: string;
|
||||||
|
teamId: string;
|
||||||
|
ownerName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScoringRules {
|
||||||
|
pointsFor1st: number;
|
||||||
|
pointsFor2nd: number;
|
||||||
|
pointsFor3rd: number;
|
||||||
|
pointsFor4th: number;
|
||||||
|
pointsFor5th: number;
|
||||||
|
pointsFor6th: number;
|
||||||
|
pointsFor7th: number;
|
||||||
|
pointsFor8th: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SportSeasonDisplayProps {
|
||||||
|
scoringPattern: ScoringPattern;
|
||||||
|
sportSeasonName: string;
|
||||||
|
sportName: string;
|
||||||
|
|
||||||
|
// Playoff data
|
||||||
|
playoffMatches?: Match[];
|
||||||
|
playoffRounds?: string[];
|
||||||
|
|
||||||
|
// Season standings data (F1)
|
||||||
|
seasonStandings?: SeasonStanding[];
|
||||||
|
seasonIsFinalized?: boolean;
|
||||||
|
|
||||||
|
// Qualifying points data (Golf/Tennis)
|
||||||
|
qpStandings?: QPStanding[];
|
||||||
|
qpIsFinalized?: boolean;
|
||||||
|
totalMajors?: number | null;
|
||||||
|
majorsCompleted?: number;
|
||||||
|
canFinalize?: boolean;
|
||||||
|
|
||||||
|
// Shared data
|
||||||
|
teamOwnerships?: TeamOwnership[];
|
||||||
|
scoringRules?: ScoringRules | null;
|
||||||
|
showOwnership?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SportSeasonDisplay({
|
||||||
|
scoringPattern,
|
||||||
|
sportSeasonName,
|
||||||
|
sportName,
|
||||||
|
playoffMatches = [],
|
||||||
|
playoffRounds = [],
|
||||||
|
seasonStandings = [],
|
||||||
|
seasonIsFinalized = false,
|
||||||
|
qpStandings = [],
|
||||||
|
qpIsFinalized = false,
|
||||||
|
totalMajors,
|
||||||
|
majorsCompleted = 0,
|
||||||
|
canFinalize = false,
|
||||||
|
teamOwnerships = [],
|
||||||
|
scoringRules = null,
|
||||||
|
showOwnership = true,
|
||||||
|
}: SportSeasonDisplayProps) {
|
||||||
|
// Pattern detection and component selection
|
||||||
|
switch (scoringPattern) {
|
||||||
|
case "single_elimination_playoff":
|
||||||
|
case "page_playoff":
|
||||||
|
// Display playoff bracket
|
||||||
|
return (
|
||||||
|
<PlayoffBracket
|
||||||
|
matches={playoffMatches}
|
||||||
|
rounds={playoffRounds}
|
||||||
|
teamOwnerships={teamOwnerships}
|
||||||
|
showOwnership={showOwnership}
|
||||||
|
title={`${sportName} ${sportSeasonName}`}
|
||||||
|
description={
|
||||||
|
scoringPattern === "page_playoff"
|
||||||
|
? "AFL Finals format with double-chance system"
|
||||||
|
: "Single elimination playoff bracket"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "season_standings":
|
||||||
|
// Display F1-style championship standings
|
||||||
|
return (
|
||||||
|
<SeasonStandings
|
||||||
|
standings={seasonStandings}
|
||||||
|
teamOwnerships={teamOwnerships}
|
||||||
|
showOwnership={showOwnership}
|
||||||
|
isFinalized={seasonIsFinalized}
|
||||||
|
title={`${sportName} ${sportSeasonName}`}
|
||||||
|
description="Championship standings - positions calculated from points"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "qualifying_points":
|
||||||
|
// Display qualifying points standings (Golf/Tennis)
|
||||||
|
return (
|
||||||
|
<QualifyingPointsStandings
|
||||||
|
standings={qpStandings}
|
||||||
|
scoringRules={scoringRules}
|
||||||
|
isFinalized={qpIsFinalized}
|
||||||
|
totalMajors={totalMajors}
|
||||||
|
majorsCompleted={majorsCompleted}
|
||||||
|
canFinalize={canFinalize}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Unknown pattern - show error
|
||||||
|
return (
|
||||||
|
<Card className="border-destructive/50">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-destructive flex items-center gap-2">
|
||||||
|
<AlertCircle className="h-5 w-5" />
|
||||||
|
Unknown Scoring Pattern
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
This sports season uses an unsupported scoring pattern: "
|
||||||
|
{scoringPattern}". Please contact support.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,10 @@ export default [
|
||||||
route("leagues/new", "routes/leagues/new.tsx"),
|
route("leagues/new", "routes/leagues/new.tsx"),
|
||||||
route("leagues/:leagueId", "routes/leagues/$leagueId.tsx"),
|
route("leagues/:leagueId", "routes/leagues/$leagueId.tsx"),
|
||||||
route("leagues/:leagueId/settings", "routes/leagues/$leagueId.settings.tsx"),
|
route("leagues/:leagueId/settings", "routes/leagues/$leagueId.settings.tsx"),
|
||||||
|
route(
|
||||||
|
"leagues/:leagueId/sports-seasons/:sportsSeasonId",
|
||||||
|
"routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.tsx"
|
||||||
|
),
|
||||||
route(
|
route(
|
||||||
"leagues/:leagueId/draft/:seasonId",
|
"leagues/:leagueId/draft/:seasonId",
|
||||||
"routes/leagues/$leagueId.draft.$seasonId.tsx"
|
"routes/leagues/$leagueId.draft.$seasonId.tsx"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,183 @@
|
||||||
|
import { getAuth } from "@clerk/react-router/server";
|
||||||
|
import type { Route } from "./+types/$leagueId.sports-seasons.$sportsSeasonId";
|
||||||
|
import {
|
||||||
|
findLeagueById,
|
||||||
|
isUserLeagueMember,
|
||||||
|
isCommissioner,
|
||||||
|
findTeamsBySeasonId,
|
||||||
|
findUserByClerkId,
|
||||||
|
} from "~/models";
|
||||||
|
import { getDraftPicks } from "~/models/draft-pick";
|
||||||
|
import { findSportsSeasonById } from "~/models/sports-season";
|
||||||
|
import { getSeasonResults } from "~/models/participant-season-result";
|
||||||
|
import { getQPStandings } from "~/models/qualifying-points";
|
||||||
|
import { findSeasonById } from "~/models/season";
|
||||||
|
import { database } from "~/database/context";
|
||||||
|
import * as schema from "~/database/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
export async function loader(args: Route.LoaderArgs) {
|
||||||
|
const { userId } = await getAuth(args);
|
||||||
|
const { params } = args;
|
||||||
|
const { leagueId, sportsSeasonId } = params;
|
||||||
|
|
||||||
|
// Check authentication
|
||||||
|
if (!userId) {
|
||||||
|
throw new Response("You must be logged in to view this page", {
|
||||||
|
status: 401,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch league
|
||||||
|
const league = await findLeagueById(leagueId);
|
||||||
|
if (!league) {
|
||||||
|
throw new Response("League not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check access: user must be a commissioner or member
|
||||||
|
const isUserCommissioner = await isCommissioner(leagueId, userId);
|
||||||
|
const isUserMember = await isUserLeagueMember(leagueId, userId);
|
||||||
|
|
||||||
|
if (!isUserCommissioner && !isUserMember) {
|
||||||
|
throw new Response("You do not have access to this league", {
|
||||||
|
status: 403,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch sports season with relations
|
||||||
|
const db = database();
|
||||||
|
const sportsSeason = await db.query.sportsSeasons.findFirst({
|
||||||
|
where: eq(schema.sportsSeasons.id, sportsSeasonId),
|
||||||
|
with: {
|
||||||
|
sport: true,
|
||||||
|
seasonSports: {
|
||||||
|
with: {
|
||||||
|
season: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!sportsSeason) {
|
||||||
|
throw new Response("Sports season not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the fantasy season for this league (to get teams and draft picks)
|
||||||
|
// We need to find which season in this league includes this sports season
|
||||||
|
const seasonSport = sportsSeason.seasonSports?.[0];
|
||||||
|
if (!seasonSport) {
|
||||||
|
throw new Response("This sports season is not linked to this league", {
|
||||||
|
status: 404,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const season = await findSeasonById(seasonSport.seasonId);
|
||||||
|
if (!season || season.leagueId !== leagueId) {
|
||||||
|
throw new Response("This sports season is not linked to this league", {
|
||||||
|
status: 404,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch teams and draft picks to determine ownership
|
||||||
|
const teams = await findTeamsBySeasonId(season.id);
|
||||||
|
const draftPicks = await getDraftPicks(season.id);
|
||||||
|
|
||||||
|
// Build ownership map: participantId -> { teamName, teamId, ownerName }
|
||||||
|
const ownershipMap = new Map<
|
||||||
|
string,
|
||||||
|
{ teamName: string; teamId: string; ownerName?: string }
|
||||||
|
>();
|
||||||
|
|
||||||
|
// Get unique owner IDs for user lookups
|
||||||
|
const ownerIds = [...new Set(teams.map((t) => t.ownerId).filter(Boolean))];
|
||||||
|
const ownerUsers = await Promise.all(
|
||||||
|
ownerIds.map(async (ownerId) => {
|
||||||
|
const user = await findUserByClerkId(ownerId!);
|
||||||
|
return {
|
||||||
|
ownerId,
|
||||||
|
name: user?.username || user?.displayName || "Unknown",
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const ownerMap = new Map(ownerUsers.map((o) => [o.ownerId, o.name]));
|
||||||
|
|
||||||
|
// Map draft picks to ownership
|
||||||
|
for (const pick of draftPicks) {
|
||||||
|
const team = teams.find((t) => t.id === pick.teamId);
|
||||||
|
if (team && pick.participantId) {
|
||||||
|
ownershipMap.set(pick.participantId, {
|
||||||
|
teamName: team.name,
|
||||||
|
teamId: team.id,
|
||||||
|
ownerName: team.ownerId ? ownerMap.get(team.ownerId) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to array for serialization
|
||||||
|
const teamOwnerships = Array.from(ownershipMap.entries()).map(
|
||||||
|
([participantId, data]) => ({
|
||||||
|
participantId,
|
||||||
|
...data,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch pattern-specific data based on scoring pattern
|
||||||
|
const scoringPattern = sportsSeason.scoringPattern;
|
||||||
|
|
||||||
|
let playoffMatches: any[] = [];
|
||||||
|
let playoffRounds: string[] = [];
|
||||||
|
let seasonStandings: any[] = [];
|
||||||
|
let qpStandings: any[] = [];
|
||||||
|
|
||||||
|
if (
|
||||||
|
scoringPattern === "single_elimination_playoff" ||
|
||||||
|
scoringPattern === "page_playoff"
|
||||||
|
) {
|
||||||
|
// Fetch playoff matches via scoring events
|
||||||
|
// First get all scoring events for this sports season
|
||||||
|
const events = await db.query.scoringEvents.findMany({
|
||||||
|
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Then get all playoff matches for these events
|
||||||
|
if (events.length > 0) {
|
||||||
|
const eventIds = events.map((e) => e.id);
|
||||||
|
const matches = await db.query.playoffMatches.findMany({
|
||||||
|
where: (playoffMatches, { inArray }) =>
|
||||||
|
inArray(playoffMatches.scoringEventId, eventIds),
|
||||||
|
with: {
|
||||||
|
participant1: true,
|
||||||
|
participant2: true,
|
||||||
|
winner: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
playoffMatches = matches;
|
||||||
|
|
||||||
|
// Get unique rounds in order
|
||||||
|
const roundSet = new Set(matches.map((m: any) => m.round));
|
||||||
|
playoffRounds = Array.from(roundSet);
|
||||||
|
|
||||||
|
// TODO: Sort rounds by template order if needed
|
||||||
|
}
|
||||||
|
} else if (scoringPattern === "season_standings") {
|
||||||
|
// Fetch F1-style championship standings
|
||||||
|
const results = await getSeasonResults(sportsSeasonId);
|
||||||
|
seasonStandings = results;
|
||||||
|
} else if (scoringPattern === "qualifying_points") {
|
||||||
|
// Fetch qualifying points standings
|
||||||
|
const standings = await getQPStandings(sportsSeasonId);
|
||||||
|
qpStandings = standings;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
league,
|
||||||
|
season,
|
||||||
|
sportsSeason,
|
||||||
|
scoringPattern,
|
||||||
|
playoffMatches,
|
||||||
|
playoffRounds,
|
||||||
|
seasonStandings,
|
||||||
|
qpStandings,
|
||||||
|
teamOwnerships,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
import { Link } from "react-router";
|
||||||
|
import type { Route } from "./+types/$leagueId.sports-seasons.$sportsSeasonId";
|
||||||
|
import { loader } from "./$leagueId.sports-seasons.$sportsSeasonId.server";
|
||||||
|
import { SportSeasonDisplay } from "~/components/scoring/SportSeasonDisplay";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
|
||||||
|
export { loader };
|
||||||
|
|
||||||
|
export default function SportSeasonDetail({
|
||||||
|
loaderData,
|
||||||
|
}: Route.ComponentProps) {
|
||||||
|
const {
|
||||||
|
league,
|
||||||
|
season,
|
||||||
|
sportsSeason,
|
||||||
|
scoringPattern,
|
||||||
|
playoffMatches,
|
||||||
|
playoffRounds,
|
||||||
|
seasonStandings,
|
||||||
|
qpStandings,
|
||||||
|
teamOwnerships,
|
||||||
|
} = loaderData;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto py-8 px-4">
|
||||||
|
<div className="mb-6">
|
||||||
|
<Button variant="ghost" size="sm" asChild className="mb-4">
|
||||||
|
<Link to={`/leagues/${league.id}`}>
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back to League
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<h1 className="text-3xl font-bold mb-2">
|
||||||
|
{sportsSeason.sport.name} - {sportsSeason.name}
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{league.name} • {season.year} Season
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SportSeasonDisplay
|
||||||
|
scoringPattern={scoringPattern as any}
|
||||||
|
sportSeasonName={sportsSeason.name}
|
||||||
|
sportName={sportsSeason.sport.name}
|
||||||
|
playoffMatches={playoffMatches as any}
|
||||||
|
playoffRounds={playoffRounds}
|
||||||
|
seasonStandings={seasonStandings as any}
|
||||||
|
seasonIsFinalized={false} // TODO: determine from data
|
||||||
|
qpStandings={qpStandings as any}
|
||||||
|
qpIsFinalized={sportsSeason.qualifyingPointsFinalized || false}
|
||||||
|
totalMajors={sportsSeason.totalMajors}
|
||||||
|
majorsCompleted={sportsSeason.majorsCompleted || 0}
|
||||||
|
canFinalize={
|
||||||
|
(sportsSeason.majorsCompleted || 0) >=
|
||||||
|
(sportsSeason.totalMajors || 0) &&
|
||||||
|
!sportsSeason.qualifyingPointsFinalized
|
||||||
|
}
|
||||||
|
teamOwnerships={teamOwnerships}
|
||||||
|
scoringRules={season} // Season has the scoring rules (pointsFor1st, etc.)
|
||||||
|
showOwnership={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -792,12 +792,13 @@ async function updateAllExpectedValues(seasonId: uuid) {
|
||||||
- Historical standings view (after season completion)
|
- Historical standings view (after season completion)
|
||||||
- Show final results, placements, tiebreakers
|
- Show final results, placements, tiebreakers
|
||||||
|
|
||||||
/leagues/$leagueId/seasons/$seasonId/sports/$sportsSeasonId
|
/leagues/$leagueId/sports-seasons/$sportsSeasonId ✅ *Implemented*
|
||||||
- View for specific sport season
|
- View for specific sport season
|
||||||
- Show all participants in this sport
|
- Show all participants in this sport
|
||||||
- Ownership hints (which teams own which participants)
|
- Ownership hints (which teams own which participants)
|
||||||
- Current results/QP standings
|
- Current results/QP standings
|
||||||
- Bracket display (for playoff sports)
|
- Bracket display (for playoff sports)
|
||||||
|
- **Implementation note**: Route simplified to not require seasonId since it's determined from sports season relations
|
||||||
|
|
||||||
/leagues/$leagueId/seasons/$seasonId/teams/$teamId
|
/leagues/$leagueId/seasons/$seasonId/teams/$teamId
|
||||||
- Detailed team breakdown
|
- Detailed team breakdown
|
||||||
|
|
@ -1247,11 +1248,20 @@ scoring_events {
|
||||||
- Preliminary Finals: Losers share 3rd-4th
|
- Preliminary Finals: Losers share 3rd-4th
|
||||||
- Grand Final: Winner 1st, Loser 2nd
|
- Grand Final: Winner 1st, Loser 2nd
|
||||||
|
|
||||||
- [ ] **3.4** Pattern-specific UI components
|
- [x] **3.4** Pattern-specific UI components ✅ *Completed*
|
||||||
- [ ] `PlayoffBracket` component with ownership hints (Q15)
|
- [x] `PlayoffBracket` component with ownership hints (Q15)
|
||||||
- [ ] `QualifyingPointsStandings` component
|
- [x] `QualifyingPointsStandings` component (completed in 3.2)
|
||||||
- [ ] `SeasonStandings` component for F1
|
- [x] `SeasonStandings` component for F1
|
||||||
- [ ] Pattern detection and appropriate display
|
- [x] `SportSeasonDisplay` component for pattern detection and appropriate display
|
||||||
|
- [x] Member-facing route `/leagues/:leagueId/sports-seasons/:sportsSeasonId`
|
||||||
|
|
||||||
|
**Implementation Notes:**
|
||||||
|
- PlayoffBracket: Displays bracket with matches grouped by round, shows ownership with team avatar badges
|
||||||
|
- SeasonStandings: Shows F1-style championship standings with positions auto-calculated from points
|
||||||
|
- SportSeasonDisplay: Pattern detection wrapper that renders appropriate component based on scoring pattern
|
||||||
|
- Route created for members to view individual sport seasons within their league
|
||||||
|
- Ownership hints show team avatars with hover tooltips (Q15: manager name in title attribute)
|
||||||
|
- All TypeScript compilation passes ✅
|
||||||
|
|
||||||
### Phase 4: Standings & Display
|
### Phase 4: Standings & Display
|
||||||
**Goal**: Full-featured standings with breakdowns and historical views
|
**Goal**: Full-featured standings with breakdowns and historical views
|
||||||
|
|
@ -1274,14 +1284,23 @@ scoring_events {
|
||||||
- [ ] Group by sport season
|
- [ ] Group by sport season
|
||||||
- [ ] Link to sport season pages
|
- [ ] Link to sport season pages
|
||||||
|
|
||||||
- [ ] **4.4** Sport season pages with ownership
|
- [x] **4.4** Sport season pages with ownership ✅ *Completed in Phase 3.4*
|
||||||
- [ ] Create sport season detail route
|
- [x] Create sport season detail route (`/leagues/:leagueId/sports-seasons/:sportsSeasonId`)
|
||||||
- [ ] Display all participants for that sport
|
- [x] Display all participants for that sport
|
||||||
- [ ] Show current results/standings
|
- [x] Show current results/standings (pattern-specific via SportSeasonDisplay)
|
||||||
- [ ] Ownership indicators with avatars + tooltips (Q11, Q15)
|
- [x] Ownership indicators with avatars + tooltips (Q11, Q15)
|
||||||
- [ ] Bracket display for playoff sports (Q3)
|
- [x] Bracket display for playoff sports (Q3)
|
||||||
|
|
||||||
- [ ] **4.5** Historical views
|
**Note**: Route exists but needs navigation from league home page (see 4.5 below)
|
||||||
|
|
||||||
|
- [ ] **4.5** League home page integration
|
||||||
|
- [ ] Display team standings table on league home page
|
||||||
|
- [ ] Add "Sports Seasons" section showing all sports in current season
|
||||||
|
- [ ] Link each sport season card to detail page (Q11 - ownership hints on each sport's page)
|
||||||
|
- [ ] Show sport status badges (upcoming, active, completed)
|
||||||
|
- [ ] Quick access to brackets and standings from league home
|
||||||
|
|
||||||
|
- [ ] **4.6** Historical views
|
||||||
- [ ] Season completion detection
|
- [ ] Season completion detection
|
||||||
- [ ] Historical standings page
|
- [ ] Historical standings page
|
||||||
- [ ] Point progression chart over time (Q16)
|
- [ ] Point progression chart over time (Q16)
|
||||||
|
|
@ -1374,7 +1393,7 @@ scoring_events {
|
||||||
- **3.1** Season Standings (F1) - Event-based workflow with auto-calculated positions
|
- **3.1** Season Standings (F1) - Event-based workflow with auto-calculated positions
|
||||||
- **3.2** Qualifying Points (Golf/Tennis) - Two-phase scoring with configurable QP values
|
- **3.2** Qualifying Points (Golf/Tennis) - Two-phase scoring with configurable QP values
|
||||||
- **3.3** AFL Finals - Double-chance system with Wildcard Round (10 teams)
|
- **3.3** AFL Finals - Double-chance system with Wildcard Round (10 teams)
|
||||||
- ⏳ **Phase 3.4**: Pattern-specific UI Components - TODO
|
- **3.4** Pattern-specific UI Components - PlayoffBracket, SeasonStandings, SportSeasonDisplay components + member route
|
||||||
- ⏳ **Phase 4**: Standings & Display - TODO
|
- ⏳ **Phase 4**: Standings & Display - TODO
|
||||||
- ⏳ **Phase 5**: Expected Value - TODO
|
- ⏳ **Phase 5**: Expected Value - TODO
|
||||||
- ⏳ **Phase 6**: Polish & Optimization - TODO
|
- ⏳ **Phase 6**: Polish & Optimization - TODO
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue