diff --git a/app/components/scoring/PlayoffBracket.tsx b/app/components/scoring/PlayoffBracket.tsx new file mode 100644 index 0000000..6219456 --- /dev/null +++ b/app/components/scoring/PlayoffBracket.tsx @@ -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(); + 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); + + // 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 ( +
+ {initial} +
+ ); + }; + + // 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 ( +
+
+ {isWinner && ( + + )} + + {participantName} + +
+
+ {score && ( + + {parseFloat(score)} + + )} + {ownership && ( +
+ {getTeamAvatar(ownership.teamName)} +
+ )} +
+
+ ); + }; + + // 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 ( + + + + + {title} + + {description && {description}} + {showOwnership && matchesWithOwnership > 0 && ( +
+ + {matchesWithOwnership} match{matchesWithOwnership !== 1 ? "es" : ""} with team ownership + +
+ )} +
+ + {matches.length === 0 ? ( +
+

No bracket matches available yet.

+

+ Matches will appear here once the bracket is set up. +

+
+ ) : ( +
+ {rounds.map((round) => { + const roundMatches = matchesByRound[round] || []; + if (roundMatches.length === 0) return null; + + return ( +
+
+

{round}

+ + {roundMatches.length} match{roundMatches.length !== 1 ? "es" : ""} + +
+ +
+ {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 ( + + +
+ + Match {match.matchNumber} + + {isComplete && ( + + Complete + + )} +
+ +
+ {renderParticipant( + match.participant1, + match.participant1Id, + participant1IsWinner, + match.participant1Score + )} + +
+
+ + vs + +
+
+ + {renderParticipant( + match.participant2, + match.participant2Id, + participant2IsWinner, + match.participant2Score + )} +
+
+
+ ); + })} +
+
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/app/components/scoring/SeasonStandings.tsx b/app/components/scoring/SeasonStandings.tsx new file mode 100644 index 0000000..9feeea0 --- /dev/null +++ b/app/components/scoring/SeasonStandings.tsx @@ -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(); + 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 ( +
+ {initial} +
+ ); + }; + + // 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 ( +
+ + +{change} +
+ ); + } else if (change < 0) { + return ( +
+ + {change} +
+ ); + } else { + return ( +
+ +
+ ); + } + }; + + // 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 ( +
+ 🥇 + {positionText} +
+ ); + } else if (position === 2 && !isTied) { + return ( +
+ 🥈 + {positionText} +
+ ); + } else if (position === 3 && !isTied) { + return ( +
+ 🥉 + {positionText} +
+ ); + } else { + return ( + + {positionText} + {position === 1 + ? "st" + : position === 2 + ? "nd" + : position === 3 + ? "rd" + : "th"} + + ); + } + }; + + // 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 ( + + + + + {title} + + + {isFinalized ? ( + + + Season complete - Fantasy points assigned to top 8 finishers + + ) : ( + <> + Current championship standings. Positions calculated from points + (highest = 1st). + {top8Count > 0 && ( + + Top 8 finishers will receive fantasy points when season completes. + + )} + + )} + + + + {standings.length === 0 ? ( +
+

No standings data available yet.

+

+ Championship points will appear here once results are entered. +

+
+ ) : ( + <> + + + + Pos + {standings.some((s) => s.previousPosition) && ( + Change + )} + Participant + Points + {showOwnership && ( + Team + )} + + + + {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 ( + + + {getPositionBadge(standing.position, isTied)} + + {standings.some((s) => s.previousPosition) && ( + + {getMovementIndicator( + standing.position, + standing.previousPosition + )} + + )} + + {standing.participant.name} + + + + {parseFloat(standing.championshipPoints).toFixed(1)} pts + + + {showOwnership && ( + + {ownership ? ( +
+ {getTeamAvatar(ownership.teamName)} +
+ ) : ( + - + )} +
+ )} +
+ ); + })} +
+
+ + {!isFinalized && top8Count > 0 && ( +
+

+ + {top8Count} + {" "} + participant{top8Count !== 1 ? "s" : ""} currently in top 8 will + receive fantasy points when season completes. +

+
+ )} + + )} +
+
+ ); +} diff --git a/app/components/scoring/SportSeasonDisplay.tsx b/app/components/scoring/SportSeasonDisplay.tsx new file mode 100644 index 0000000..1be8b63 --- /dev/null +++ b/app/components/scoring/SportSeasonDisplay.tsx @@ -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 ( + + ); + + case "season_standings": + // Display F1-style championship standings + return ( + + ); + + case "qualifying_points": + // Display qualifying points standings (Golf/Tennis) + return ( + + ); + + default: + // Unknown pattern - show error + return ( + + + + + Unknown Scoring Pattern + + + +

+ This sports season uses an unsupported scoring pattern: " + {scoringPattern}". Please contact support. +

+
+
+ ); + } +} diff --git a/app/routes.ts b/app/routes.ts index 7afc19c..44b1fd7 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -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" diff --git a/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.server.ts b/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.server.ts new file mode 100644 index 0000000..31eaaaf --- /dev/null +++ b/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.server.ts @@ -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, + }; +} diff --git a/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.tsx b/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.tsx new file mode 100644 index 0000000..9a10473 --- /dev/null +++ b/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.tsx @@ -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 ( +
+
+ + +
+

+ {sportsSeason.sport.name} - {sportsSeason.name} +

+

+ {league.name} • {season.year} Season +

+
+
+ + = + (sportsSeason.totalMajors || 0) && + !sportsSeason.qualifyingPointsFinalized + } + teamOwnerships={teamOwnerships} + scoringRules={season} // Season has the scoring rules (pointsFor1st, etc.) + showOwnership={true} + /> +
+ ); +} diff --git a/plans/scoring-system.md b/plans/scoring-system.md index be8fd9b..9b776b0 100644 --- a/plans/scoring-system.md +++ b/plans/scoring-system.md @@ -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