brackt/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.events.$eventId.server.ts
chrisp b8c21d227b
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 3m19s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m23s
🚀 Deploy / 🐳 Build (push) Successful in 1m11s
🚀 Deploy / 🚀 Deploy (push) Successful in 10s
Show bracket on tennis Grand Slam major event pages (#113)
## What

Tennis Grand Slam majors already store the full 128-player draw in `playoff_matches` (synced from Wikipedia), and the `PlayoffBracket` component + `tennis_128` template already exist — but users couldn't see any of it. The public league event page only rendered a bracket for `eventType === "playoff_game"`, while tennis majors are `major_tournament` events, so they fell through to the QP-only results table.

## Change

- New `kind: "tennis"` branch in the event loader, gated on `isBracketMajor(simulatorType) && eventType === "major_tournament"`. Loads the bracket (primary-keyed for shared majors, via the existing read-only-sibling ownership remap) plus this window's local QP results.
- The event page renders the full draw via the existing `PlayoffBracket`, followed by the QP results table.
- Extracted the duplicated results-table markup into a shared `QpResultsTable` used by both the `tennis` and `results` branches.
- "In Contention" table now sorts manager-drafted players first, then alphabetically by name.

CS2 majors (handled earlier) and golf (`isBracketMajor` false) are unaffected.

## Verification

- `npm run typecheck` — clean
- `npm run test:run` — 2533 passed
- `oxlint` — clean

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #113
2026-06-28 04:35:40 +00:00

365 lines
15 KiB
TypeScript

import { auth } from "~/lib/auth.server";
import type { Route } from "./+types/$leagueId.sports-seasons.$sportsSeasonId.events.$eventId";
import {
findLeagueById,
isUserLeagueMember,
isCommissioner,
findTeamsBySeasonId,
getUserDisplayName,
} from "~/models";
import { getDraftPicks } from "~/models/draft-pick";
import { getScoringEventById, getPrimaryEventForTournament, isReadOnlySibling } from "~/models/scoring-event";
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
import { getCs2StageResultsForEvent } from "~/models/cs2-major-stage";
import { findSeasonMatchesByScoringEventId } from "~/models/season-match";
import { getEventResults } from "~/models/event-result";
import { getBracketTemplate, getOrderedRoundsFromMatches } from "~/lib/bracket-templates";
import { isBracketMajor } from "~/lib/event-utils";
import { findGroupsByEventId } from "~/models/tournament-group";
import { findMatchesByGroupIds, computeGroupStandings } from "~/models/group-stage-match";
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({ params, request }: Route.LoaderArgs) {
const session = await auth.api.getSession({ headers: request.headers });
const userId = session?.user.id ?? null;
const { leagueId, sportsSeasonId, eventId } = params;
if (!userId) {
throw new Response("You must be logged in to view this page", { status: 401 });
}
const league = await findLeagueById(leagueId);
if (!league) throw new Response("League not found", { status: 404 });
const [isUserCommissioner, isUserMember] = await Promise.all([
isCommissioner(leagueId, userId),
isUserLeagueMember(leagueId, userId),
]);
if (!isUserCommissioner && !isUserMember) {
throw new Response("You do not have access to this league", { status: 403 });
}
const db = database();
const [scoringEvent, sportsSeason] = await Promise.all([
getScoringEventById(eventId),
db.query.sportsSeasons.findFirst({
where: eq(schema.sportsSeasons.id, sportsSeasonId),
with: { sport: true, seasonSports: { with: { season: true } } },
}),
]);
if (!scoringEvent) throw new Response("Event not found", { status: 404 });
if (!sportsSeason) throw new Response("Sports season not found", { status: 404 });
if (scoringEvent.sportsSeasonId !== sportsSeasonId) {
throw new Response("Event does not belong to this sports season", { status: 404 });
}
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;
// Build team ownership map
const teams = await findTeamsBySeasonId(season.id);
const draftPicks = await getDraftPicks(season.id);
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.id, ownerIds),
columns: { id: true, username: true, displayName: true },
})
: [];
const ownerMap = new Map(ownerUserRows.map((u) => [u.id, getUserDisplayName(u) ?? "Unknown"]));
const ownershipMap = new Map<string, { teamName: string; teamId: string; ownerName?: string }>();
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,
});
}
}
const teamOwnerships = Array.from(ownershipMap.entries()).map(([participantId, data]) => ({
participantId,
...data,
}));
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);
const simulatorType = sportsSeason.sport?.simulatorType ?? null;
// Shared-major fan-out for DISPLAY: a tournament-linked, non-primary event has
// no bracket/stage rows of its own — those live on the primary window. Read the
// structure from the primary, then re-key this league's ownership overlay from
// its own season_participant ids to the PRIMARY's season_participant ids (the
// ids the bracket structure uses) via the shared canonical participant. QP/
// results branches keep using this window's local rows and are untouched.
let structureEventId = eventId;
let structureBracketTemplateId = scoringEvent.bracketTemplateId;
let bracketTeamOwnerships = teamOwnerships;
let bracketUserParticipantIds = userParticipantIds;
if (isReadOnlySibling(scoringEvent) && scoringEvent.tournamentId) {
const primaryEvent = await getPrimaryEventForTournament(scoringEvent.tournamentId);
if (primaryEvent && primaryEvent.id !== eventId) {
structureEventId = primaryEvent.id;
structureBracketTemplateId = primaryEvent.bracketTemplateId;
const [siblingParticipants, primaryParticipants] = await Promise.all([
findParticipantsBySportsSeasonId(sportsSeasonId),
findParticipantsBySportsSeasonId(primaryEvent.sportsSeasonId),
]);
// sibling season_participant id → canonical participant id
const siblingSpToCanonical = new Map(
siblingParticipants
.filter((p) => p.participantId)
.map((p) => [p.id, p.participantId as string])
);
// canonical participant id → primary season_participant id
const canonicalToPrimarySp = new Map(
primaryParticipants
.filter((p) => p.participantId)
.map((p) => [p.participantId as string, p.id])
);
const toPrimarySp = (siblingSpId: string): string | undefined => {
const canonical = siblingSpToCanonical.get(siblingSpId);
return canonical ? canonicalToPrimarySp.get(canonical) : undefined;
};
bracketTeamOwnerships = teamOwnerships.flatMap((o) => {
const primarySpId = toPrimarySp(o.participantId);
return primarySpId ? [{ ...o, participantId: primarySpId }] : [];
});
bracketUserParticipantIds = userParticipantIds.flatMap((id) => {
const primarySpId = toPrimarySp(id);
return primarySpId ? [primarySpId] : [];
});
}
}
type PlayoffMatchWithRelations = Omit<PlayoffMatch, "createdAt" | "updatedAt"> & {
createdAt: string;
updatedAt: string;
participant1: { id: string; name: string } | null;
participant2: { id: string; name: string } | null;
winner: { id: string; name: string } | null;
loser: { id: string; name: string } | null;
};
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 }>;
};
// CS2 Major — load Swiss stage data + bracket
if (simulatorType === "cs2_major_qualifying_points") {
const [cs2StageResults, seasonMatches, playoffMatchRows] = await Promise.all([
getCs2StageResultsForEvent(structureEventId),
findSeasonMatchesByScoringEventId(structureEventId),
db.query.playoffMatches.findMany({
where: eq(schema.playoffMatches.scoringEventId, structureEventId),
with: { participant1: true, participant2: true, winner: true, loser: true },
}),
]);
const playoffMatches: PlayoffMatchWithRelations[] = playoffMatchRows.map((m) => ({
...m,
createdAt: m.createdAt.toISOString(),
updatedAt: m.updatedAt.toISOString(),
}));
const templateId = structureBracketTemplateId ?? undefined;
const template = templateId ? getBracketTemplate(templateId) : undefined;
const playoffRounds = getOrderedRoundsFromMatches(playoffMatches, template);
return {
kind: "cs2" as const,
league,
sportsSeason,
scoringEvent,
teamOwnerships: bracketTeamOwnerships,
userParticipantIds: bracketUserParticipantIds,
cs2StageResults,
seasonMatches: seasonMatches.map((m) => ({
...m,
scheduledAt: m.scheduledAt ? m.scheduledAt.toISOString() : null,
startedAt: m.startedAt ? m.startedAt.toISOString() : null,
completedAt: m.completedAt ? m.completedAt.toISOString() : null,
})),
playoffMatches,
playoffRounds,
bracketTemplateId: templateId ?? null,
};
}
// Tennis Grand Slam major — bracket draw (primary-keyed) + this window's QP results.
// major_tournament + tennis_qualifying_points. CS2 (also a bracket major) is handled
// above; golf (golf_qualifying_points) returns false from isBracketMajor and falls
// through to the plain QP results branch.
if (isBracketMajor(simulatorType) && scoringEvent.eventType === "major_tournament") {
const [playoffMatchRows, eventResultRows] = await Promise.all([
db.query.playoffMatches.findMany({
where: eq(schema.playoffMatches.scoringEventId, structureEventId),
with: { participant1: true, participant2: true, winner: true, loser: true },
}),
// QP/results rows live on this window (local eventId), not the primary structure.
getEventResults(eventId),
]);
const playoffMatches: PlayoffMatchWithRelations[] = playoffMatchRows.map((m) => ({
...m,
createdAt: m.createdAt.toISOString(),
updatedAt: m.updatedAt.toISOString(),
}));
const templateId = structureBracketTemplateId ?? undefined;
const template = templateId ? getBracketTemplate(templateId) : undefined;
const playoffRounds = getOrderedRoundsFromMatches(playoffMatches, template);
const allResults = await db.query.seasonParticipantResults.findMany({
where: and(
eq(schema.seasonParticipantResults.sportsSeasonId, sportsSeasonId),
eq(schema.seasonParticipantResults.finalPosition, 0)
),
with: { participant: true },
});
const preEliminatedParticipants = allResults
.filter((r): r is typeof r & { participant: NonNullable<typeof r.participant> } => r.participant !== null)
.map((r) => ({ id: r.participant.id, name: r.participant.name }));
const eventResults = eventResultRows
.filter((r): r is typeof r & { placement: number } => r.placement !== null && !r.notParticipating)
.map((r) => ({
id: r.id,
placement: r.placement,
qualifyingPointsAwarded: r.qualifyingPointsAwarded,
rawScore: r.rawScore,
seasonParticipantId: r.seasonParticipantId,
participantName: r.seasonParticipant?.name ?? null,
}));
return {
kind: "tennis" as const,
league,
sportsSeason,
scoringEvent,
// Bracket structure + ownership keyed to the primary window (shared majors).
playoffMatches,
playoffRounds,
bracketTemplateId: templateId ?? null,
preEliminatedParticipants,
bracketTeamOwnerships,
bracketUserParticipantIds,
// QP results table uses this window's local ownership + participant ids.
teamOwnerships,
userParticipantIds,
eventResults,
};
}
// Bracket events (tennis/golf bracket majors, soccer/UCL group+bracket)
if (scoringEvent.eventType === "playoff_game") {
const playoffMatchRows = await db.query.playoffMatches.findMany({
where: eq(schema.playoffMatches.scoringEventId, structureEventId),
with: { participant1: true, participant2: true, winner: true, loser: true },
});
const playoffMatches: PlayoffMatchWithRelations[] = playoffMatchRows.map((m) => ({
...m,
createdAt: m.createdAt.toISOString(),
updatedAt: m.updatedAt.toISOString(),
}));
const templateId = structureBracketTemplateId ?? undefined;
const template = templateId ? getBracketTemplate(templateId) : undefined;
const playoffRounds = getOrderedRoundsFromMatches(playoffMatches, template);
let groupStandings: GroupStandingData[] = [];
if (template?.groupStage) {
const groups = await findGroupsByEventId(structureEventId);
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 is typeof m & { participant: NonNullable<typeof m.participant> } => m.participant !== null)
.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,
})),
};
});
}
// Season participant results for scoring display
const allResults = await db.query.seasonParticipantResults.findMany({
where: and(
eq(schema.seasonParticipantResults.sportsSeasonId, sportsSeasonId),
eq(schema.seasonParticipantResults.finalPosition, 0)
),
with: { participant: true },
});
const preEliminatedParticipants = allResults
.filter((r): r is typeof r & { participant: NonNullable<typeof r.participant> } => r.participant !== null)
.map((r) => ({ id: r.participant.id, name: r.participant.name }));
return {
kind: "bracket" as const,
league,
sportsSeason,
scoringEvent,
teamOwnerships: bracketTeamOwnerships,
userParticipantIds: bracketUserParticipantIds,
playoffMatches,
playoffRounds,
bracketTemplateId: templateId ?? null,
groupStandings,
preEliminatedParticipants,
};
}
// QP major tournament / racing / other
const eventResultRows = await getEventResults(eventId);
const eventResults = eventResultRows
.filter((r): r is typeof r & { placement: number } => r.placement !== null && !r.notParticipating)
.map((r) => ({
id: r.id,
placement: r.placement,
qualifyingPointsAwarded: r.qualifyingPointsAwarded,
rawScore: r.rawScore,
seasonParticipantId: r.seasonParticipantId,
participantName: r.seasonParticipant?.name ?? null,
}));
return {
kind: "results" as const,
league,
sportsSeason,
scoringEvent,
teamOwnerships,
userParticipantIds,
eventResults,
};
}