diff --git a/app/models/__tests__/upcoming-calendar.test.ts b/app/models/__tests__/upcoming-calendar.test.ts index 5d0810a..7ed2ef7 100644 --- a/app/models/__tests__/upcoming-calendar.test.ts +++ b/app/models/__tests__/upcoming-calendar.test.ts @@ -335,24 +335,29 @@ describe("getUpcomingEventsForDraftedParticipants — bracket sports", () => { expect(result[0].eventDate).toBeNull(); }); - it("picks the earliest scheduledAt when multiple game rows exist for an event", async () => { + it("series with both games in window produces two entries, each with correct time and label", async () => { const gameTime1 = new Date("2025-04-09T13:00:00.000Z"); - const gameTime2 = new Date("2025-04-09T15:00:00.000Z"); + const gameTime2 = new Date("2025-04-11T15:00:00.000Z"); const rows = [ { id: "e1", name: "UCL QF", eventDate: null, eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID, participant1Id: "barcelona", participant2Id: "sporting", - round: "Knockout Stage", gameNumber: 1, scheduledAt: gameTime2, + round: "Knockout Stage", gameNumber: 1, scheduledAt: gameTime1, }, { id: "e1", name: "UCL QF", eventDate: null, eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID, participant1Id: "barcelona", participant2Id: "sporting", - round: "Knockout Stage", gameNumber: 2, scheduledAt: gameTime1, + round: "Knockout Stage", gameNumber: 2, scheduledAt: gameTime2, }, ]; - mockDb.selectDistinct.mockReturnValueOnce(makeSelectChain(rows)); + mockDb.selectDistinct + .mockReturnValueOnce(makeSelectChain(rows)) + .mockReturnValueOnce(makeMaxGameChain([ + { eventId: "e1", gameNumber: 1 }, + { eventId: "e1", gameNumber: 2 }, + ])); const result = await getUpcomingEventsForDraftedParticipants( SPORTS_SEASON_ID, "playoff_bracket", @@ -360,10 +365,13 @@ describe("getUpcomingEventsForDraftedParticipants — bracket sports", () => { DATE_FROM, DATE_TO ); - // Should use the earlier time - expect(result[0].earliestGameTime).toBe(gameTime1.toISOString()); - // Both games in the window → multi-game series → show lowest game number - expect(result[0].matchLabel).toBe("Knockout Stage Game #1"); + expect(result).toHaveLength(2); + const game1 = result.find((r) => r.id === "e1|1")!; + const game2 = result.find((r) => r.id === "e1|2")!; + expect(game1.earliestGameTime).toBe(gameTime1.toISOString()); + expect(game1.matchLabel).toBe("Knockout Stage Game #1"); + expect(game2.earliestGameTime).toBe(gameTime2.toISOString()); + expect(game2.matchLabel).toBe("Knockout Stage Game #2"); }); it("sets matchLabel to round name only (no game number) for single-game matchup", async () => { @@ -404,14 +412,19 @@ describe("getUpcomingEventsForDraftedParticipants — bracket sports", () => { expect(result[0].matchLabel).toBeNull(); }); - it("shows game number when game #2+ is upcoming (multi-game series)", async () => { + 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", eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID, participant1Id: "man-city", participant2Id: "arsenal", round: "Semifinals", gameNumber: 2, scheduledAt: null, }]; - mockDb.selectDistinct.mockReturnValueOnce(makeSelectChain(rows)); + mockDb.selectDistinct + .mockReturnValueOnce(makeSelectChain(rows)) + .mockReturnValueOnce(makeMaxGameChain([ + { eventId: "e1", gameNumber: 1 }, + { eventId: "e1", gameNumber: 2 }, + ])); const result = await getUpcomingEventsForDraftedParticipants( SPORTS_SEASON_ID, "playoff_bracket", diff --git a/app/models/scoring-event.ts b/app/models/scoring-event.ts index 73d7ef3..c59ad96 100644 --- a/app/models/scoring-event.ts +++ b/app/models/scoring-event.ts @@ -472,59 +472,11 @@ export async function getUpcomingEventsForDraftedParticipants( ) .orderBy(asc(schema.scoringEvents.eventDate)); - // Group rows by event, collecting relevant participants, earliest game time, and match label. - const eventMap = new Map(); - const participantsByEvent = new Map>(); - // Unique "round|gameNumber" combos per event — used to build matchLabel - const gameKeysByEvent = new Map>(); - // Min scheduledAt per event - const earliestTimeByEvent = new Map(); - - for (const row of rows) { - if (!eventMap.has(row.id)) { - eventMap.set(row.id, { - id: row.id, - name: row.name, - eventDate: row.eventDate, - earliestGameTime: null, - matchLabel: null, - eventType: row.eventType, - sportsSeasonId: row.sportsSeasonId, - relevantParticipants: [], - }); - participantsByEvent.set(row.id, new Map()); - gameKeysByEvent.set(row.id, new Set()); - } - - const pMap = participantsByEvent.get(row.id); - if (pMap) { - if (row.participant1Id) { - const p1 = draftedMap.get(row.participant1Id); - if (p1) pMap.set(row.participant1Id, p1); - } - if (row.participant2Id) { - const p2 = draftedMap.get(row.participant2Id); - if (p2) pMap.set(row.participant2Id, p2); - } - } - - if (row.round && row.gameNumber !== null) { - gameKeysByEvent.get(row.id)?.add(`${row.round}|${row.gameNumber}`); - } - - if (row.scheduledAt) { - const prev = earliestTimeByEvent.get(row.id); - if (!prev || row.scheduledAt < prev) { - earliestTimeByEvent.set(row.id, row.scheduledAt); - } - } - } - // Determine max game number per event across ALL games (not date-filtered) - // so we can distinguish "Game #1 of a series" from a single-game matchup. + // so we can distinguish single-game matchups from multi-game series. + const eventIdsFromRows = [...new Set(rows.map((r) => r.id))]; const maxGameNumberByEvent = new Map(); - const eventIds = Array.from(eventMap.keys()); - if (eventIds.length > 0) { + if (eventIdsFromRows.length > 0) { const allGameRows = await db .selectDistinct({ eventId: schema.scoringEvents.id, @@ -539,7 +491,7 @@ export async function getUpcomingEventsForDraftedParticipants( schema.playoffMatchGames, eq(schema.playoffMatchGames.playoffMatchId, schema.playoffMatches.id) ) - .where(inArray(schema.scoringEvents.id, eventIds)); + .where(inArray(schema.scoringEvents.id, eventIdsFromRows)); for (const row of allGameRows) { if (row.gameNumber !== null) { @@ -549,47 +501,91 @@ export async function getUpcomingEventsForDraftedParticipants( } } - for (const [eventId, event] of eventMap) { - event.relevantParticipants = Array.from(participantsByEvent.get(eventId)?.values() ?? []); + // Group rows into calendar entries. + // Series events (max game number > 1) get one entry per game so each game + // appears as its own row on the calendar. Single-game events get one entry. + const entryMap = new Map(); + const participantsByEntry = new Map>(); + const gameKeysByEntry = new Map>(); + const earliestTimeByEntry = new Map(); - const earliest = earliestTimeByEvent.get(eventId); - event.earliestGameTime = earliest ? earliest.toISOString() : null; + for (const row of rows) { + const isSeries = (maxGameNumberByEvent.get(row.id) ?? 1) > 1; + const entryKey = + isSeries && row.gameNumber !== null ? `${row.id}|${row.gameNumber}` : row.id; - // Build matchLabel from game keys - const gameKeys = gameKeysByEvent.get(eventId) ?? new Set(); - if (gameKeys.size === 1) { - const [round, gameNumberStr] = [...gameKeys][0].split("|"); - const gameNumber = parseInt(gameNumberStr, 10); - if (gameNumber > 1) { - // Game #2+ in the date window — always show the game number. - event.matchLabel = - round === event.name - ? `Game #${gameNumberStr}` - : `${round} Game #${gameNumberStr}`; - } else { - // Game #1 — only show game number if this is a multi-game series - // (i.e. Game #2+ exists in the DB, even if outside the date window). - const maxGameNumber = maxGameNumberByEvent.get(eventId) ?? 1; - if (maxGameNumber > 1) { - event.matchLabel = round === event.name ? `Game #1` : `${round} Game #1`; - } else { - // Truly a single-game matchup — no number needed. - event.matchLabel = round === event.name ? null : round; - } + if (!entryMap.has(entryKey)) { + entryMap.set(entryKey, { + id: entryKey, + name: row.name, + eventDate: row.eventDate, + earliestGameTime: null, + matchLabel: null, + eventType: row.eventType, + sportsSeasonId: row.sportsSeasonId, + relevantParticipants: [], + }); + participantsByEntry.set(entryKey, new Map()); + gameKeysByEntry.set(entryKey, new Set()); + } + + const pMap = participantsByEntry.get(entryKey); + if (pMap) { + if (row.participant1Id) { + const p1 = draftedMap.get(row.participant1Id); + if (p1) pMap.set(row.participant1Id, p1); } - } else if (gameKeys.size > 1) { - // Multiple games in the window — it's a series, show the lowest game number. - const rounds = new Set([...gameKeys].map((k) => k.split("|")[0])); - if (rounds.size === 1) { - const round = [...rounds][0]; - const minGameNumber = Math.min(...[...gameKeys].map((k) => parseInt(k.split("|")[1], 10))); - event.matchLabel = - round === event.name ? `Game #${minGameNumber}` : `${round} Game #${minGameNumber}`; + if (row.participant2Id) { + const p2 = draftedMap.get(row.participant2Id); + if (p2) pMap.set(row.participant2Id, p2); + } + } + + if (row.round && row.gameNumber !== null) { + gameKeysByEntry.get(entryKey)?.add(`${row.round}|${row.gameNumber}`); + } + + if (row.scheduledAt) { + const prev = earliestTimeByEntry.get(entryKey); + if (!prev || row.scheduledAt < prev) { + earliestTimeByEntry.set(entryKey, row.scheduledAt); } } } - return Array.from(eventMap.values()); + for (const [entryKey, event] of entryMap) { + event.relevantParticipants = Array.from(participantsByEntry.get(entryKey)?.values() ?? []); + + const earliest = earliestTimeByEntry.get(entryKey); + event.earliestGameTime = earliest ? earliest.toISOString() : null; + + const eventId = entryKey.split("|")[0]; + const isSeries = (maxGameNumberByEvent.get(eventId) ?? 1) > 1; + const gameKeys = gameKeysByEntry.get(entryKey) ?? new Set(); + + if (isSeries) { + // Each entry is for a specific game in a series — always show the game number. + if (gameKeys.size >= 1) { + const [round, gameNumberStr] = [...gameKeys][0].split("|"); + event.matchLabel = + round === event.name ? `Game #${gameNumberStr}` : `${round} Game #${gameNumberStr}`; + } + } else { + // Single-game matchup — show the round name, no game number. + if (gameKeys.size === 1) { + const [round] = [...gameKeys][0].split("|"); + event.matchLabel = 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; + } + } + } + } + + return Array.from(entryMap.values()); } export interface DashboardScoringEvent {