diff --git a/app/components/standings/StandingsTable.tsx b/app/components/standings/StandingsTable.tsx new file mode 100644 index 0000000..c83b192 --- /dev/null +++ b/app/components/standings/StandingsTable.tsx @@ -0,0 +1,183 @@ +import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "~/components/ui/table"; +import { Badge } from "~/components/ui/badge"; +import { type TeamStanding } from "~/types/standings"; + +interface StandingsTableProps { + standings: TeamStanding[]; + showPlacementBreakdown?: boolean; +} + +/** + * Display team standings with ranking, points, and placement breakdown + * Phase 4.1: Enhanced standings table with tiebreakers + */ +export function StandingsTable({ + standings, + showPlacementBreakdown = true, +}: StandingsTableProps) { + return ( +
+ + + + Rank + Team + Points + {showPlacementBreakdown && ( + Placements + )} + Remaining + + + + {standings.length === 0 ? ( + + + No standings data available + + + ) : ( + standings.map((standing) => ( + + +
+ + {standing.rankChange !== 0 && ( + + )} +
+
+ {standing.teamName} + + {standing.totalPoints.toFixed(1)} + + {showPlacementBreakdown && ( + + + + )} + + {standing.participantsRemaining > 0 ? ( + + {standing.participantsRemaining} remaining + + ) : ( + Complete + )} + +
+ )) + )} +
+
+
+ ); +} + +/** + * Display rank badge with special styling for top 3 + */ +function RankBadge({ rank }: { rank: number }) { + if (rank === 1) { + return ( + + πŸ† 1st + + ); + } + + if (rank === 2) { + return ( + + πŸ₯ˆ 2nd + + ); + } + + if (rank === 3) { + return ( + + πŸ₯‰ 3rd + + ); + } + + return ( + + {rank} + + ); +} + +/** + * Show movement indicator (up/down arrow) + */ +function MovementIndicator({ change }: { change: number }) { + if (change > 0) { + return ( + + ↑{change} + + ); + } + + if (change < 0) { + return ( + + ↓{Math.abs(change)} + + ); + } + + return null; +} + +/** + * Display placement breakdown showing counts for each placement (1st-8th) + */ +function PlacementBreakdown({ + placements, +}: { + placements: { + first: number; + second: number; + third: number; + fourth: number; + fifth: number; + sixth: number; + seventh: number; + eighth: number; + }; +}) { + const items = [ + { label: "1st", count: placements.first, color: "text-yellow-600" }, + { label: "2nd", count: placements.second, color: "text-gray-500" }, + { label: "3rd", count: placements.third, color: "text-orange-600" }, + { label: "4th", count: placements.fourth, color: "text-blue-600" }, + { label: "5th", count: placements.fifth, color: "text-purple-600" }, + { label: "6th", count: placements.sixth, color: "text-green-600" }, + { label: "7th", count: placements.seventh, color: "text-pink-600" }, + { label: "8th", count: placements.eighth, color: "text-indigo-600" }, + ]; + + // Only show placements that have counts > 0 + const nonZero = items.filter((item) => item.count > 0); + + if (nonZero.length === 0) { + return None yet; + } + + return ( +
+ {nonZero.map((item) => ( + + {item.label}Γ—{item.count} + + ))} +
+ ); +} diff --git a/app/components/standings/__tests__/StandingsTable.test.tsx b/app/components/standings/__tests__/StandingsTable.test.tsx new file mode 100644 index 0000000..2a23a83 --- /dev/null +++ b/app/components/standings/__tests__/StandingsTable.test.tsx @@ -0,0 +1,322 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { StandingsTable } from "../StandingsTable"; +import { type TeamStanding } from "~/models/standings"; + +/** + * StandingsTable Component Tests + * Phase 4.1: Test standings display with tiebreakers and placement breakdown + */ +describe("StandingsTable", () => { + const mockStandings: TeamStanding[] = [ + { + teamId: "team1", + teamName: "Champions United", + totalPoints: 250.5, + currentRank: 1, + previousRank: 2, + rankChange: 1, // Moved up + placementCounts: { + first: 2, + second: 1, + third: 0, + fourth: 1, + fifth: 0, + sixth: 0, + seventh: 0, + eighth: 1, + }, + participantsRemaining: 3, + calculatedAt: new Date(), + }, + { + teamId: "team2", + teamName: "Second Place Squad", + totalPoints: 245.0, + currentRank: 2, + previousRank: 1, + rankChange: -1, // Moved down + placementCounts: { + first: 1, + second: 2, + third: 1, + fourth: 0, + fifth: 1, + sixth: 0, + seventh: 0, + eighth: 0, + }, + participantsRemaining: 0, + calculatedAt: new Date(), + }, + { + teamId: "team3", + teamName: "Bronze Warriors", + totalPoints: 200.0, + currentRank: 3, + previousRank: 3, + rankChange: 0, // No change + placementCounts: { + first: 0, + second: 1, + third: 2, + fourth: 1, + fifth: 0, + sixth: 0, + seventh: 0, + eighth: 0, + }, + participantsRemaining: 5, + calculatedAt: new Date(), + }, + ]; + + describe("Basic Rendering", () => { + it("should render standings table with all teams", () => { + render(); + + expect(screen.getByText("Champions United")).toBeInTheDocument(); + expect(screen.getByText("Second Place Squad")).toBeInTheDocument(); + expect(screen.getByText("Bronze Warriors")).toBeInTheDocument(); + }); + + it("should display total points for each team", () => { + render(); + + expect(screen.getByText("250.5")).toBeInTheDocument(); + expect(screen.getByText("245.0")).toBeInTheDocument(); + expect(screen.getByText("200.0")).toBeInTheDocument(); + }); + + it("should show empty state when no standings", () => { + render(); + + expect(screen.getByText("No standings data available")).toBeInTheDocument(); + }); + }); + + describe("Rank Badges", () => { + it("should show trophy icon for 1st place", () => { + render(); + + expect(screen.getByText(/πŸ†.*1st/)).toBeInTheDocument(); + }); + + it("should show silver medal icon for 2nd place", () => { + render(); + + expect(screen.getByText(/πŸ₯ˆ.*2nd/)).toBeInTheDocument(); + }); + + it("should show bronze medal icon for 3rd place", () => { + render(); + + expect(screen.getByText(/πŸ₯‰.*3rd/)).toBeInTheDocument(); + }); + + it("should show plain rank for 4th place and below", () => { + const standings: TeamStanding[] = [ + { + ...mockStandings[0], + currentRank: 4, + teamName: "Fourth Place", + }, + ]; + + render(); + + // Should not have emoji, just the number + const badge = screen.getByText("4"); + expect(badge).toBeInTheDocument(); + }); + }); + + describe("Movement Indicators", () => { + it("should show up arrow for positive rank change", () => { + render(); + + expect(screen.getByText("↑1")).toBeInTheDocument(); + }); + + it("should show down arrow for negative rank change", () => { + render(); + + expect(screen.getByText("↓1")).toBeInTheDocument(); + }); + + it("should not show indicator for no rank change", () => { + render(); + + // Third team has no change, so no arrow + const arrows = screen.queryAllByText(/↑|↓/); + expect(arrows).toHaveLength(2); // Only first two teams + }); + }); + + describe("Placement Breakdown", () => { + it("should display placement counts", () => { + const singleTeam: TeamStanding[] = [ + { + teamId: "team1", + teamName: "Test Team", + totalPoints: 250, + currentRank: 1, + previousRank: null, + rankChange: 0, + placementCounts: { + first: 2, + second: 1, + third: 0, + fourth: 1, + fifth: 0, + sixth: 0, + seventh: 0, + eighth: 1, + }, + participantsRemaining: 0, + calculatedAt: new Date(), + }, + ]; + + render(); + + // Test Team: 2 firsts, 1 second, 1 fourth, 1 eighth + expect(screen.getByText("1stΓ—2")).toBeInTheDocument(); + expect(screen.getByText("2ndΓ—1")).toBeInTheDocument(); + expect(screen.getByText("4thΓ—1")).toBeInTheDocument(); + expect(screen.getByText("8thΓ—1")).toBeInTheDocument(); + }); + + it("should only show non-zero placement counts", () => { + render(); + + // Should not show "3rdΓ—0" for Champions United + const placements = screen.queryByText("3rdΓ—0"); + expect(placements).not.toBeInTheDocument(); + }); + + it("should hide placement breakdown when disabled", () => { + render(); + + expect(screen.queryByText("1stΓ—2")).not.toBeInTheDocument(); + }); + + it("should show 'None yet' when no placements", () => { + const standings: TeamStanding[] = [ + { + ...mockStandings[0], + placementCounts: { + first: 0, + second: 0, + third: 0, + fourth: 0, + fifth: 0, + sixth: 0, + seventh: 0, + eighth: 0, + }, + }, + ]; + + render(); + + expect(screen.getByText("None yet")).toBeInTheDocument(); + }); + }); + + describe("Participants Remaining", () => { + it("should show remaining count when participants are incomplete", () => { + render(); + + expect(screen.getByText("3 remaining")).toBeInTheDocument(); + expect(screen.getByText("5 remaining")).toBeInTheDocument(); + }); + + it("should show 'Complete' badge when all participants done", () => { + render(); + + expect(screen.getByText("Complete")).toBeInTheDocument(); + }); + }); + + describe("Table Structure", () => { + it("should have correct table headers", () => { + render(); + + expect(screen.getByText("Rank")).toBeInTheDocument(); + expect(screen.getByText("Team")).toBeInTheDocument(); + expect(screen.getByText("Points")).toBeInTheDocument(); + expect(screen.getByText("Placements")).toBeInTheDocument(); + expect(screen.getByText("Remaining")).toBeInTheDocument(); + }); + + it("should render teams in order", () => { + const { container } = render(); + + const rows = container.querySelectorAll("tbody tr"); + expect(rows).toHaveLength(3); + + // First row should be Champions United (rank 1) + expect(rows[0].textContent).toContain("Champions United"); + + // Second row should be Second Place Squad (rank 2) + expect(rows[1].textContent).toContain("Second Place Squad"); + + // Third row should be Bronze Warriors (rank 3) + expect(rows[2].textContent).toContain("Bronze Warriors"); + }); + }); + + describe("Tied Ranks", () => { + it("should handle teams with same rank", () => { + const tiedStandings: TeamStanding[] = [ + { + ...mockStandings[0], + currentRank: 1, + teamName: "Team A", + }, + { + ...mockStandings[1], + currentRank: 1, + teamName: "Team B", + }, + { + ...mockStandings[2], + currentRank: 3, // Skips 2 + teamName: "Team C", + }, + ]; + + render(); + + // Both teams should show rank 1 + const firstPlaceBadges = screen.getAllByText(/πŸ†.*1st/); + expect(firstPlaceBadges).toHaveLength(2); + + // Third team should show rank 3 (not 2) + expect(screen.getByText(/πŸ₯‰.*3rd/)).toBeInTheDocument(); + }); + }); + + describe("Accessibility", () => { + it("should have title attributes for movement indicators", () => { + const { container } = render(); + + const upArrow = container.querySelector('[title*="Up"]'); + expect(upArrow).toBeInTheDocument(); + expect(upArrow?.getAttribute("title")).toContain("Up 1 place"); + + const downArrow = container.querySelector('[title*="Down"]'); + expect(downArrow).toBeInTheDocument(); + expect(downArrow?.getAttribute("title")).toContain("Down 1 place"); + }); + + it("should have title attributes for placement counts", () => { + const { container } = render(); + + const firstPlace = container.querySelector('[title*="1st place"]'); + expect(firstPlace).toBeInTheDocument(); + expect(firstPlace?.getAttribute("title")).toContain("2 1st place finishes"); + }); + }); +}); diff --git a/app/models/__tests__/tiebreaker-logic.test.ts b/app/models/__tests__/tiebreaker-logic.test.ts new file mode 100644 index 0000000..f0c524a --- /dev/null +++ b/app/models/__tests__/tiebreaker-logic.test.ts @@ -0,0 +1,339 @@ +import { describe, it, expect } from "vitest"; + +/** + * Tiebreaker Logic Tests + * Phase 4.1: Test comprehensive tiebreaker scenarios + * + * Tiebreaker order: + * 1. Total points (higher is better) + * 2. Number of 1st place finishes + * 3. Number of 2nd place finishes + * 4. Continue through all placements (3rd, 4th, ..., 8th) + * + * Note: These are unit tests that test the tiebreaker logic in isolation. + * The actual recalculateStandings function is tested through integration tests + * in actual league scenarios. + */ + +/** + * Helper function to simulate compareTeamsForRanking logic + * This mirrors the private function in scoring-calculator.ts + */ +function compareTeamsForRanking( + teamA: { + totalPoints: number; + placementCounts: Record; + }, + teamB: { + totalPoints: number; + placementCounts: Record; + } +): number { + // First compare by total points (descending) + if (teamA.totalPoints !== teamB.totalPoints) { + return teamB.totalPoints - teamA.totalPoints; + } + + // If points are tied, apply tiebreaker cascade + for (let placement = 1; placement <= 8; placement++) { + const countA = teamA.placementCounts[placement] || 0; + const countB = teamB.placementCounts[placement] || 0; + + if (countA !== countB) { + return countB - countA; // More of this placement is better + } + } + + // Completely tied + return 0; +} + +/** + * Helper function to simulate assignRanks logic + * This mirrors the private function in scoring-calculator.ts + */ +function assignRanks( + teams: Array<{ + teamId: string; + totalPoints: number; + placementCounts: Record; + }> +): Map { + // Sort teams by ranking criteria + const sorted = [...teams].sort(compareTeamsForRanking); + + const ranks = new Map(); + let currentRank = 1; + + for (let i = 0; i < sorted.length; i++) { + const team = sorted[i]; + + // Check if this team is tied with the previous team + if (i > 0) { + const prevTeam = sorted[i - 1]; + const comparison = compareTeamsForRanking(team, prevTeam); + + if (comparison !== 0) { + // Not tied, advance rank + currentRank = i + 1; + } + // If comparison === 0, keep the same rank (tie) + } + + ranks.set(team.teamId, currentRank); + } + + return ranks; +} + +describe("Tiebreaker Logic", () => { + + describe("Basic Point Sorting", () => { + it("should rank teams by total points descending", () => { + const teams = [ + { teamId: "team-a", totalPoints: 100, placementCounts: {} }, + { teamId: "team-b", totalPoints: 200, placementCounts: {} }, + { teamId: "team-c", totalPoints: 150, placementCounts: {} }, + ]; + + const ranks = assignRanks(teams); + + expect(ranks.get("team-b")).toBe(1); // Highest points + expect(ranks.get("team-c")).toBe(2); + expect(ranks.get("team-a")).toBe(3); // Lowest points + }); + }); + + describe("First Place Tiebreaker", () => { + it("should break tie using 1st place count", () => { + // Both teams have 215 points total, different 1st place counts + const teams = [ + { + teamId: "team-a", + totalPoints: 215, + placementCounts: { 1: 2, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 1 }, // 2 firsts + }, + { + teamId: "team-b", + totalPoints: 215, + placementCounts: { 1: 1, 2: 1, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 1 }, // 1 first + }, + ]; + + const ranks = assignRanks(teams); + + // Team A should rank higher due to more 1st place finishes + expect(ranks.get("team-a")).toBe(1); + expect(ranks.get("team-b")).toBe(2); + }); + + it("should handle multiple teams with same points but different 1st place counts", () => { + const teams = [ + { + teamId: "team-a", + totalPoints: 215, + placementCounts: { 1: 2, 8: 1 } as Record, // 2 firsts + }, + { + teamId: "team-b", + totalPoints: 215, + placementCounts: { 1: 1, 2: 1, 8: 1 } as Record, // 1 first + }, + { + teamId: "team-c", + totalPoints: 215, + placementCounts: { 2: 2, 3: 1 } as Record, // 0 firsts + }, + ]; + + const ranks = assignRanks(teams); + + // Ranked by 1st place count + expect(ranks.get("team-a")).toBe(1); // 2 firsts + expect(ranks.get("team-b")).toBe(2); // 1 first + expect(ranks.get("team-c")).toBe(3); // 0 firsts + }); + }); + + describe("Second Place Tiebreaker", () => { + it("should break tie using 2nd place count when 1st place count is tied", () => { + const teams = [ + { + teamId: "team-a", + totalPoints: 240, + placementCounts: { 1: 1, 2: 2, 8: 1 } as Record, // 1 first, 2 seconds + }, + { + teamId: "team-b", + totalPoints: 240, + placementCounts: { 1: 1, 3: 2, 8: 1 } as Record, // 1 first, 0 seconds + }, + ]; + + const ranks = assignRanks(teams); + + expect(ranks.get("team-a")).toBe(1); // More 2nd places + expect(ranks.get("team-b")).toBe(2); + }); + }); + + describe("Cascading Tiebreakers", () => { + it("should cascade through all placement counts when needed", () => { + // Tiebreaker goes deep: same points, same 1st-3rd, different 4th + const teams = [ + { + teamId: "team-a", + totalPoints: 160, + placementCounts: { 2: 1, 3: 1, 4: 1 } as Record, // 0Γ—1st, 1Γ—2nd, 1Γ—3rd, 1Γ—4th + }, + { + teamId: "team-b", + totalPoints: 160, + placementCounts: { 2: 1, 3: 1, 5: 2 } as Record, // 0Γ—1st, 1Γ—2nd, 1Γ—3rd, 0Γ—4th, 2Γ—5th + }, + ]; + + const ranks = assignRanks(teams); + + // Team A wins on 4th place count + expect(ranks.get("team-a")).toBe(1); + expect(ranks.get("team-b")).toBe(2); + }); + }); + + describe("Complete Ties", () => { + it("should assign same rank to teams with identical scores and placements", () => { + const teams = [ + { + teamId: "team-a", + totalPoints: 220, + placementCounts: { 1: 1, 2: 1, 3: 1 }, + }, + { + teamId: "team-b", + totalPoints: 220, + placementCounts: { 1: 1, 2: 1, 3: 1 }, + }, + ]; + + const ranks = assignRanks(teams); + + // Both should have rank 1 + expect(ranks.get("team-a")).toBe(1); + expect(ranks.get("team-b")).toBe(1); + }); + + it("should handle 3-way ties correctly", () => { + const teams = [ + { teamId: "team-a", totalPoints: 170, placementCounts: { 1: 1, 2: 1 } }, + { teamId: "team-b", totalPoints: 170, placementCounts: { 1: 1, 2: 1 } }, + { teamId: "team-c", totalPoints: 170, placementCounts: { 1: 1, 2: 1 } }, + ]; + + const ranks = assignRanks(teams); + + // All three should have rank 1 + expect(ranks.get("team-a")).toBe(1); + expect(ranks.get("team-b")).toBe(1); + expect(ranks.get("team-c")).toBe(1); + }); + + it("should skip rank numbers after ties", () => { + // 2 teams tied for 1st, next team should be 3rd + const teams = [ + { teamId: "team-a", totalPoints: 300, placementCounts: { 1: 3 } as Record }, + { teamId: "team-b", totalPoints: 300, placementCounts: { 1: 3 } as Record }, // Tied + { teamId: "team-c", totalPoints: 210, placementCounts: { 2: 3 } as Record }, + ]; + + const ranks = assignRanks(teams); + + expect(ranks.get("team-a")).toBe(1); + expect(ranks.get("team-b")).toBe(1); + expect(ranks.get("team-c")).toBe(3); // Skips 2 + }); + }); + + describe("Edge Cases", () => { + it("should handle season with single team", () => { + const teams = [{ teamId: "solo", totalPoints: 100, placementCounts: { 1: 1 } }]; + + const ranks = assignRanks(teams); + + expect(ranks.size).toBe(1); + expect(ranks.get("solo")).toBe(1); + }); + + it("should handle teams with zero points", () => { + const teams = [ + { teamId: "team-a", totalPoints: 100, placementCounts: { 1: 1 } as Record }, + { teamId: "team-b", totalPoints: 0, placementCounts: {} as Record }, + ]; + + const ranks = assignRanks(teams); + + expect(ranks.get("team-a")).toBe(1); + expect(ranks.get("team-b")).toBe(2); + }); + + it("should handle all teams with same score", () => { + const teams = [ + { teamId: "team-a", totalPoints: 100, placementCounts: { 1: 1 } }, + { teamId: "team-b", totalPoints: 100, placementCounts: { 1: 1 } }, + { teamId: "team-c", totalPoints: 100, placementCounts: { 1: 1 } }, + ]; + + const ranks = assignRanks(teams); + + // All should be rank 1 + expect(ranks.get("team-a")).toBe(1); + expect(ranks.get("team-b")).toBe(1); + expect(ranks.get("team-c")).toBe(1); + }); + }); + + describe("Complex Tiebreaker Scenarios", () => { + it("should handle 8-way cascade ending in complete tie", () => { + // Two teams identical through all 8 placements + const teams = [ + { + teamId: "team-a", + totalPoints: 450, + placementCounts: { 1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1, 7: 1, 8: 1 }, + }, + { + teamId: "team-b", + totalPoints: 450, + placementCounts: { 1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1, 7: 1, 8: 1 }, + }, + ]; + + const ranks = assignRanks(teams); + + expect(ranks.get("team-a")).toBe(1); + expect(ranks.get("team-b")).toBe(1); // Perfect tie + }); + + it("should break tie at 8th place (last tiebreaker)", () => { + // Identical through 1st-7th, different at 8th + const teams = [ + { + teamId: "team-a", + totalPoints: 100, + placementCounts: { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 7 }, // 7Γ—8th + }, + { + teamId: "team-b", + totalPoints: 100, + placementCounts: { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 6 }, // 6Γ—8th + }, + ]; + + const ranks = assignRanks(teams); + + // Team A wins with more 8th place finishes + expect(ranks.get("team-a")).toBe(1); + expect(ranks.get("team-b")).toBe(2); + }); + }); +}); diff --git a/app/models/scoring-calculator.ts b/app/models/scoring-calculator.ts index cc4ffbd..13e6bc3 100644 --- a/app/models/scoring-calculator.ts +++ b/app/models/scoring-calculator.ts @@ -602,10 +602,88 @@ export async function calculateTeamScore( }; } +/** + * Compare two teams for ranking purposes using tiebreaker logic + * Returns: negative if teamA ranks higher, positive if teamB ranks higher, 0 if tied + * + * Tiebreaker order: + * 1. Total points (higher is better) + * 2. Number of 1st place finishes (higher is better) + * 3. Number of 2nd place finishes (higher is better) + * 4. Continue through all placements (3rd, 4th, ..., 8th) + */ +function compareTeamsForRanking( + teamA: { + totalPoints: number; + placementCounts: Record; + }, + teamB: { + totalPoints: number; + placementCounts: Record; + } +): number { + // First compare by total points (descending) + if (teamA.totalPoints !== teamB.totalPoints) { + return teamB.totalPoints - teamA.totalPoints; + } + + // If points are tied, apply tiebreaker cascade + for (let placement = 1; placement <= 8; placement++) { + const countA = teamA.placementCounts[placement] || 0; + const countB = teamB.placementCounts[placement] || 0; + + if (countA !== countB) { + return countB - countA; // More of this placement is better + } + } + + // Completely tied + return 0; +} + +/** + * Assign ranks to teams based on their scores and tiebreakers + * Teams with identical scores and placements will share the same rank + */ +function assignRanks( + teams: Array<{ + teamId: string; + totalPoints: number; + placementCounts: Record; + }> +): Map { + // Sort teams by ranking criteria + const sorted = [...teams].sort(compareTeamsForRanking); + + const ranks = new Map(); + let currentRank = 1; + + for (let i = 0; i < sorted.length; i++) { + const team = sorted[i]; + + // Check if this team is tied with the previous team + if (i > 0) { + const prevTeam = sorted[i - 1]; + const comparison = compareTeamsForRanking(team, prevTeam); + + if (comparison !== 0) { + // Not tied, advance rank + currentRank = i + 1; + } + // If comparison === 0, keep the same rank (tie) + } + + ranks.set(team.teamId, currentRank); + } + + return ranks; +} + /** * Recalculate standings for all teams in a season * Called after any scoring event completes or participant results change - * Implementation will be completed in Phase 4 + * + * Phase 4.1: Implements tiebreaker logic and ranking */ export async function recalculateStandings( seasonId: string, @@ -613,62 +691,71 @@ export async function recalculateStandings( ): Promise { const db = providedDb || database(); - // TODO: Complete implementation in Phase 4 - // For now, calculate basic scores without rankings - const teams = await db.query.teams.findMany({ where: eq(schema.teams.seasonId, seasonId), }); - for (const team of teams) { - const teamScore = await calculateTeamScore(team.id, seasonId, db); + // Calculate scores for all teams + const teamScores = await Promise.all( + teams.map(async (team) => { + const score = await calculateTeamScore(team.id, seasonId, db); + return { + teamId: team.id, + totalPoints: score.totalPoints, + placementCounts: score.placementCounts, + participantsCompleted: score.participantsCompleted, + participantsTotal: score.participantsTotal, + }; + }) + ); - // Upsert team standings + // Assign ranks based on tiebreaker logic + const ranks = assignRanks(teamScores); + + // Update team standings with scores and ranks + for (const teamScore of teamScores) { + const newRank = ranks.get(teamScore.teamId) || 1; + + // Get existing standing to preserve previousRank const existing = await db.query.teamStandings.findFirst({ where: and( - eq(schema.teamStandings.teamId, team.id), + eq(schema.teamStandings.teamId, teamScore.teamId), eq(schema.teamStandings.seasonId, seasonId) ), }); + const standingData = { + totalPoints: teamScore.totalPoints.toString(), + currentRank: newRank, + previousRank: existing ? existing.currentRank : null, + firstPlaceCount: teamScore.placementCounts[1], + secondPlaceCount: teamScore.placementCounts[2], + thirdPlaceCount: teamScore.placementCounts[3], + fourthPlaceCount: teamScore.placementCounts[4], + fifthPlaceCount: teamScore.placementCounts[5], + sixthPlaceCount: teamScore.placementCounts[6], + seventhPlaceCount: teamScore.placementCounts[7], + eighthPlaceCount: teamScore.placementCounts[8], + participantsRemaining: + teamScore.participantsTotal - teamScore.participantsCompleted, + calculatedAt: new Date(), + }; + if (existing) { await db .update(schema.teamStandings) - .set({ - totalPoints: teamScore.totalPoints.toString(), - firstPlaceCount: teamScore.placementCounts[1], - secondPlaceCount: teamScore.placementCounts[2], - thirdPlaceCount: teamScore.placementCounts[3], - fourthPlaceCount: teamScore.placementCounts[4], - fifthPlaceCount: teamScore.placementCounts[5], - sixthPlaceCount: teamScore.placementCounts[6], - seventhPlaceCount: teamScore.placementCounts[7], - eighthPlaceCount: teamScore.placementCounts[8], - participantsRemaining: - teamScore.participantsTotal - teamScore.participantsCompleted, - calculatedAt: new Date(), - }) + .set(standingData) .where(eq(schema.teamStandings.id, existing.id)); } else { await db.insert(schema.teamStandings).values({ - teamId: team.id, + teamId: teamScore.teamId, seasonId, - totalPoints: teamScore.totalPoints.toString(), - currentRank: 1, // Will be updated when we add ranking logic - firstPlaceCount: teamScore.placementCounts[1], - secondPlaceCount: teamScore.placementCounts[2], - thirdPlaceCount: teamScore.placementCounts[3], - fourthPlaceCount: teamScore.placementCounts[4], - fifthPlaceCount: teamScore.placementCounts[5], - sixthPlaceCount: teamScore.placementCounts[6], - seventhPlaceCount: teamScore.placementCounts[7], - eighthPlaceCount: teamScore.placementCounts[8], - participantsRemaining: teamScore.participantsTotal - teamScore.participantsCompleted, + ...standingData, }); } } - console.log(`[ScoringCalculator] Recalculated standings for season ${seasonId}`); + console.log(`[ScoringCalculator] Recalculated standings for season ${seasonId}: ${teams.length} teams ranked`); } /** diff --git a/app/models/standings.ts b/app/models/standings.ts index 47361a7..5305ee3 100644 --- a/app/models/standings.ts +++ b/app/models/standings.ts @@ -1,33 +1,10 @@ import { database } from "~/database/context"; import * as schema from "~/database/schema"; import { eq, and, desc } from "drizzle-orm"; +import type { TeamStanding, TeamStandingSnapshot } from "~/types/standings"; -export interface TeamStanding { - teamId: string; - teamName: string; - totalPoints: number; - currentRank: number; - previousRank: number | null; - rankChange: number; // Positive = moved up, negative = moved down - placementCounts: { - first: number; - second: number; - third: number; - fourth: number; - fifth: number; - sixth: number; - seventh: number; - eighth: number; - }; - participantsRemaining: number; - calculatedAt: Date; -} - -export interface TeamStandingSnapshot { - date: Date; - rank: number; - totalPoints: number; -} +// Re-export types from shared types file +export type { TeamStanding, TeamStandingSnapshot } from "~/types/standings"; /** * Get current standings for a season @@ -40,16 +17,15 @@ export async function getSeasonStandings( const standings = await db.query.teamStandings.findMany({ where: eq(schema.teamStandings.seasonId, seasonId), - orderBy: [ - desc(schema.teamStandings.currentRank), // Lower rank number = better - desc(schema.teamStandings.totalPoints), - ], with: { team: true, }, }); - return standings.map((standing) => ({ + // Sort by currentRank ascending (1 is best, 2 is second, etc.) + const sorted = standings.sort((a, b) => a.currentRank - b.currentRank); + + return sorted.map((standing) => ({ teamId: standing.teamId, teamName: standing.team.name, totalPoints: parseFloat(standing.totalPoints), diff --git a/app/routes.ts b/app/routes.ts index 44b1fd7..b53e6f5 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -18,6 +18,10 @@ export default [ "leagues/:leagueId/draft-board/:seasonId", "routes/leagues/$leagueId.draft-board.$seasonId.tsx" ), + route( + "leagues/:leagueId/standings/:seasonId", + "routes/leagues/$leagueId.standings.$seasonId.tsx" + ), route("teams/:teamId/settings", "routes/teams/$teamId.settings.tsx"), route("api/webhooks/clerk", "routes/api/webhooks/clerk.ts"), route("api/queue/add", "routes/api/queue.add.ts"), diff --git a/app/routes/leagues/$leagueId.standings.$seasonId.tsx b/app/routes/leagues/$leagueId.standings.$seasonId.tsx new file mode 100644 index 0000000..3d355b7 --- /dev/null +++ b/app/routes/leagues/$leagueId.standings.$seasonId.tsx @@ -0,0 +1,187 @@ +import { useLoaderData, Link } from "react-router"; +import { getAuth } from "@clerk/react-router/server"; +import { database } from "~/database/context"; +import { eq, and } from "drizzle-orm"; +import * as schema from "~/database/schema"; +import { Button } from "~/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; +import { StandingsTable } from "~/components/standings/StandingsTable"; + +export async function loader(args: any) { + try { + const auth = await getAuth(args); + const userId = (auth as any).userId as string | null; + const { params } = args; + const { leagueId, seasonId } = params; + + const db = database(); + + // Fetch league + const league = await db.query.leagues.findFirst({ + where: eq(schema.leagues.id, leagueId), + }); + + if (!league) { + throw new Response("League not found", { status: 404 }); + } + + // Fetch season + const season = await db.query.seasons.findFirst({ + where: eq(schema.seasons.id, seasonId), + }); + + if (!season || season.leagueId !== leagueId) { + throw new Response("Season not found", { status: 404 }); + } + + // Check access + if (!userId) { + throw new Response("You must be logged in to view standings", { + status: 401, + }); + } + + // Check if user is a commissioner + const isUserCommissioner = await db.query.commissioners.findFirst({ + where: and( + eq(schema.commissioners.leagueId, leagueId), + eq(schema.commissioners.userId, userId) + ), + }); + + // Check if user has a team in this season + const hasTeam = await db.query.teams.findFirst({ + where: and( + eq(schema.teams.seasonId, seasonId), + eq(schema.teams.ownerId, userId) + ), + }); + + if (!isUserCommissioner && !hasTeam) { + throw new Response("You do not have access to this league", { + status: 403, + }); + } + + // Fetch standings + const standings = await db.query.teamStandings.findMany({ + where: eq(schema.teamStandings.seasonId, seasonId), + with: { + team: true, + }, + }); + + // Sort by currentRank ascending (1 is best) + const sorted = standings.sort((a, b) => a.currentRank - b.currentRank); + + const formattedStandings = sorted.map((standing) => { + if (!standing.team) { + console.error("Standing missing team:", standing); + throw new Error(`Standing for team ${standing.teamId} has no team relation`); + } + + return { + teamId: standing.teamId, + teamName: standing.team.name, + totalPoints: parseFloat(standing.totalPoints || "0"), + currentRank: standing.currentRank || 0, + previousRank: standing.previousRank || null, + rankChange: standing.previousRank + ? standing.previousRank - standing.currentRank + : 0, + placementCounts: { + first: standing.firstPlaceCount || 0, + second: standing.secondPlaceCount || 0, + third: standing.thirdPlaceCount || 0, + fourth: standing.fourthPlaceCount || 0, + fifth: standing.fifthPlaceCount || 0, + sixth: standing.sixthPlaceCount || 0, + seventh: standing.seventhPlaceCount || 0, + eighth: standing.eighthPlaceCount || 0, + }, + participantsRemaining: standing.participantsRemaining || 0, + calculatedAt: standing.calculatedAt, + }; + }); + + return { + league, + season, + standings: formattedStandings, + }; + } catch (error) { + console.error("Error loading standings:", error); + console.error("Error details:", { + message: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }); + throw error; + } +} + +export default function LeagueStandings() { + const { league, season, standings } = useLoaderData(); + + return ( +
+ {/* Header */} +
+ + +
+
+

{league.name}

+

+ {season.year} Season Standings +

+
+
+
+ + {/* Standings Card */} + + + Team Standings + + + {standings.length === 0 ? ( +
+

No standings data yet.

+

+ Standings will appear once participants have results. +

+
+ ) : ( + + )} +
+
+ + {/* Info Card */} + + +
+

+ Tiebreaker Rules: Teams are ranked by total + points. If tied, the team with more 1st place finishes ranks + higher. If still tied, 2nd place finishes are compared, then 3rd, + and so on through 8th place. +

+

+ Movement Indicators: Arrows show rank changes + since the last standings update. +

+

+ Placement Breakdown: Shows how many times each + team's participants finished in each position (1stΓ—2 means 2 first-place finishes). +

+
+
+
+
+ ); +} diff --git a/app/routes/leagues/$leagueId.tsx b/app/routes/leagues/$leagueId.tsx index e05bd58..b547b39 100644 --- a/app/routes/leagues/$leagueId.tsx +++ b/app/routes/leagues/$leagueId.tsx @@ -430,6 +430,15 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) { View Draft Board +
+

Standings

+ + View Standings + +

Flex Spots

diff --git a/app/types/standings.ts b/app/types/standings.ts new file mode 100644 index 0000000..1b6da7c --- /dev/null +++ b/app/types/standings.ts @@ -0,0 +1,30 @@ +/** + * Shared types for standings that can be used in both server and client code + */ + +export interface TeamStanding { + teamId: string; + teamName: string; + totalPoints: number; + currentRank: number; + previousRank: number | null; + rankChange: number; // Positive = moved up, negative = moved down + placementCounts: { + first: number; + second: number; + third: number; + fourth: number; + fifth: number; + sixth: number; + seventh: number; + eighth: number; + }; + participantsRemaining: number; + calculatedAt: Date; +} + +export interface TeamStandingSnapshot { + date: Date; + rank: number; + totalPoints: number; +} diff --git a/plans/scoring-system.md b/plans/scoring-system.md index 9b776b0..6d5f7e3 100644 --- a/plans/scoring-system.md +++ b/plans/scoring-system.md @@ -1266,10 +1266,44 @@ scoring_events { ### Phase 4: Standings & Display **Goal**: Full-featured standings with breakdowns and historical views -- [ ] **4.1** Enhanced standings table - - [ ] Implement tiebreaker logic (placement counts) - - [ ] Add sorting by tiebreakers - - [ ] Display placement breakdown per team +- [x] **4.1** Enhanced standings table βœ… *Completed* + - [x] Implement tiebreaker logic (placement counts) + - [x] Add sorting by tiebreakers + - [x] Display placement breakdown per team + - [x] Create standalone standings page route + - [x] Integrate StandingsTable component into route + - [x] Add navigation from league home page + - [x] Fix client/server code separation issues + - [x] Comprehensive test suite (13 tiebreaker tests + 21 component tests) + - [x] All 371 tests passing βœ… + + **Implementation Notes**: + - Tiebreaker cascade: Total points β†’ 1st place count β†’ 2nd place count β†’ ... β†’ 8th place count + - Teams with identical scores and placements share the same rank + - Rank numbers skip after ties (e.g., two teams tied for 1st, next is 3rd) + - `StandingsTable` component displays: + - Rank badges with trophy/medal icons for top 3 + - Total points with one decimal place + - Placement breakdown showing counts for each placement (1st-8th) + - Movement indicators (up/down arrows) + - Participants remaining count + - `recalculateStandings` function now assigns ranks using tiebreaker logic + - `previousRank` tracked for movement indicators + - Created standalone standings page at `/leagues/:leagueId/standings/:seasonId` + - Fixed module bundling issue by separating types into `app/types/standings.ts` + - Types can now be safely imported by both client and server code + - Added "View Standings" link in League Info section on league home page + - Files created/updated: + - app/models/scoring-calculator.ts (added compareTeamsForRanking, assignRanks) + - app/models/standings.ts (fixed sort order, re-exports types) + - app/types/standings.ts (new - shared type definitions) + - app/components/standings/StandingsTable.tsx (new component) + - app/routes/leagues/$leagueId.standings.$seasonId.tsx (new route) + - app/routes/leagues/$leagueId.tsx (added standings link) + - app/routes.ts (registered new route) + - tsconfig.node.json (added app/types/**/*.ts to include) + - app/models/__tests__/tiebreaker-logic.test.ts (new tests) + - app/components/standings/__tests__/StandingsTable.test.tsx (new tests) - [ ] **4.2** Movement tracking & snapshots - [ ] Daily snapshot creation (scheduled job) @@ -1291,14 +1325,20 @@ scoring_events { - [x] Ownership indicators with avatars + tooltips (Q11, Q15) - [x] Bracket display for playoff sports (Q3) - **Note**: Route exists but needs navigation from league home page (see 4.5 below) + **Note**: Route exists and displays correctly. Navigation cards on league home page pending (see 4.5) -- [ ] **4.5** League home page integration - - [ ] Display team standings table on league home page +- [ ] **4.5** League home page integration (partially complete) + - [x] Add navigation link to standings page from league home + - [ ] Display team standings table on league home page (or keep separate as currently implemented) - [ ] 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 + - [ ] Quick access to brackets from league home + + **Implementation Notes**: + - "View Standings" link added in League Info section (app/routes/leagues/$leagueId.tsx:433-440) + - Standalone standings page preferred over embedding in league home for cleaner UX + - Sport season detail pages already exist (Phase 3.4) - need navigation cards on league home - [ ] **4.6** Historical views - [ ] Season completion detection @@ -1394,10 +1434,12 @@ scoring_events { - **3.2** Qualifying Points (Golf/Tennis) - Two-phase scoring with configurable QP values - **3.3** AFL Finals - Double-chance system with Wildcard Round (10 teams) - **3.4** Pattern-specific UI Components - PlayoffBracket, SeasonStandings, SportSeasonDisplay components + member route -- ⏳ **Phase 4**: Standings & Display - TODO +- ⏳ **Phase 4**: Standings & Display - IN PROGRESS + - βœ… **4.1** Enhanced Standings Table - COMPLETE (tiebreaker logic, placement breakdowns, standalone page, navigation) + - ⏳ **4.5** League Home Integration - PARTIAL (standings link added, sports season cards pending) - ⏳ **Phase 5**: Expected Value - TODO - ⏳ **Phase 6**: Polish & Optimization - TODO -**Current Focus**: Phase 3.4 - Pattern-specific UI Components or Phase 4 - Standings & Display +**Current Focus**: Phase 4 - Standings & Display (4.1 complete with standalone page, 4.5 partially complete) -**Total Test Count**: 337 tests passing βœ… +**Total Test Count**: 371 tests passing βœ… diff --git a/tsconfig.node.json b/tsconfig.node.json index a5539d9..09d6cf3 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -6,6 +6,7 @@ "app/contexts/**/*.ts", "app/models/**/*.ts", "app/lib/**/*.ts", + "app/types/**/*.ts", "vite.config.ts" ], "exclude": [