brackt/app/routes/leagues/$leagueId.standings.$seasonId.tsx
Chris Parsons e201ecd28a
Extract reusable league wizard components; wire settings page (#350)
* Extract reusable league wizard components; wire settings page (#103)

Extracts 9 domain components from new.tsx and $leagueId.settings.tsx into
app/components/league/ (each with a Storybook story), consolidates wizard
form-building into wizard-state.ts, and updates the settings page to use
the shared components instead of hand-rolled duplicates. new.tsx shrinks
from ~2000 → ~1280 lines.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix sports-season test: add participants to mock data

findDraftableSportsSeasons now returns participantCount, which requires
participants in the mock db response.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix sports-season test: loosen makeMockDb type to Record<string, unknown>[]

typeof mockSeasons became too strict after adding participants to the mock
data, breaking inline arrays in other tests that don't include participants.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 22:24:13 -07:00

183 lines
6.3 KiB
TypeScript

import { useLoaderData, Link } from "react-router";
import { database } from "~/database/context";
import { eq } from "drizzle-orm";
import * as schema from "~/database/schema";
import { requireLeagueAccess } from "~/lib/auth";
import { Button } from "~/components/ui/button";
import { logger } from "~/lib/logger";
import { getSevenDayStandingsChange, getSeasonPointProgression } from "~/models/standings";
import { isSeasonComplete, getSeasonCompletionPercentage } from "~/lib/season-helpers.server";
import { getRecentTeamScoreEvents } from "~/models/team-score-events";
import { PointProgressionChart } from "~/components/standings/PointProgressionChart";
import { findUsersByIds, getUserDisplayName } from "~/models/user";
import { StandingsPreview } from "~/components/league/StandingsPreview";
import { buildTiedRankChecker, getDisplayRank } from "~/lib/standings-display";
import { RecentScoresCard } from "~/components/standings/RecentScoresCard";
import type { Route } from "./+types/$leagueId.standings.$seasonId";
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
return [{ title: `Standings — ${data?.league?.name ?? "League"} - Brackt` }];
}
export async function loader(args: Route.LoaderArgs) {
try {
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
await requireLeagueAccess(args, {
leagueId,
seasonId,
db,
unauthMessage: "You must be logged in to view standings",
unauthorizedMessage: "You do not have access to this league",
});
// Fetch standings, teams (for owner lookup), and independent data in parallel
const [standingsWithComparison, teams, progressionData, seasonComplete, completionPercentage, recentScoreEvents] =
await Promise.all([
getSevenDayStandingsChange(seasonId, db),
db.query.teams.findMany({
where: eq(schema.teams.seasonId, seasonId),
columns: { id: true, ownerId: true },
}),
getSeasonPointProgression(seasonId, db),
isSeasonComplete(seasonId, db),
getSeasonCompletionPercentage(seasonId, db),
getRecentTeamScoreEvents(seasonId, 10, db),
]);
// Build teamId -> ownerName map
const ownerIds = [...new Set(teams.map((t) => t.ownerId).filter((id): id is string => id !== null))];
const userRows = await findUsersByIds(ownerIds);
const userById = new Map(userRows.map((u) => [u.id, u]));
const ownerNameByTeamId = new Map(
teams
.filter((t): t is typeof t & { ownerId: string } => t.ownerId !== null)
.map((t) => {
const user = userById.get(t.ownerId);
return [t.id, user ? getUserDisplayName(user) : null] as const;
})
);
// Map to format expected by component (use sevenDayRankChange instead of previousRank)
const formattedStandings = standingsWithComparison.map((standing) => ({
...standing,
rankChange: standing.sevenDayRankChange,
ownerName: ownerNameByTeamId.get(standing.teamId) ?? null,
}));
// Enrich recentScoreEvents with owner names
const enrichedScoreEvents = recentScoreEvents.map((event) => ({
...event,
ownerName: ownerNameByTeamId.get(event.teamId) ?? null,
}));
return {
league,
season,
standings: formattedStandings,
progressionData,
seasonComplete,
completionPercentage,
recentScoreEvents: enrichedScoreEvents,
};
} catch (error) {
if (error instanceof Response) {
throw error;
}
logger.error("Error loading standings:", error);
throw error;
}
}
export default function LeagueStandings() {
const { league, season, standings, progressionData, seasonComplete, completionPercentage, recentScoreEvents } = useLoaderData<typeof loader>();
const isTied = buildTiedRankChecker(standings.map((s) => s.currentRank));
const previewEntries = standings.map((s) => ({
teamId: s.teamId,
teamName: s.teamName,
ownerName: s.ownerName,
displayRank: getDisplayRank(s, standings.length, isTied(s.currentRank)),
currentRank: s.currentRank,
points: s.totalPoints,
rankChange: s.rankChange,
pointChange: s.sevenDayPointChange,
href: `/leagues/${league.id}/standings/${season.id}/teams/${s.teamId}`,
}));
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 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>
</div>
</div>
{/* Two-column layout */}
<div className="grid gap-6 md:grid-cols-3">
{/* Left: Standings + Point Progression (2/3 width) */}
<div className="md:col-span-2 space-y-6">
<StandingsPreview
entries={previewEntries}
description={seasonComplete ? "Final Standings" : "Current Standings"}
/>
{progressionData.chartData.length > 0 && (
<PointProgressionChart
chartData={progressionData.chartData}
teams={progressionData.teams}
/>
)}
</div>
{/* Right: Recent Scores (1/3 width) */}
<RecentScoresCard events={recentScoreEvents} />
</div>
</div>
);
}