brackt/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.server.ts
Chris Parsons 9ed0282fd0
New design (#309)
* Redesign home page with new layout and component system

- Two-column layout (My Leagues 2/3, Upcoming Events 1/3) with mobile stack
- LeagueRow: square avatar, gradient draft highlight, rank/points display, progress bar
- MyLeaguesCard, CreateLeagueCard with shared SectionCardHeader
- UpcomingEventsCard: vertical timeline with grouped multi-league events
- Shared gradient system: BracktGradients SVG defs, GradientIcon wrapper, brand.ts constants
- Button default variant updated to green→cyan gradient
- Navbar: plain nav links with gradient hover, support/admin icon buttons
- Accessibility fixes: semantic h2 headings, aria-label on LeagueAvatar and nav elements
- Storybook stories for all new components

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

* Responsive league row layout and mobile polish

- League rows stack avatar+name on top, stats full-width below on mobile
- Stats spread to right side on sm+ screens with border separator on mobile
- Tighter padding on mobile (px-3/py-3), full padding on sm+
- Card headers and content use px-3 sm:px-6 to reduce mobile gutters
- Two-column home layout deferred to lg breakpoint (tablet gets stacked)
- Active leagues sorted by completion percentage descending
- Default rank 1 / 0 points for active leagues with no scoring events yet
- Fix ordinal bug for 11th/12th/13th; add aria-labels to rank change indicators
- Remove dead StatDivider className prop

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

* Improve claude file.

* Add StandingsPreview card component with podium row styling

- New StandingsPreview component with gold/silver/bronze row tints for
  top 3, team avatar, and LeagueRow-style stat columns (Ranking + Points)
  with rank and 7-day point change indicators
- Fix GradientIcon in Storybook by adding BracktGradients decorator to
  preview.tsx (renamed from .ts to support JSX)
- Fix degenerate SVG gradient on horizontal strokes by switching
  BracktGradients to gradientUnits="userSpaceOnUse" with Lucide-space
  coordinates (0→24)
- Revert erroneous fill: url(#gradient) from GradientIcon; stroke-only
  fix was sufficient once gradientUnits was corrected

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

* Update components on league homepage.

* Finish up league page styling.

* Work on standings page.

* Add story for RecentScoresCard

* Update Point Progression Chart.

* Sort point progression legend by ranking and add team links to standings rows

* Fix standings discrepancy on change.

* Create draft cell component.

* Update draft board page

* Draft room improvements.

* Update some draft room styling.

* Fix context menu missing.

* Move tab navigation and autodraft to header row, narrow sidebar

* Virtualize available participants list, memoize draft room props

Adds @tanstack/react-virtual to replace separate mobile/desktop lists
with a single unified virtual scroll loop. Also memoizes miniDraftGrid
and availableParticipantsSectionProps, and switches pick lookup from
Array.find to a Map for O(1) access.

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

* Update draft room UI.

* More draft room fixes.

* Draft room tweaks.

* Fix Rosters page.

* Queue Section fixes.

* Mobile Draft fixes.

* Fix draft board page.

* Create bracket look.

* Bracket work.

* Finish bracket page.

* Homepage initial styling

* homepage copy

* Add privacy policy. Fixes #88.

* how to play copy

* rules copy

* Fix brackets on homepage.

* Add footer to website.

* Glow on dots.

* Landing page copy.

* Fix sidebar.

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 13:14:55 -07:00

346 lines
13 KiB
TypeScript

import { getAuth } from "@clerk/react-router/server";
import type { Route } from "./+types/$leagueId.sports-seasons.$sportsSeasonId";
import {
findLeagueById,
isUserLeagueMember,
isCommissioner,
findTeamsBySeasonId,
getUserDisplayName,
} from "~/models";
import { getDraftPicks } from "~/models/draft-pick";
import { getSeasonResults } from "~/models/participant-season-result";
import { calculateBracketPoints } from "~/models/scoring-rules";
import { getQPStandings } from "~/models/qualifying-points";
import {
getUpcomingScoringEvents,
getRecentCompletedEvents,
} from "~/models/scoring-event";
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
import { getAllParticipantEVsForSeason } from "~/models/participant-expected-value";
import { getBracketTemplate, getOrderedRoundsFromMatches } from "~/lib/bracket-templates";
import {
findMatchesByGroupIds,
computeGroupStandings,
} from "~/models/group-stage-match";
import { findGroupsByEventId } from "~/models/tournament-group";
import type { PlayoffMatch } from "~/models/playoff-match";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq, and, inArray } from "drizzle-orm";
export async function loader(args: Route.LoaderArgs) {
const { userId } = await getAuth(args);
const { params } = args;
const { leagueId, sportsSeasonId } = params;
// Check authentication
if (!userId) {
throw new Response("You must be logged in to view this page", {
status: 401,
});
}
// Fetch league
const league = await findLeagueById(leagueId);
if (!league) {
throw new Response("League not found", { status: 404 });
}
// Check access: user must be a commissioner or member
const isUserCommissioner = await isCommissioner(leagueId, userId);
const isUserMember = await isUserLeagueMember(leagueId, userId);
if (!isUserCommissioner && !isUserMember) {
throw new Response("You do not have access to this league", {
status: 403,
});
}
// Fetch sports season with relations
const db = database();
const sportsSeason = await db.query.sportsSeasons.findFirst({
where: eq(schema.sportsSeasons.id, sportsSeasonId),
with: {
sport: true,
seasonSports: {
with: {
season: true,
},
},
},
});
if (!sportsSeason) {
throw new Response("Sports season not found", { status: 404 });
}
// Get the fantasy season for this league (to get teams and draft picks)
// We need to find which season in this league includes this sports season
// Filter by leagueId since a sports season can be linked to multiple leagues
const seasonSport = sportsSeason.seasonSports?.find(
(ss) => ss.season.leagueId === leagueId
);
if (!seasonSport) {
throw new Response("This sports season is not linked to this league", {
status: 404,
});
}
const season = seasonSport.season;
// Fetch teams and draft picks to determine ownership
const teams = await findTeamsBySeasonId(season.id);
const draftPicks = await getDraftPicks(season.id);
// Build ownership map: participantId -> { teamName, teamId, ownerName }
const ownershipMap = new Map<
string,
{ teamName: string; teamId: string; ownerName?: string }
>();
// Get unique owner IDs and batch-fetch their user records in a single query
const ownerIds = [...new Set(teams.map((t) => t.ownerId).filter(Boolean))] as string[];
const ownerUserRows = ownerIds.length > 0
? await db.query.users.findMany({
where: inArray(schema.users.clerkId, ownerIds),
columns: { clerkId: true, username: true, displayName: true },
})
: [];
const ownerMap = new Map(
ownerUserRows.map((u) => [u.clerkId, getUserDisplayName(u) ?? "Unknown"])
);
// Map draft picks to ownership
for (const pick of draftPicks) {
const team = teams.find((t) => t.id === pick.teamId);
if (team && pick.participantId) {
ownershipMap.set(pick.participantId, {
teamName: team.name,
teamId: team.id,
ownerName: team.ownerId ? ownerMap.get(team.ownerId) : undefined,
});
}
}
// Convert to array for serialization
const teamOwnerships = Array.from(ownershipMap.entries()).map(
([participantId, data]) => ({
participantId,
...data,
})
);
// Derive which participant IDs the current user has drafted
const userTeams = teams.filter((t) => t.ownerId === userId);
const userTeamIds = new Set(userTeams.map((t) => t.id));
const userParticipantIds = draftPicks
.filter((p) => p.teamId && userTeamIds.has(p.teamId) && p.participantId)
.map((p) => p.participantId as string);
// Fetch pattern-specific data based on scoring pattern
const scoringPattern = sportsSeason.scoringPattern;
type SeasonStanding = { id: string; position: number; championshipPoints: string; participant: { id: string; name: string } };
type PlayoffMatchWithRelations = PlayoffMatch & {
participant1: { id: string; name: string } | null;
participant2: { id: string; name: string } | null;
winner: { id: string; name: string } | null;
loser: { id: string; name: string } | null;
};
let playoffMatches: PlayoffMatchWithRelations[] = [];
let playoffRounds: string[] = [];
let preEliminatedParticipants: { id: string; name: string }[] = [];
let participantPoints: { participantId: string; points: number }[] = [];
let partialScoreParticipantIds: string[] = [];
let seasonStandings: SeasonStanding[] = [];
type QPStanding = Awaited<ReturnType<typeof getQPStandings>>[number];
let qpStandings: QPStanding[] = [];
// Group standings for group-stage events (e.g. FIFA World Cup)
type RawGroupMatch = Awaited<ReturnType<typeof findMatchesByGroupIds>> extends Map<string, Array<infer T>> ? T : never;
type GroupStandingData = {
groupName: string;
standings: ReturnType<typeof computeGroupStandings>;
matches: Array<Omit<RawGroupMatch, "scheduledAt"> & { scheduledAt: string | null }>;
};
let groupStandings: GroupStandingData[] = [];
let bracketTemplateId: string | null = null;
if (scoringPattern === "playoff_bracket") {
// Fetch playoff matches via scoring events
let templateId: string | undefined;
const events = await db.query.scoringEvents.findMany({
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
});
if (events.length > 0) {
const eventIds = events.map((e) => e.id);
const matches = await db.query.playoffMatches.findMany({
where: (pm) => inArray(pm.scoringEventId, eventIds),
with: {
participant1: true,
participant2: true,
winner: true,
loser: true,
},
});
playoffMatches = matches as PlayoffMatchWithRelations[];
templateId = events.find((e) => e.bracketTemplateId)?.bracketTemplateId ?? undefined;
bracketTemplateId = templateId ?? null;
const template = templateId ? getBracketTemplate(templateId) : undefined;
playoffRounds = getOrderedRoundsFromMatches(matches, template);
// Load group stage standings if this event uses a group-stage template
if (template?.groupStage) {
const groupStageEventId = events.find((e) => e.bracketTemplateId === templateId)?.id;
if (groupStageEventId) {
const groups = await findGroupsByEventId(groupStageEventId);
// Single batched query for all groups instead of N round-trips
const matchesByGroupId = await findMatchesByGroupIds(groups.map((g) => g.id));
groupStandings = groups.map((group) => {
const groupMatches = matchesByGroupId.get(group.id) ?? [];
const members = (group.members ?? [])
.filter((m) => m.participant !== null && m.participant !== undefined)
.map((m) => ({
participantId: m.participant?.id ?? "",
participantName: m.participant?.name ?? "",
}));
return {
groupName: group.groupName,
standings: computeGroupStandings(
members,
groupMatches.map((m) => ({
participant1Id: m.participant1Id,
participant2Id: m.participant2Id,
participant1Score: m.participant1Score,
participant2Score: m.participant2Score,
isComplete: m.isComplete,
}))
),
matches: groupMatches.map((m) => ({
...m,
scheduledAt: m.scheduledAt ? m.scheduledAt.toISOString() : null,
})),
};
});
}
}
}
// Fetch group-stage losers and all participant results for bracket scoring
const [eliminatedResults, allResults] = await Promise.all([
db.query.participantResults.findMany({
where: and(
eq(schema.participantResults.sportsSeasonId, sportsSeasonId),
eq(schema.participantResults.finalPosition, 0)
),
with: { participant: true },
}),
db.query.participantResults.findMany({
where: eq(schema.participantResults.sportsSeasonId, sportsSeasonId),
}),
]);
preEliminatedParticipants = eliminatedResults
.filter((r): r is typeof r & { participant: NonNullable<typeof r.participant> } => r.participant !== null && r.participant !== undefined)
.map((r) => ({ id: r.participant.id, name: r.participant.name }));
// Compute per-participant fantasy points directly from participant_results.
// This covers both finalized placements and progressive floor scores for still-alive participants.
const scoringRules = {
pointsFor1st: season.pointsFor1st,
pointsFor2nd: season.pointsFor2nd,
pointsFor3rd: season.pointsFor3rd,
pointsFor4th: season.pointsFor4th,
pointsFor5th: season.pointsFor5th,
pointsFor6th: season.pointsFor6th,
pointsFor7th: season.pointsFor7th,
pointsFor8th: season.pointsFor8th,
};
const seenParticipantIds = new Set<string>();
participantPoints = allResults
.filter((r) => r.finalPosition !== null && r.finalPosition > 0)
.filter((r) => {
if (seenParticipantIds.has(r.participantId)) return false;
seenParticipantIds.add(r.participantId);
return true;
})
.map((r) => ({
participantId: r.participantId,
points: calculateBracketPoints(r.finalPosition ?? 0, scoringRules, templateId),
}));
// Track which participants have provisional (floor) scores — still competing
partialScoreParticipantIds = allResults
.filter((r) => r.isPartialScore)
.map((r) => r.participantId);
} else if (scoringPattern === "season_standings") {
// Fetch F1-style championship standings and map to SeasonStanding shape
const results = await getSeasonResults(sportsSeasonId);
seasonStandings = results
.filter((r) => r.currentPosition !== null || (r.currentPoints && parseFloat(r.currentPoints) > 0))
.map((r) => ({
id: r.id,
position: r.currentPosition ?? 999,
championshipPoints: r.currentPoints ?? "0",
participant: {
id: r.participant.id,
name: r.participant.name,
},
}));
} else if (scoringPattern === "qualifying_points") {
// Fetch qualifying points standings
const standings = await getQPStandings(sportsSeasonId);
qpStandings = standings;
}
// Fetch event schedule for all patterns (upcoming + recent)
const [upcomingEvents, recentEvents] = await Promise.all([
getUpcomingScoringEvents(sportsSeasonId, 5),
getRecentCompletedEvents(sportsSeasonId, 3),
]);
// Fetch regular season standings for team-sport playoff_bracket seasons (NBA, NHL)
const isTeamBracket =
scoringPattern === "playoff_bracket" && sportsSeason.sport?.type === "team";
const [regularSeasonStandings, regularSeasonEvs] = isTeamBracket
? await Promise.all([
getRegularSeasonStandings(sportsSeasonId),
getAllParticipantEVsForSeason(sportsSeasonId),
])
: [[], []];
// Derive seasonIsFinalized from status
const seasonIsFinalized = sportsSeason.status === "completed";
// Build participantId → expectedValue map for display
const participantEvs = Object.fromEntries(
regularSeasonEvs.map((ev) => [ev.participantId, ev.expectedValue])
);
return {
league,
season,
sportsSeason,
scoringPattern,
playoffMatches,
playoffRounds,
preEliminatedParticipants,
participantPoints,
partialScoreParticipantIds,
seasonStandings,
qpStandings,
teamOwnerships,
userParticipantIds,
upcomingEvents,
recentEvents,
seasonIsFinalized,
regularSeasonStandings,
participantEvs,
groupStandings,
bracketTemplateId,
};
}