Show bracket on tennis Grand Slam major event pages (#113)
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

## 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
This commit is contained in:
chrisp 2026-06-28 04:35:40 +00:00
parent dfbb25771d
commit b8c21d227b
3 changed files with 179 additions and 51 deletions

View file

@ -284,7 +284,14 @@ export function PlayoffBracket({
const activeParticipants = [...allBracketParticipantIds] const activeParticipants = [...allBracketParticipantIds]
.filter((id) => !rankedParticipantIds.has(id)) .filter((id) => !rankedParticipantIds.has(id))
.map((id) => participantMap.get(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) => const isDraftedOrScoring = (participantId: string) =>
ownershipMap.has(participantId) || (pointsMap.get(participantId) ?? 0) > 0; ownershipMap.has(participantId) || (pointsMap.get(participantId) ?? 0) > 0;

View file

@ -14,6 +14,7 @@ import { getCs2StageResultsForEvent } from "~/models/cs2-major-stage";
import { findSeasonMatchesByScoringEventId } from "~/models/season-match"; import { findSeasonMatchesByScoringEventId } from "~/models/season-match";
import { getEventResults } from "~/models/event-result"; import { getEventResults } from "~/models/event-result";
import { getBracketTemplate, getOrderedRoundsFromMatches } from "~/lib/bracket-templates"; import { getBracketTemplate, getOrderedRoundsFromMatches } from "~/lib/bracket-templates";
import { isBracketMajor } from "~/lib/event-utils";
import { findGroupsByEventId } from "~/models/tournament-group"; import { findGroupsByEventId } from "~/models/tournament-group";
import { findMatchesByGroupIds, computeGroupStandings } from "~/models/group-stage-match"; import { findMatchesByGroupIds, computeGroupStandings } from "~/models/group-stage-match";
import type { PlayoffMatch } from "~/models/playoff-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<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) // Bracket events (tennis/golf bracket majors, soccer/UCL group+bracket)
if (scoringEvent.eventType === "playoff_game") { if (scoringEvent.eventType === "playoff_game") {
const playoffMatchRows = await db.query.playoffMatches.findMany({ const playoffMatchRows = await db.query.playoffMatches.findMany({

View file

@ -30,6 +30,79 @@ export function meta({ data }: Route.MetaArgs) {
export { loader }; 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<string, { teamName: string; ownerName: string; teamId: string }>;
userParticipantIds: string[];
}) {
if (eventResults.length === 0) {
return (
<Card>
<CardContent className="py-8 text-center">
<p className="text-muted-foreground text-sm">Results not yet available for this event.</p>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Results</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">#</TableHead>
<TableHead>Participant</TableHead>
<TableHead>Manager</TableHead>
<TableHead className="text-right">QP</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{eventResults.map((r) => {
const ownership = ownershipMap[r.seasonParticipantId];
const isUser = userParticipantIds.includes(r.seasonParticipantId);
return (
<TableRow key={r.id} className={isUser ? "bg-electric/5" : undefined}>
<TableCell className="text-muted-foreground">{r.placement}</TableCell>
<TableCell className="font-medium">
{r.participantName ?? "—"}
{isUser && <span className="ml-1.5 text-xs text-electric"></span>}
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{ownership?.teamName ?? "—"}
</TableCell>
<TableCell className="text-right">
{r.qualifyingPointsAwarded !== null
? parseFloat(r.qualifyingPointsAwarded).toFixed(2)
: r.rawScore !== null
? r.rawScore
: "—"}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
);
}
export default function EventDetailPage({ loaderData }: Route.ComponentProps) { export default function EventDetailPage({ loaderData }: Route.ComponentProps) {
const { league, sportsSeason, scoringEvent } = loaderData; const { league, sportsSeason, scoringEvent } = loaderData;
const leagueId = league.id; const leagueId = league.id;
@ -114,58 +187,42 @@ export default function EventDetailPage({ loaderData }: Route.ComponentProps) {
</div> </div>
)} )}
{/* Tennis Grand Slam major — draw bracket + qualifying-points results */}
{loaderData.kind === "tennis" && (
<div className="space-y-6">
{loaderData.playoffMatches.length > 0 ? (
<PlayoffBracket
matches={loaderData.playoffMatches}
rounds={loaderData.playoffRounds}
bracketTemplateId={loaderData.bracketTemplateId}
preEliminatedParticipants={loaderData.preEliminatedParticipants}
teamOwnerships={loaderData.bracketTeamOwnerships}
userParticipantIds={loaderData.bracketUserParticipantIds}
showOwnership={true}
mode="bracket"
/>
) : (
<Card>
<CardContent className="py-8 text-center">
<p className="text-muted-foreground text-sm">Bracket not yet available.</p>
</CardContent>
</Card>
)}
<QpResultsTable
eventResults={loaderData.eventResults}
ownershipMap={ownershipMap}
userParticipantIds={loaderData.userParticipantIds}
/>
</div>
)}
{/* Results table (QP tournaments, racing events) */} {/* Results table (QP tournaments, racing events) */}
{loaderData.kind === "results" && ( {loaderData.kind === "results" && (
loaderData.eventResults.length > 0 ? ( <QpResultsTable
<Card> eventResults={loaderData.eventResults}
<CardHeader> ownershipMap={ownershipMap}
<CardTitle className="text-base">Results</CardTitle> userParticipantIds={loaderData.userParticipantIds}
</CardHeader> />
<CardContent className="pt-0">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">#</TableHead>
<TableHead>Participant</TableHead>
<TableHead>Manager</TableHead>
<TableHead className="text-right">QP</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loaderData.eventResults.map((r) => {
const ownership = ownershipMap[r.seasonParticipantId];
const isUser = loaderData.userParticipantIds.includes(r.seasonParticipantId);
return (
<TableRow key={r.id} className={isUser ? "bg-electric/5" : undefined}>
<TableCell className="text-muted-foreground">{r.placement}</TableCell>
<TableCell className="font-medium">
{r.participantName ?? "—"}
{isUser && <span className="ml-1.5 text-xs text-electric"></span>}
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{ownership?.teamName ?? "—"}
</TableCell>
<TableCell className="text-right">
{r.qualifyingPointsAwarded !== null
? parseFloat(r.qualifyingPointsAwarded).toFixed(2)
: r.rawScore !== null
? r.rawScore
: "—"}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
) : (
<Card>
<CardContent className="py-8 text-center">
<p className="text-muted-foreground text-sm">Results not yet available for this event.</p>
</CardContent>
</Card>
)
)} )}
</div> </div>
); );