diff --git a/app/models/__tests__/upcoming-calendar.test.ts b/app/models/__tests__/upcoming-calendar.test.ts index ffa5587..a876c13 100644 --- a/app/models/__tests__/upcoming-calendar.test.ts +++ b/app/models/__tests__/upcoming-calendar.test.ts @@ -459,6 +459,129 @@ describe("getUpcomingEventsForDraftedParticipants — bracket sports", () => { 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 () => { const rows = [{ id: "e1", name: "PDC World Championship", eventDate: "2025-04-10", diff --git a/app/models/scoring-event.ts b/app/models/scoring-event.ts index 4752663..354fc76 100644 --- a/app/models/scoring-event.ts +++ b/app/models/scoring-event.ts @@ -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 { recalculateAffectedLeagues } from "./scoring-calculator"; import { hasProcessedQualifyingPlacement, recalculateParticipantQP } from "./qualifying-points"; +import { findParticipantNamesByIds } from "./season-participant"; export type EventType = "playoff_game" | "major_tournament" | "final_standings" | "schedule_event"; @@ -408,8 +409,11 @@ export interface UpcomingParticipantEvent { */ earliestGameTime: string | null; /** - * e.g. "Semifinals Game #1" — derived from playoffMatches.round and - * playoffMatchGames.gameNumber. null when unavailable or redundant with event name. + * e.g. "Team Spirit vs G2 Esports" — the bracket matchup, derived from + * 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; eventType: string; @@ -607,34 +611,50 @@ export async function getUpcomingEventsForDraftedParticipants( ) .orderBy(asc(schema.scoringEvents.eventDate)); - // Determine max game number per event across ALL games (not date-filtered) - // so we can distinguish single-game matchups from multi-game series. + // Look up names for ALL participants in these matches (not just drafted + // 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 maxGameNumberByEvent = new Map(); - 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) { - if (row.gameNumber !== null) { - const current = maxGameNumberByEvent.get(row.eventId) ?? 0; - maxGameNumberByEvent.set(row.eventId, Math.max(current, row.gameNumber)); + const [matchParticipantNameById, maxGameNumberByEvent] = await Promise.all([ + findParticipantNamesByIds(db, allMatchParticipantIds), + (async () => { + // 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(); + 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. // 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>(); const earliestTimeByEntry = new Map(); const isSeriesByEntry = new Map(); + const matchupIdsByEntry = new Map(); for (const row of rows) { const isSeries = (maxGameNumberByEvent.get(row.id) ?? 1) > 1; @@ -667,6 +688,7 @@ export async function getUpcomingEventsForDraftedParticipants( participantsByEntry.set(entryKey, new Map()); gameKeysByEntry.set(entryKey, new Set()); isSeriesByEntry.set(entryKey, isSeries); + matchupIdsByEntry.set(entryKey, { p1Id: row.participant1Id, p2Id: row.participant2Id }); } const pMap = participantsByEntry.get(entryKey); @@ -702,25 +724,38 @@ export async function getUpcomingEventsForDraftedParticipants( const isSeries = isSeriesByEntry.get(entryKey) ?? false; const gameKeys = gameKeysByEntry.get(entryKey) ?? new Set(); + // 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 (gameKeys.size >= 1) { const [round, gameNumberStr] = [...gameKeys][0].split("|"); - event.matchLabel = + roundLabel = round === event.name ? `Game #${gameNumberStr}` : `${round} Game #${gameNumberStr}`; } } else { // Single-game: omit the game number, just show the round name. if (gameKeys.size === 1) { const [round] = [...gameKeys][0].split("|"); - event.matchLabel = round === event.name ? null : round; + roundLabel = round === event.name ? null : round; } else if (gameKeys.size > 1) { const rounds = new Set([...gameKeys].map((k) => k.split("|")[0])); if (rounds.size === 1) { 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()); diff --git a/app/models/season-participant.ts b/app/models/season-participant.ts index e5bcffe..fc8dfa9 100644 --- a/app/models/season-participant.ts +++ b/app/models/season-participant.ts @@ -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, + ids: string[] +): Promise> { + const nameById = new Map(); + 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( externalId: string ): Promise { diff --git a/app/models/team-score-events.ts b/app/models/team-score-events.ts index ab28493..077772c 100644 --- a/app/models/team-score-events.ts +++ b/app/models/team-score-events.ts @@ -2,6 +2,7 @@ import { database } from "~/database/context"; import * as schema from "~/database/schema"; import { eq, inArray, desc, sql, and } from "drizzle-orm"; import { calculateBracketPoints, type ScoringRules } from "~/models/scoring-rules"; +import { findParticipantNamesByIds } from "~/models/season-participant"; import { logger } from "~/lib/logger"; /** @@ -227,17 +228,7 @@ export async function getRecentTeamScoreEvents( const allParticipantIds = [ ...new Set(rows.flatMap((r) => r.participantIds ?? [])), ]; - - const participantNameById = new Map(); - 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); - } - } + const participantNameById = await findParticipantNamesByIds(db, allParticipantIds); return rows.map((row) => ({ id: row.id,