feat: add standings page and enhance standings functionality

- Added a new route for league standings: `/leagues/:leagueId/standings/:seasonId`
- Implemented the `StandingsTable` component to display team standings with ranking, points, and placement breakdown.
- Integrated tiebreaker logic for ranking teams based on total points and placements.
- Enhanced the league home page with a link to view standings.
- Updated scoring system documentation to reflect the new standings features.
- Added comprehensive tests for tiebreaker logic and `StandingsTable` component.
- Created shared types for standings to be used in both server and client code.
This commit is contained in:
Chris Parsons 2025-11-13 13:24:03 -08:00
parent 604eb6980c
commit 0385ca220c
11 changed files with 1257 additions and 77 deletions

View file

@ -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 (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[80px]">Rank</TableHead>
<TableHead>Team</TableHead>
<TableHead className="text-right">Points</TableHead>
{showPlacementBreakdown && (
<TableHead className="text-center">Placements</TableHead>
)}
<TableHead className="text-right">Remaining</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{standings.length === 0 ? (
<TableRow>
<TableCell colSpan={showPlacementBreakdown ? 5 : 4} className="text-center text-muted-foreground">
No standings data available
</TableCell>
</TableRow>
) : (
standings.map((standing) => (
<TableRow key={standing.teamId}>
<TableCell>
<div className="flex items-center gap-2">
<RankBadge rank={standing.currentRank} />
{standing.rankChange !== 0 && (
<MovementIndicator change={standing.rankChange} />
)}
</div>
</TableCell>
<TableCell className="font-medium">{standing.teamName}</TableCell>
<TableCell className="text-right font-semibold">
{standing.totalPoints.toFixed(1)}
</TableCell>
{showPlacementBreakdown && (
<TableCell>
<PlacementBreakdown placements={standing.placementCounts} />
</TableCell>
)}
<TableCell className="text-right text-muted-foreground">
{standing.participantsRemaining > 0 ? (
<Badge variant="secondary">
{standing.participantsRemaining} remaining
</Badge>
) : (
<Badge variant="outline">Complete</Badge>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
);
}
/**
* Display rank badge with special styling for top 3
*/
function RankBadge({ rank }: { rank: number }) {
if (rank === 1) {
return (
<Badge className="bg-yellow-500 hover:bg-yellow-600 text-white font-bold">
🏆 1st
</Badge>
);
}
if (rank === 2) {
return (
<Badge className="bg-gray-400 hover:bg-gray-500 text-white font-bold">
🥈 2nd
</Badge>
);
}
if (rank === 3) {
return (
<Badge className="bg-orange-600 hover:bg-orange-700 text-white font-bold">
🥉 3rd
</Badge>
);
}
return (
<Badge variant="outline" className="font-semibold">
{rank}
</Badge>
);
}
/**
* Show movement indicator (up/down arrow)
*/
function MovementIndicator({ change }: { change: number }) {
if (change > 0) {
return (
<span className="text-green-600 text-sm font-medium" title={`Up ${change} ${change === 1 ? 'place' : 'places'}`}>
{change}
</span>
);
}
if (change < 0) {
return (
<span className="text-red-600 text-sm font-medium" title={`Down ${Math.abs(change)} ${Math.abs(change) === 1 ? 'place' : 'places'}`}>
{Math.abs(change)}
</span>
);
}
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 <span className="text-muted-foreground text-sm">None yet</span>;
}
return (
<div className="flex flex-wrap gap-2 justify-center">
{nonZero.map((item) => (
<span
key={item.label}
className={`text-sm font-medium ${item.color}`}
title={`${item.count} ${item.label} place ${item.count === 1 ? 'finish' : 'finishes'}`}
>
{item.label}×{item.count}
</span>
))}
</div>
);
}

View file

@ -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(<StandingsTable standings={mockStandings} />);
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(<StandingsTable standings={mockStandings} />);
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(<StandingsTable standings={[]} />);
expect(screen.getByText("No standings data available")).toBeInTheDocument();
});
});
describe("Rank Badges", () => {
it("should show trophy icon for 1st place", () => {
render(<StandingsTable standings={mockStandings} />);
expect(screen.getByText(/🏆.*1st/)).toBeInTheDocument();
});
it("should show silver medal icon for 2nd place", () => {
render(<StandingsTable standings={mockStandings} />);
expect(screen.getByText(/🥈.*2nd/)).toBeInTheDocument();
});
it("should show bronze medal icon for 3rd place", () => {
render(<StandingsTable standings={mockStandings} />);
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(<StandingsTable standings={standings} />);
// 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(<StandingsTable standings={mockStandings} />);
expect(screen.getByText("↑1")).toBeInTheDocument();
});
it("should show down arrow for negative rank change", () => {
render(<StandingsTable standings={mockStandings} />);
expect(screen.getByText("↓1")).toBeInTheDocument();
});
it("should not show indicator for no rank change", () => {
render(<StandingsTable standings={mockStandings} />);
// 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(<StandingsTable standings={singleTeam} showPlacementBreakdown />);
// 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(<StandingsTable standings={mockStandings} showPlacementBreakdown />);
// 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(<StandingsTable standings={mockStandings} showPlacementBreakdown={false} />);
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(<StandingsTable standings={standings} showPlacementBreakdown />);
expect(screen.getByText("None yet")).toBeInTheDocument();
});
});
describe("Participants Remaining", () => {
it("should show remaining count when participants are incomplete", () => {
render(<StandingsTable standings={mockStandings} />);
expect(screen.getByText("3 remaining")).toBeInTheDocument();
expect(screen.getByText("5 remaining")).toBeInTheDocument();
});
it("should show 'Complete' badge when all participants done", () => {
render(<StandingsTable standings={mockStandings} />);
expect(screen.getByText("Complete")).toBeInTheDocument();
});
});
describe("Table Structure", () => {
it("should have correct table headers", () => {
render(<StandingsTable standings={mockStandings} />);
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(<StandingsTable standings={mockStandings} />);
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(<StandingsTable standings={tiedStandings} />);
// 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(<StandingsTable standings={mockStandings} />);
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(<StandingsTable standings={mockStandings} showPlacementBreakdown />);
const firstPlace = container.querySelector('[title*="1st place"]');
expect(firstPlace).toBeInTheDocument();
expect(firstPlace?.getAttribute("title")).toContain("2 1st place finishes");
});
});
});

View file

@ -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<number, number>;
},
teamB: {
totalPoints: number;
placementCounts: Record<number, number>;
}
): 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<number, number>;
}>
): Map<string, number> {
// Sort teams by ranking criteria
const sorted = [...teams].sort(compareTeamsForRanking);
const ranks = new Map<string, number>();
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<number, number>, // 2 firsts
},
{
teamId: "team-b",
totalPoints: 215,
placementCounts: { 1: 1, 2: 1, 8: 1 } as Record<number, number>, // 1 first
},
{
teamId: "team-c",
totalPoints: 215,
placementCounts: { 2: 2, 3: 1 } as Record<number, number>, // 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<number, number>, // 1 first, 2 seconds
},
{
teamId: "team-b",
totalPoints: 240,
placementCounts: { 1: 1, 3: 2, 8: 1 } as Record<number, number>, // 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<number, number>, // 0×1st, 1×2nd, 1×3rd, 1×4th
},
{
teamId: "team-b",
totalPoints: 160,
placementCounts: { 2: 1, 3: 1, 5: 2 } as Record<number, number>, // 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<number, number> },
{ teamId: "team-b", totalPoints: 300, placementCounts: { 1: 3 } as Record<number, number> }, // Tied
{ teamId: "team-c", totalPoints: 210, placementCounts: { 2: 3 } as Record<number, number> },
];
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<number, number> },
{ teamId: "team-b", totalPoints: 0, placementCounts: {} as Record<number, number> },
];
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);
});
});
});

View file

@ -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<number, number>;
},
teamB: {
totalPoints: number;
placementCounts: Record<number, number>;
}
): 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<number, number>;
}>
): Map<string, number> {
// Sort teams by ranking criteria
const sorted = [...teams].sort(compareTeamsForRanking);
const ranks = new Map<string, number>();
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<void> {
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`);
}
/**

View file

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

View file

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

View file

@ -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<typeof loader>();
return (
<div className="container mx-auto py-8 px-4">
{/* Header */}
<div className="mb-8">
<Button variant="ghost" className="mb-4" asChild>
<Link to={`/leagues/${league.id}`}>
Back to League
</Link>
</Button>
<div className="flex items-center justify-between">
<div>
<h1 className="text-4xl font-bold mb-2">{league.name}</h1>
<p className="text-muted-foreground text-lg">
{season.year} Season Standings
</p>
</div>
</div>
</div>
{/* Standings Card */}
<Card>
<CardHeader>
<CardTitle>Team Standings</CardTitle>
</CardHeader>
<CardContent>
{standings.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<p className="text-lg">No standings data yet.</p>
<p className="text-sm mt-2">
Standings will appear once participants have results.
</p>
</div>
) : (
<StandingsTable standings={standings} showPlacementBreakdown={true} />
)}
</CardContent>
</Card>
{/* Info Card */}
<Card className="mt-6">
<CardContent className="pt-6">
<div className="text-sm text-muted-foreground space-y-2">
<p>
<strong>Tiebreaker Rules:</strong> 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.
</p>
<p>
<strong>Movement Indicators:</strong> Arrows show rank changes
since the last standings update.
</p>
<p>
<strong>Placement Breakdown:</strong> Shows how many times each
team's participants finished in each position (1st×2 means 2 first-place finishes).
</p>
</div>
</CardContent>
</Card>
</div>
);
}

View file

@ -430,6 +430,15 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
View Draft Board
</Link>
</div>
<div>
<p className="text-sm text-muted-foreground">Standings</p>
<Link
to={`/leagues/${league.id}/standings/${season.id}`}
className="text-blue-600 hover:underline font-medium"
>
View Standings
</Link>
</div>
<div>
<p className="text-sm text-muted-foreground">Flex Spots</p>
<p className="font-medium text-primary">

30
app/types/standings.ts Normal file
View file

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

View file

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

View file

@ -6,6 +6,7 @@
"app/contexts/**/*.ts",
"app/models/**/*.ts",
"app/lib/**/*.ts",
"app/types/**/*.ts",
"vite.config.ts"
],
"exclude": [