2025-11-13 13:24:03 -08:00
|
|
|
|
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";
|
2025-11-14 21:18:34 -08:00
|
|
|
|
import { getSevenDayStandingsChange, getSeasonPointProgression } from "~/models/standings";
|
|
|
|
|
|
import { isSeasonComplete, getSeasonCompletionPercentage } from "~/lib/season-helpers.server";
|
|
|
|
|
|
import { PointProgressionChart } from "~/components/standings/PointProgressionChart";
|
2025-11-13 13:24:03 -08:00
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-14 09:15:58 -08:00
|
|
|
|
// Fetch standings with 7-day comparison
|
|
|
|
|
|
const standingsWithComparison = await getSevenDayStandingsChange(seasonId, db);
|
2025-11-13 13:24:03 -08:00
|
|
|
|
|
2025-11-14 09:15:58 -08:00
|
|
|
|
// Map to format expected by component (use sevenDayRankChange instead of previousRank)
|
|
|
|
|
|
const formattedStandings = standingsWithComparison.map((standing) => ({
|
|
|
|
|
|
...standing,
|
|
|
|
|
|
rankChange: standing.sevenDayRankChange,
|
|
|
|
|
|
}));
|
2025-11-13 13:24:03 -08:00
|
|
|
|
|
2025-11-14 21:18:34 -08:00
|
|
|
|
// Fetch historical progression data
|
|
|
|
|
|
const progressionData = await getSeasonPointProgression(seasonId, db);
|
|
|
|
|
|
|
|
|
|
|
|
// Check season completion status
|
|
|
|
|
|
const seasonComplete = await isSeasonComplete(seasonId, db);
|
|
|
|
|
|
const completionPercentage = await getSeasonCompletionPercentage(seasonId, db);
|
|
|
|
|
|
|
2025-11-13 13:24:03 -08:00
|
|
|
|
return {
|
|
|
|
|
|
league,
|
|
|
|
|
|
season,
|
|
|
|
|
|
standings: formattedStandings,
|
2025-11-14 21:18:34 -08:00
|
|
|
|
progressionData,
|
|
|
|
|
|
seasonComplete,
|
|
|
|
|
|
completionPercentage,
|
2025-11-13 13:24:03 -08:00
|
|
|
|
};
|
|
|
|
|
|
} 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() {
|
2025-11-14 21:18:34 -08:00
|
|
|
|
const { league, season, standings, progressionData, seasonComplete, completionPercentage } = useLoaderData<typeof loader>();
|
2025-11-13 13:24:03 -08:00
|
|
|
|
|
|
|
|
|
|
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>
|
2025-11-14 21:18:34 -08:00
|
|
|
|
<div className="text-right">
|
|
|
|
|
|
{seasonComplete ? (
|
|
|
|
|
|
<div className="inline-flex items-center px-3 py-1 rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100 text-sm font-medium">
|
|
|
|
|
|
✓ Season Complete
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="text-sm text-muted-foreground">
|
|
|
|
|
|
{completionPercentage}% Complete
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2025-11-13 13:24:03 -08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-11-14 21:18:34 -08:00
|
|
|
|
{/* Point Progression Chart */}
|
|
|
|
|
|
{progressionData.chartData.length > 0 && (
|
|
|
|
|
|
<div className="mb-6">
|
|
|
|
|
|
<PointProgressionChart
|
|
|
|
|
|
chartData={progressionData.chartData}
|
|
|
|
|
|
teams={progressionData.teams}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2025-11-13 13:24:03 -08:00
|
|
|
|
{/* Standings Card */}
|
|
|
|
|
|
<Card>
|
|
|
|
|
|
<CardHeader>
|
2025-11-14 21:18:34 -08:00
|
|
|
|
<CardTitle>
|
|
|
|
|
|
{seasonComplete ? "Final Standings" : "Current Standings"}
|
|
|
|
|
|
</CardTitle>
|
2025-11-13 13:24:03 -08:00
|
|
|
|
</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>
|
|
|
|
|
|
) : (
|
2025-11-14 20:01:21 -08:00
|
|
|
|
<StandingsTable
|
|
|
|
|
|
standings={standings}
|
|
|
|
|
|
leagueId={league.id}
|
|
|
|
|
|
seasonId={season.id}
|
|
|
|
|
|
showPlacementBreakdown={true}
|
|
|
|
|
|
/>
|
2025-11-13 13:24:03 -08:00
|
|
|
|
)}
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Info Card */}
|
|
|
|
|
|
<Card className="mt-6">
|
|
|
|
|
|
<CardContent className="pt-6">
|
|
|
|
|
|
<div className="text-sm text-muted-foreground space-y-2">
|
2026-02-14 22:30:12 -08:00
|
|
|
|
<p>
|
|
|
|
|
|
<strong>Projected Points:</strong> Shows actual points from finished
|
|
|
|
|
|
participants plus expected value (EV) from remaining participants based
|
|
|
|
|
|
on their probability distributions. The "+X.X EV" indicates how many
|
|
|
|
|
|
additional points are expected from unfinished participants.
|
|
|
|
|
|
</p>
|
2025-11-13 13:24:03 -08:00
|
|
|
|
<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
|
2025-11-14 09:15:58 -08:00
|
|
|
|
compared to 7 days ago. Green arrows (↑) indicate improvement in rank,
|
|
|
|
|
|
red arrows (↓) indicate decline.
|
2025-11-13 13:24:03 -08:00
|
|
|
|
</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>
|
2025-11-14 21:18:34 -08:00
|
|
|
|
{progressionData.chartData.length > 0 && (
|
|
|
|
|
|
<p>
|
|
|
|
|
|
<strong>Point Progression Chart:</strong> Visualizes how each team's
|
|
|
|
|
|
total points evolved over time based on {progressionData.chartData.length} day{progressionData.chartData.length !== 1 ? 's' : ''} of snapshot data.
|
|
|
|
|
|
{seasonComplete && " Use this to see how the final standings developed throughout the season."}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
2025-11-13 13:24:03 -08:00
|
|
|
|
</div>
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|