diff --git a/app/models/__tests__/upcoming-calendar.test.ts b/app/models/__tests__/upcoming-calendar.test.ts index b4cc94d..a876c13 100644 --- a/app/models/__tests__/upcoming-calendar.test.ts +++ b/app/models/__tests__/upcoming-calendar.test.ts @@ -459,7 +459,7 @@ describe("getUpcomingEventsForDraftedParticipants — bracket sports", () => { expect(result[0].matchLabel).toBeNull(); }); - it("includes the P1 vs P2 matchup in matchLabel alongside the round name", async () => { + 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, @@ -479,7 +479,7 @@ describe("getUpcomingEventsForDraftedParticipants — bracket sports", () => { DATE_FROM, DATE_TO ); - expect(result[0].matchLabel).toBe("Quarterfinals — Team Spirit vs G2 Esports"); + 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" }]); }); @@ -507,7 +507,7 @@ describe("getUpcomingEventsForDraftedParticipants — bracket sports", () => { expect(result[0].matchLabel).toBe("Barcelona vs Sporting"); }); - it("includes the matchup in matchLabel for series games", async () => { + 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, @@ -533,7 +533,53 @@ describe("getUpcomingEventsForDraftedParticipants — bracket sports", () => { DATE_FROM, DATE_TO ); - expect(result[0].matchLabel).toBe("Knockout Stage Game #1 — Barcelona vs Sporting"); + 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 () => { diff --git a/app/models/scoring-event.ts b/app/models/scoring-event.ts index 836a595..136a5b9 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; @@ -608,51 +612,49 @@ export async function getUpcomingEventsForDraftedParticipants( .orderBy(asc(schema.scoringEvents.eventDate)); // Look up names for ALL participants in these matches (not just drafted - // ones) so matchLabel can show the full "P1 vs P2" matchup, mirroring the - // group-stage matchLabel below. + // 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 matchParticipantNameById = new Map(); - if (allMatchParticipantIds.length > 0) { - const matchParticipantRows = await db.query.seasonParticipants.findMany({ - where: inArray(schema.seasonParticipants.id, allMatchParticipantIds), - }); - for (const p of matchParticipantRows) { - matchParticipantNameById.set(p.id, p.name); - } - } - - // Determine max game number per event across ALL games (not date-filtered) - // so we can distinguish single-game matchups from multi-game series. 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 maxGameNumberByEvent = new Map(); + if (eventIdsFromRows.length === 0) return maxGameNumberByEvent; + + 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)); + } } - } - } + return maxGameNumberByEvent; + })(), + ]); // Group rows into calendar entries. // Series events (max game number > 1) get one entry per game so each game @@ -722,10 +724,15 @@ 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; + const matchupLabel = p1Name || p2Name ? `${p1Name ?? "?"} vs ${p2Name ?? "?"}` : null; let roundLabel: string | null = null; if (isSeries) { @@ -748,9 +755,7 @@ export async function getUpcomingEventsForDraftedParticipants( } } - event.matchLabel = roundLabel && matchupLabel - ? `${roundLabel} — ${matchupLabel}` - : roundLabel ?? matchupLabel; + 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,