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/:leagueId", "routes/leagues/$leagueId.tsx"),
|
||||
route("leagues/:leagueId/settings", "routes/leagues/$leagueId.settings.tsx"),
|
||||
route(
|
||||
"leagues/:leagueId/sports-seasons/:sportsSeasonId",
|
||||
"routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.tsx"
|
||||
),
|
||||
route(
|
||||
"leagues/:leagueId/draft/:seasonId",
|
||||
"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)
|
||||
- Show final results, placements, tiebreakers
|
||||
|
||||
/leagues/$leagueId/seasons/$seasonId/sports/$sportsSeasonId
|
||||
/leagues/$leagueId/sports-seasons/$sportsSeasonId ✅ *Implemented*
|
||||
- View for specific sport season
|
||||
- Show all participants in this sport
|
||||
- Ownership hints (which teams own which participants)
|
||||
- Current results/QP standings
|
||||
- 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
|
||||
- Detailed team breakdown
|
||||
|
|
@ -1247,11 +1248,20 @@ scoring_events {
|
|||
- Preliminary Finals: Losers share 3rd-4th
|
||||
- Grand Final: Winner 1st, Loser 2nd
|
||||
|
||||
- [ ] **3.4** Pattern-specific UI components
|
||||
- [ ] `PlayoffBracket` component with ownership hints (Q15)
|
||||
- [ ] `QualifyingPointsStandings` component
|
||||
- [ ] `SeasonStandings` component for F1
|
||||
- [ ] Pattern detection and appropriate display
|
||||
- [x] **3.4** Pattern-specific UI components ✅ *Completed*
|
||||
- [x] `PlayoffBracket` component with ownership hints (Q15)
|
||||
- [x] `QualifyingPointsStandings` component (completed in 3.2)
|
||||
- [x] `SeasonStandings` component for F1
|
||||
- [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
|
||||
**Goal**: Full-featured standings with breakdowns and historical views
|
||||
|
|
@ -1274,14 +1284,23 @@ scoring_events {
|
|||
- [ ] Group by sport season
|
||||
- [ ] Link to sport season pages
|
||||
|
||||
- [ ] **4.4** Sport season pages with ownership
|
||||
- [ ] Create sport season detail route
|
||||
- [ ] Display all participants for that sport
|
||||
- [ ] Show current results/standings
|
||||
- [ ] Ownership indicators with avatars + tooltips (Q11, Q15)
|
||||
- [ ] Bracket display for playoff sports (Q3)
|
||||
- [x] **4.4** Sport season pages with ownership ✅ *Completed in Phase 3.4*
|
||||
- [x] Create sport season detail route (`/leagues/:leagueId/sports-seasons/:sportsSeasonId`)
|
||||
- [x] Display all participants for that sport
|
||||
- [x] Show current results/standings (pattern-specific via SportSeasonDisplay)
|
||||
- [x] Ownership indicators with avatars + tooltips (Q11, Q15)
|
||||
- [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
|
||||
- [ ] Historical standings page
|
||||
- [ ] 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.2** Qualifying Points (Golf/Tennis) - Two-phase scoring with configurable QP values
|
||||
- **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 5**: Expected Value - TODO
|
||||
- ⏳ **Phase 6**: Polish & Optimization - TODO
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue