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:
parent
604eb6980c
commit
0385ca220c
11 changed files with 1257 additions and 77 deletions
183
app/components/standings/StandingsTable.tsx
Normal file
183
app/components/standings/StandingsTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
322
app/components/standings/__tests__/StandingsTable.test.tsx
Normal file
322
app/components/standings/__tests__/StandingsTable.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
339
app/models/__tests__/tiebreaker-logic.test.ts
Normal file
339
app/models/__tests__/tiebreaker-logic.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,29 +691,43 @@ 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)
|
||||
),
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await db
|
||||
.update(schema.teamStandings)
|
||||
.set({
|
||||
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],
|
||||
|
|
@ -647,28 +739,23 @@ export async function recalculateStandings(
|
|||
participantsRemaining:
|
||||
teamScore.participantsTotal - teamScore.participantsCompleted,
|
||||
calculatedAt: new Date(),
|
||||
})
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
await db
|
||||
.update(schema.teamStandings)
|
||||
.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`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
187
app/routes/leagues/$leagueId.standings.$seasonId.tsx
Normal file
187
app/routes/leagues/$leagueId.standings.$seasonId.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
30
app/types/standings.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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 ✅
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
"app/contexts/**/*.ts",
|
||||
"app/models/**/*.ts",
|
||||
"app/lib/**/*.ts",
|
||||
"app/types/**/*.ts",
|
||||
"vite.config.ts"
|
||||
],
|
||||
"exclude": [
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue