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
This commit is contained in:
parent
dfbb25771d
commit
b8c21d227b
3 changed files with 179 additions and 51 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<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({
|
||||
|
|
|
|||
|
|
@ -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<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) {
|
||||
const { league, sportsSeason, scoringEvent } = loaderData;
|
||||
const leagueId = league.id;
|
||||
|
|
@ -114,58 +187,42 @@ export default function EventDetailPage({ loaderData }: Route.ComponentProps) {
|
|||
</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) */}
|
||||
{loaderData.kind === "results" && (
|
||||
loaderData.eventResults.length > 0 ? (
|
||||
<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>
|
||||
{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>
|
||||
)
|
||||
<QpResultsTable
|
||||
eventResults={loaderData.eventResults}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={loaderData.userParticipantIds}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue