Compare commits

...
Sign in to create a new pull request.

3 commits

Author SHA1 Message Date
Claude
ff9bc6717c
Fix no-shadow lint error in scoring-event.ts
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 2m57s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m16s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
Rename the inner maxGameNumberByEvent local inside the IIFE to `map`
so it no longer shadows the outer destructuring target of the same name.

https://claude.ai/code/session_01NpQNGWacjg7mbnq8aaUweJ
2026-06-16 21:36:54 +00:00
Claude
e5728fd7ed
Address code review feedback on bracket matchup label
Some checks failed
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m7s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Failing after 47s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
Simplify matchLabel to just the bare "P1 vs P2" matchup (round/game
number is redundant with the event name and sport already shown),
extract the participant-name batch lookup into a shared
findParticipantNamesByIds helper reused by team-score-events, run it
concurrently with the max-game-number lookup, and cover the
no-games-scheduled-yet and partial-name-resolution cases with tests.

https://claude.ai/code/session_01NpQNGWacjg7mbnq8aaUweJ
2026-06-16 19:59:33 +00:00
Claude
f91f7dd7ad
Show full matchup in My Upcoming Events for bracket matches
Bracket match matchLabel now includes "P1 vs P2" alongside the round
name (e.g. "Quarterfinals — Team Spirit vs G2 Esports") so users can
see who their drafted team is playing, not just the team they own.
relevantParticipants stays drafted-only since it also drives the
"X of your picks" badge UI.

https://claude.ai/code/session_01NpQNGWacjg7mbnq8aaUweJ
2026-06-16 19:43:19 +00:00
4 changed files with 212 additions and 41 deletions

View file

@ -459,6 +459,129 @@ describe("getUpcomingEventsForDraftedParticipants — bracket sports", () => {
expect(result[0].matchLabel).toBeNull(); expect(result[0].matchLabel).toBeNull();
}); });
it("shows just the P1 vs P2 matchup in matchLabel (round name dropped)", async () => {
const rows = [{
id: "e1", name: "IEM Cologne 2026", eventDate: "2026-06-19",
eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
playoffMatchId: "pm1",
participant1Id: "team-spirit", participant2Id: "g2-esports",
round: "Quarterfinals", gameNumber: 1, scheduledAt: null,
}];
mockDb.selectDistinct.mockReturnValueOnce(makeSelectChain(rows));
mockDb.query.seasonParticipants.findMany.mockResolvedValueOnce([
{ id: "team-spirit", name: "Team Spirit" },
{ id: "g2-esports", name: "G2 Esports" },
]);
const result = await getUpcomingEventsForDraftedParticipants(
SPORTS_SEASON_ID, "playoff_bracket",
[makeParticipant("g2-esports", "G2 Esports")],
DATE_FROM, DATE_TO
);
expect(result[0].matchLabel).toBe("Team Spirit vs G2 Esports");
// relevantParticipants stays drafted-only — the opponent is only surfaced via matchLabel.
expect(result[0].relevantParticipants).toEqual([{ id: "g2-esports", name: "G2 Esports" }]);
});
it("shows just the matchup (no round prefix) when round name is redundant with event name", async () => {
const rows = [{
id: "e1", name: "Knockout Stage", eventDate: null,
eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
playoffMatchId: "pm1",
participant1Id: "barcelona", participant2Id: "sporting",
round: "Knockout Stage", gameNumber: 1, scheduledAt: null,
}];
mockDb.selectDistinct.mockReturnValueOnce(makeSelectChain(rows));
mockDb.query.seasonParticipants.findMany.mockResolvedValueOnce([
{ id: "barcelona", name: "Barcelona" },
{ id: "sporting", name: "Sporting" },
]);
const result = await getUpcomingEventsForDraftedParticipants(
SPORTS_SEASON_ID, "playoff_bracket",
[makeParticipant("barcelona", "Barcelona")],
DATE_FROM, DATE_TO
);
expect(result[0].matchLabel).toBe("Barcelona vs Sporting");
});
it("shows just the matchup in matchLabel for series games (round/game number dropped)", async () => {
const gameTime1 = new Date("2025-04-09T13:00:00.000Z");
const rows = [{
id: "e1", name: "UCL QF", eventDate: null,
eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
playoffMatchId: "pm1",
participant1Id: "barcelona", participant2Id: "sporting",
round: "Knockout Stage", gameNumber: 1, scheduledAt: gameTime1,
}];
mockDb.selectDistinct
.mockReturnValueOnce(makeSelectChain(rows))
.mockReturnValueOnce(makeMaxGameChain([
{ eventId: "e1", gameNumber: 1 },
{ eventId: "e1", gameNumber: 2 },
]));
mockDb.query.seasonParticipants.findMany.mockResolvedValueOnce([
{ id: "barcelona", name: "Barcelona" },
{ id: "sporting", name: "Sporting" },
]);
const result = await getUpcomingEventsForDraftedParticipants(
SPORTS_SEASON_ID, "playoff_bracket",
[makeParticipant("barcelona", "Barcelona")],
DATE_FROM, DATE_TO
);
expect(result[0].matchLabel).toBe("Barcelona vs Sporting");
});
it("shows the matchup even when no games are scheduled yet (gameKeys empty)", async () => {
const rows = [{
id: "e1", name: "IEM Cologne 2026", eventDate: "2026-06-19",
eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
playoffMatchId: "pm1",
participant1Id: "team-spirit", participant2Id: "g2-esports",
round: "Quarterfinals", gameNumber: null, scheduledAt: null,
}];
mockDb.selectDistinct.mockReturnValueOnce(makeSelectChain(rows));
mockDb.query.seasonParticipants.findMany.mockResolvedValueOnce([
{ id: "team-spirit", name: "Team Spirit" },
{ id: "g2-esports", name: "G2 Esports" },
]);
const result = await getUpcomingEventsForDraftedParticipants(
SPORTS_SEASON_ID, "playoff_bracket",
[makeParticipant("g2-esports", "G2 Esports")],
DATE_FROM, DATE_TO
);
expect(result[0].matchLabel).toBe("Team Spirit vs G2 Esports");
});
it("falls back to '?' for the side whose participant name didn't resolve", async () => {
const rows = [{
id: "e1", name: "IEM Cologne 2026", eventDate: "2026-06-19",
eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
playoffMatchId: "pm1",
participant1Id: "team-spirit", participant2Id: "g2-esports",
round: "Quarterfinals", gameNumber: 1, scheduledAt: null,
}];
mockDb.selectDistinct.mockReturnValueOnce(makeSelectChain(rows));
// Only one of the two participant ids resolves (e.g. orphaned/deleted row).
mockDb.query.seasonParticipants.findMany.mockResolvedValueOnce([
{ id: "g2-esports", name: "G2 Esports" },
]);
const result = await getUpcomingEventsForDraftedParticipants(
SPORTS_SEASON_ID, "playoff_bracket",
[makeParticipant("g2-esports", "G2 Esports")],
DATE_FROM, DATE_TO
);
expect(result[0].matchLabel).toBe("? vs G2 Esports");
});
it("shows game number when only game #2 is in the window (game #1 already played)", async () => { it("shows game number when only game #2 is in the window (game #1 already played)", async () => {
const rows = [{ const rows = [{
id: "e1", name: "PDC World Championship", eventDate: "2025-04-10", id: "e1", name: "PDC World Championship", eventDate: "2025-04-10",

View file

@ -4,6 +4,7 @@ import { eq, and, desc, asc, gte, lte, or, inArray, isNotNull, sql } from "drizz
import type { BracketRegion } from "~/lib/bracket-templates"; import type { BracketRegion } from "~/lib/bracket-templates";
import { recalculateAffectedLeagues } from "./scoring-calculator"; import { recalculateAffectedLeagues } from "./scoring-calculator";
import { hasProcessedQualifyingPlacement, recalculateParticipantQP } from "./qualifying-points"; import { hasProcessedQualifyingPlacement, recalculateParticipantQP } from "./qualifying-points";
import { findParticipantNamesByIds } from "./season-participant";
export type EventType = "playoff_game" | "major_tournament" | "final_standings" | "schedule_event"; export type EventType = "playoff_game" | "major_tournament" | "final_standings" | "schedule_event";
@ -408,8 +409,11 @@ export interface UpcomingParticipantEvent {
*/ */
earliestGameTime: string | null; earliestGameTime: string | null;
/** /**
* e.g. "Semifinals Game #1" derived from playoffMatches.round and * e.g. "Team Spirit vs G2 Esports" the bracket matchup, derived from
* playoffMatchGames.gameNumber. null when unavailable or redundant with event name. * playoffMatches.participant1Id/participant2Id (or group-stage match
* participants). Falls back to a round/game-number label (e.g. "Semifinals
* Game #1") when neither participant's name resolves. null when unavailable
* or redundant with event name.
*/ */
matchLabel: string | null; matchLabel: string | null;
eventType: string; eventType: string;
@ -607,34 +611,50 @@ export async function getUpcomingEventsForDraftedParticipants(
) )
.orderBy(asc(schema.scoringEvents.eventDate)); .orderBy(asc(schema.scoringEvents.eventDate));
// Determine max game number per event across ALL games (not date-filtered) // Look up names for ALL participants in these matches (not just drafted
// so we can distinguish single-game matchups from multi-game series. // ones) so matchLabel can show the "P1 vs P2" matchup, mirroring the
// group-stage matchLabel below. Run alongside the max-game-number lookup
// below since neither depends on the other's result.
const allMatchParticipantIds = [
...new Set(
rows.flatMap((r) => [r.participant1Id, r.participant2Id]).filter((id): id is string => !!id)
),
];
const eventIdsFromRows = [...new Set(rows.map((r) => r.id))]; const eventIdsFromRows = [...new Set(rows.map((r) => r.id))];
const maxGameNumberByEvent = new Map<string, number>();
if (eventIdsFromRows.length > 0) {
const allGameRows = await db
.selectDistinct({
eventId: schema.scoringEvents.id,
gameNumber: schema.playoffMatchGames.gameNumber,
})
.from(schema.scoringEvents)
.innerJoin(
schema.playoffMatches,
eq(schema.playoffMatches.scoringEventId, schema.scoringEvents.id)
)
.innerJoin(
schema.playoffMatchGames,
eq(schema.playoffMatchGames.playoffMatchId, schema.playoffMatches.id)
)
.where(inArray(schema.scoringEvents.id, eventIdsFromRows));
for (const row of allGameRows) { const [matchParticipantNameById, maxGameNumberByEvent] = await Promise.all([
if (row.gameNumber !== null) { findParticipantNamesByIds(db, allMatchParticipantIds),
const current = maxGameNumberByEvent.get(row.eventId) ?? 0; (async () => {
maxGameNumberByEvent.set(row.eventId, Math.max(current, row.gameNumber)); // Determine max game number per event across ALL games (not date-filtered)
// so we can distinguish single-game matchups from multi-game series.
const map = new Map<string, number>();
if (eventIdsFromRows.length === 0) return map;
const allGameRows = await db
.selectDistinct({
eventId: schema.scoringEvents.id,
gameNumber: schema.playoffMatchGames.gameNumber,
})
.from(schema.scoringEvents)
.innerJoin(
schema.playoffMatches,
eq(schema.playoffMatches.scoringEventId, schema.scoringEvents.id)
)
.innerJoin(
schema.playoffMatchGames,
eq(schema.playoffMatchGames.playoffMatchId, schema.playoffMatches.id)
)
.where(inArray(schema.scoringEvents.id, eventIdsFromRows));
for (const row of allGameRows) {
if (row.gameNumber !== null) {
const current = map.get(row.eventId) ?? 0;
map.set(row.eventId, Math.max(current, row.gameNumber));
}
} }
} return map;
} })(),
]);
// Group rows into calendar entries. // Group rows into calendar entries.
// Series events (max game number > 1) get one entry per game so each game // Series events (max game number > 1) get one entry per game so each game
@ -644,6 +664,7 @@ export async function getUpcomingEventsForDraftedParticipants(
const gameKeysByEntry = new Map<string, Set<string>>(); const gameKeysByEntry = new Map<string, Set<string>>();
const earliestTimeByEntry = new Map<string, Date>(); const earliestTimeByEntry = new Map<string, Date>();
const isSeriesByEntry = new Map<string, boolean>(); const isSeriesByEntry = new Map<string, boolean>();
const matchupIdsByEntry = new Map<string, { p1Id: string | null; p2Id: string | null }>();
for (const row of rows) { for (const row of rows) {
const isSeries = (maxGameNumberByEvent.get(row.id) ?? 1) > 1; const isSeries = (maxGameNumberByEvent.get(row.id) ?? 1) > 1;
@ -667,6 +688,7 @@ export async function getUpcomingEventsForDraftedParticipants(
participantsByEntry.set(entryKey, new Map()); participantsByEntry.set(entryKey, new Map());
gameKeysByEntry.set(entryKey, new Set()); gameKeysByEntry.set(entryKey, new Set());
isSeriesByEntry.set(entryKey, isSeries); isSeriesByEntry.set(entryKey, isSeries);
matchupIdsByEntry.set(entryKey, { p1Id: row.participant1Id, p2Id: row.participant2Id });
} }
const pMap = participantsByEntry.get(entryKey); const pMap = participantsByEntry.get(entryKey);
@ -702,25 +724,38 @@ export async function getUpcomingEventsForDraftedParticipants(
const isSeries = isSeriesByEntry.get(entryKey) ?? false; const isSeries = isSeriesByEntry.get(entryKey) ?? false;
const gameKeys = gameKeysByEntry.get(entryKey) ?? new Set<string>(); const gameKeys = gameKeysByEntry.get(entryKey) ?? new Set<string>();
// matchLabel shows the "P1 vs P2" matchup whenever either participant's
// name is known — the round/game-number info is intentionally NOT
// included (the event name + sport already give that context); it's
// only used as a last-resort fallback when no participant name resolved
// at all (e.g. a deleted/orphaned participant row).
const matchupIds = matchupIdsByEntry.get(entryKey);
const p1Name = matchupIds?.p1Id ? matchParticipantNameById.get(matchupIds.p1Id) : undefined;
const p2Name = matchupIds?.p2Id ? matchParticipantNameById.get(matchupIds.p2Id) : undefined;
const matchupLabel = p1Name || p2Name ? `${p1Name ?? "?"} vs ${p2Name ?? "?"}` : null;
let roundLabel: string | null = null;
if (isSeries) { if (isSeries) {
if (gameKeys.size >= 1) { if (gameKeys.size >= 1) {
const [round, gameNumberStr] = [...gameKeys][0].split("|"); const [round, gameNumberStr] = [...gameKeys][0].split("|");
event.matchLabel = roundLabel =
round === event.name ? `Game #${gameNumberStr}` : `${round} Game #${gameNumberStr}`; round === event.name ? `Game #${gameNumberStr}` : `${round} Game #${gameNumberStr}`;
} }
} else { } else {
// Single-game: omit the game number, just show the round name. // Single-game: omit the game number, just show the round name.
if (gameKeys.size === 1) { if (gameKeys.size === 1) {
const [round] = [...gameKeys][0].split("|"); const [round] = [...gameKeys][0].split("|");
event.matchLabel = round === event.name ? null : round; roundLabel = round === event.name ? null : round;
} else if (gameKeys.size > 1) { } else if (gameKeys.size > 1) {
const rounds = new Set([...gameKeys].map((k) => k.split("|")[0])); const rounds = new Set([...gameKeys].map((k) => k.split("|")[0]));
if (rounds.size === 1) { if (rounds.size === 1) {
const round = [...rounds][0]; const round = [...rounds][0];
event.matchLabel = round === event.name ? null : round; roundLabel = round === event.name ? null : round;
} }
} }
} }
event.matchLabel = matchupLabel ?? roundLabel;
} }
const bracketResults = Array.from(entryMap.values()); const bracketResults = Array.from(entryMap.values());

View file

@ -130,6 +130,28 @@ export async function findParticipantsBySportsSeasonId(sportsSeasonId: string):
}); });
} }
/**
* Batch-resolve participant names by id. Used wherever a set of season
* participant ids (e.g. from a bracket match or a stored array of ids) needs
* to be displayed by name without N+1 lookups.
*/
export async function findParticipantNamesByIds(
db: ReturnType<typeof database>,
ids: string[]
): Promise<Map<string, string>> {
const nameById = new Map<string, string>();
if (ids.length === 0) return nameById;
const rows = await db.query.seasonParticipants.findMany({
where: inArray(schema.seasonParticipants.id, ids),
columns: { id: true, name: true },
});
for (const row of rows) {
nameById.set(row.id, row.name);
}
return nameById;
}
export async function findParticipantsByExternalId( export async function findParticipantsByExternalId(
externalId: string externalId: string
): Promise<Participant[]> { ): Promise<Participant[]> {

View file

@ -2,6 +2,7 @@ import { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { eq, inArray, desc, sql, and } from "drizzle-orm"; import { eq, inArray, desc, sql, and } from "drizzle-orm";
import { calculateBracketPoints, type ScoringRules } from "~/models/scoring-rules"; import { calculateBracketPoints, type ScoringRules } from "~/models/scoring-rules";
import { findParticipantNamesByIds } from "~/models/season-participant";
import { logger } from "~/lib/logger"; import { logger } from "~/lib/logger";
/** /**
@ -227,17 +228,7 @@ export async function getRecentTeamScoreEvents(
const allParticipantIds = [ const allParticipantIds = [
...new Set(rows.flatMap((r) => r.participantIds ?? [])), ...new Set(rows.flatMap((r) => r.participantIds ?? [])),
]; ];
const participantNameById = await findParticipantNamesByIds(db, allParticipantIds);
const participantNameById = new Map<string, string>();
if (allParticipantIds.length > 0) {
const participantRows = await db.query.seasonParticipants.findMany({
where: inArray(schema.seasonParticipants.id, allParticipantIds),
columns: { id: true, name: true },
});
for (const p of participantRows) {
participantNameById.set(p.id, p.name);
}
}
return rows.map((row) => ({ return rows.map((row) => ({
id: row.id, id: row.id,