- 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.
187 lines
5.7 KiB
TypeScript
187 lines
5.7 KiB
TypeScript
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>
|
||
);
|
||
}
|