From b8c21d227b5e176f56102200720927960c8a011c Mon Sep 17 00:00:00 2001 From: chrisp Date: Sun, 28 Jun 2026 04:35:40 +0000 Subject: [PATCH] Show bracket on tennis Grand Slam major event pages (#113) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 Reviewed-on: https://forge.brackt.com/chrisp/brackt/pulls/113 --- app/components/scoring/PlayoffBracket.tsx | 9 +- ....$sportsSeasonId.events.$eventId.server.ts | 64 +++++++ ...easons.$sportsSeasonId.events.$eventId.tsx | 157 ++++++++++++------ 3 files changed, 179 insertions(+), 51 deletions(-) diff --git a/app/components/scoring/PlayoffBracket.tsx b/app/components/scoring/PlayoffBracket.tsx index ec57385..5510ca4 100644 --- a/app/components/scoring/PlayoffBracket.tsx +++ b/app/components/scoring/PlayoffBracket.tsx @@ -284,7 +284,14 @@ export function PlayoffBracket({ const activeParticipants = [...allBracketParticipantIds] .filter((id) => !rankedParticipantIds.has(id)) .map((id) => participantMap.get(id)) - .filter((p): p is Participant => p !== undefined); + .filter((p): p is Participant => p !== undefined) + // Owned-by-a-manager players first, then alphabetical by name. + .sort((a, b) => { + const aOwned = ownershipMap.has(a.id); + const bOwned = ownershipMap.has(b.id); + if (aOwned !== bOwned) return aOwned ? -1 : 1; + return a.name.localeCompare(b.name); + }); const isDraftedOrScoring = (participantId: string) => ownershipMap.has(participantId) || (pointsMap.get(participantId) ?? 0) > 0; diff --git a/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.events.$eventId.server.ts b/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.events.$eventId.server.ts index b40feb0..7450967 100644 --- a/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.events.$eventId.server.ts +++ b/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.events.$eventId.server.ts @@ -14,6 +14,7 @@ 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"; @@ -205,6 +206,69 @@ export async function loader({ params, request }: Route.LoaderArgs) { }; } + // 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 } => 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({ diff --git a/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.events.$eventId.tsx b/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.events.$eventId.tsx index ce01de8..121a842 100644 --- a/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.events.$eventId.tsx +++ b/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.events.$eventId.tsx @@ -30,6 +30,79 @@ export function meta({ data }: Route.MetaArgs) { export { loader }; +type QpResult = { + id: string; + placement: number; + qualifyingPointsAwarded: string | null; + rawScore: string | null; + seasonParticipantId: string; + participantName: string | null; +}; + +function QpResultsTable({ + eventResults, + ownershipMap, + userParticipantIds, +}: { + eventResults: QpResult[]; + ownershipMap: Record; + userParticipantIds: string[]; +}) { + if (eventResults.length === 0) { + return ( + + +

Results not yet available for this event.

+
+
+ ); + } + return ( + + + Results + + + + + + # + Participant + Manager + QP + + + + {eventResults.map((r) => { + const ownership = ownershipMap[r.seasonParticipantId]; + const isUser = userParticipantIds.includes(r.seasonParticipantId); + return ( + + {r.placement} + + {r.participantName ?? "—"} + {isUser && } + + + {ownership?.teamName ?? "—"} + + + {r.qualifyingPointsAwarded !== null + ? parseFloat(r.qualifyingPointsAwarded).toFixed(2) + : r.rawScore !== null + ? r.rawScore + : "—"} + + + ); + })} + +
+
+
+ ); +} + export default function EventDetailPage({ loaderData }: Route.ComponentProps) { const { league, sportsSeason, scoringEvent } = loaderData; const leagueId = league.id; @@ -114,58 +187,42 @@ export default function EventDetailPage({ loaderData }: Route.ComponentProps) { )} + {/* Tennis Grand Slam major — draw bracket + qualifying-points results */} + {loaderData.kind === "tennis" && ( +
+ {loaderData.playoffMatches.length > 0 ? ( + + ) : ( + + +

Bracket not yet available.

+
+
+ )} + +
+ )} + {/* Results table (QP tournaments, racing events) */} {loaderData.kind === "results" && ( - loaderData.eventResults.length > 0 ? ( - - - Results - - - - - - # - Participant - Manager - QP - - - - {loaderData.eventResults.map((r) => { - const ownership = ownershipMap[r.seasonParticipantId]; - const isUser = loaderData.userParticipantIds.includes(r.seasonParticipantId); - return ( - - {r.placement} - - {r.participantName ?? "—"} - {isUser && } - - - {ownership?.teamName ?? "—"} - - - {r.qualifyingPointsAwarded !== null - ? parseFloat(r.qualifyingPointsAwarded).toFixed(2) - : r.rawScore !== null - ? r.rawScore - : "—"} - - - ); - })} - -
-
-
- ) : ( - - -

Results not yet available for this event.

-
-
- ) + )} );