feat: Add routes and components for sports season display, including playoff brackets and standings

This commit is contained in:
Chris Parsons 2025-11-12 23:44:33 -08:00
parent 4d5b4efc23
commit 604eb6980c
7 changed files with 1080 additions and 14 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}
}

View file

@ -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"

View file

@ -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,
};
}

View file

@ -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>
);
}

View file

@ -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