diff --git a/app/models/__tests__/upcoming-calendar.test.ts b/app/models/__tests__/upcoming-calendar.test.ts index 03997b1..1178a33 100644 --- a/app/models/__tests__/upcoming-calendar.test.ts +++ b/app/models/__tests__/upcoming-calendar.test.ts @@ -142,6 +142,7 @@ describe("getUpcomingEventsForDraftedParticipants — all-compete sports", () => // ── Bracket patterns ─────────────────────────────────────────────────────── describe("getUpcomingEventsForDraftedParticipants — bracket sports", () => { + // Main query chain: resolves on orderBy (has leftJoin, orderBy) function makeSelectChain(rows: unknown[]) { const chain = { from: vi.fn().mockReturnThis(), @@ -153,8 +154,21 @@ describe("getUpcomingEventsForDraftedParticipants — bracket sports", () => { return chain; } + // Second query chain (max game lookup): resolves on where (no leftJoin, no orderBy) + function makeMaxGameChain(rows: unknown[]) { + const chain = { + from: vi.fn().mockReturnThis(), + innerJoin: vi.fn().mockReturnThis(), + where: vi.fn().mockResolvedValue(rows), + }; + return chain; + } + beforeEach(() => { vi.clearAllMocks(); + // Default for the second selectDistinct call (max-game-number lookup). + // Individual tests override the first call with mockReturnValueOnce. + mockDb.selectDistinct.mockReturnValue(makeMaxGameChain([])); }); it("returns [] when draftedParticipants is empty", async () => { @@ -172,7 +186,7 @@ describe("getUpcomingEventsForDraftedParticipants — bracket sports", () => { participant1Id: "man-city", participant2Id: "bayern", round: "Quarterfinals", gameNumber: 1, scheduledAt: null, }]; - mockDb.selectDistinct.mockReturnValue(makeSelectChain(rows)); + mockDb.selectDistinct.mockReturnValueOnce(makeSelectChain(rows)); const result = await getUpcomingEventsForDraftedParticipants( SPORTS_SEASON_ID, "playoff_bracket", @@ -197,7 +211,7 @@ describe("getUpcomingEventsForDraftedParticipants — bracket sports", () => { round: "Quarterfinals", gameNumber: 1, scheduledAt: null, }, ]; - mockDb.selectDistinct.mockReturnValue(makeSelectChain(rows)); + mockDb.selectDistinct.mockReturnValueOnce(makeSelectChain(rows)); const result = await getUpcomingEventsForDraftedParticipants( SPORTS_SEASON_ID, "playoff_bracket", @@ -227,7 +241,7 @@ describe("getUpcomingEventsForDraftedParticipants — bracket sports", () => { round: "Quarterfinals", gameNumber: 1, scheduledAt: null, }, ]; - mockDb.selectDistinct.mockReturnValue(makeSelectChain(rows)); + mockDb.selectDistinct.mockReturnValueOnce(makeSelectChain(rows)); const result = await getUpcomingEventsForDraftedParticipants( SPORTS_SEASON_ID, "playoff_bracket", @@ -247,7 +261,7 @@ describe("getUpcomingEventsForDraftedParticipants — bracket sports", () => { participant1Id: "man-city", participant2Id: "real-madrid", round: "Quarterfinals", gameNumber: 1, scheduledAt: null, }]; - mockDb.selectDistinct.mockReturnValue(makeSelectChain(rows)); + mockDb.selectDistinct.mockReturnValueOnce(makeSelectChain(rows)); const result = await getUpcomingEventsForDraftedParticipants( SPORTS_SEASON_ID, "playoff_bracket", @@ -261,7 +275,7 @@ describe("getUpcomingEventsForDraftedParticipants — bracket sports", () => { }); it("works for ucl_bracket pattern", async () => { - mockDb.selectDistinct.mockReturnValue(makeSelectChain([{ + mockDb.selectDistinct.mockReturnValueOnce(makeSelectChain([{ id: "e1", name: "UCL SF", eventDate: "2025-04-29", eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID, participant1Id: "man-city", participant2Id: "arsenal", @@ -286,7 +300,7 @@ describe("getUpcomingEventsForDraftedParticipants — bracket sports", () => { participant1Id: "barcelona", participant2Id: "sporting", round: "Knockout Stage", gameNumber: 1, scheduledAt: null, }]; - mockDb.selectDistinct.mockReturnValue(makeSelectChain(rows)); + mockDb.selectDistinct.mockReturnValueOnce(makeSelectChain(rows)); const result = await getUpcomingEventsForDraftedParticipants( SPORTS_SEASON_ID, "playoff_bracket", @@ -309,7 +323,7 @@ describe("getUpcomingEventsForDraftedParticipants — bracket sports", () => { participant1Id: "barcelona", participant2Id: "sporting", round: "Knockout Stage", gameNumber: 1, scheduledAt: gameTime, }]; - mockDb.selectDistinct.mockReturnValue(makeSelectChain(rows)); + mockDb.selectDistinct.mockReturnValueOnce(makeSelectChain(rows)); const result = await getUpcomingEventsForDraftedParticipants( SPORTS_SEASON_ID, "playoff_bracket", @@ -338,7 +352,7 @@ describe("getUpcomingEventsForDraftedParticipants — bracket sports", () => { round: "Knockout Stage", gameNumber: 2, scheduledAt: gameTime1, }, ]; - mockDb.selectDistinct.mockReturnValue(makeSelectChain(rows)); + mockDb.selectDistinct.mockReturnValueOnce(makeSelectChain(rows)); const result = await getUpcomingEventsForDraftedParticipants( SPORTS_SEASON_ID, "playoff_bracket", @@ -357,7 +371,7 @@ describe("getUpcomingEventsForDraftedParticipants — bracket sports", () => { participant1Id: "man-city", participant2Id: "arsenal", round: "Semifinals", gameNumber: 1, scheduledAt: null, }]; - mockDb.selectDistinct.mockReturnValue(makeSelectChain(rows)); + mockDb.selectDistinct.mockReturnValueOnce(makeSelectChain(rows)); const result = await getUpcomingEventsForDraftedParticipants( SPORTS_SEASON_ID, "playoff_bracket", @@ -377,7 +391,7 @@ describe("getUpcomingEventsForDraftedParticipants — bracket sports", () => { participant1Id: "barcelona", participant2Id: "sporting", round: "Knockout Stage", gameNumber: 1, scheduledAt: null, }]; - mockDb.selectDistinct.mockReturnValue(makeSelectChain(rows)); + mockDb.selectDistinct.mockReturnValueOnce(makeSelectChain(rows)); const result = await getUpcomingEventsForDraftedParticipants( SPORTS_SEASON_ID, "playoff_bracket", @@ -395,7 +409,7 @@ describe("getUpcomingEventsForDraftedParticipants — bracket sports", () => { participant1Id: "man-city", participant2Id: "arsenal", round: "Semifinals", gameNumber: 2, scheduledAt: null, }]; - mockDb.selectDistinct.mockReturnValue(makeSelectChain(rows)); + mockDb.selectDistinct.mockReturnValueOnce(makeSelectChain(rows)); const result = await getUpcomingEventsForDraftedParticipants( SPORTS_SEASON_ID, "playoff_bracket", @@ -406,6 +420,32 @@ describe("getUpcomingEventsForDraftedParticipants — bracket sports", () => { expect(result[0].matchLabel).toBe("Semifinals Game #2"); }); + it("shows Game #1 label when game #1 is upcoming but series has multiple games", async () => { + // Game #2 exists in DB but is outside the date window — only Game #1 appears + // in the main query. The second (un-date-filtered) query reveals it's a series. + const mainRows = [{ + id: "e1", name: "NBA Finals", eventDate: "2025-04-10", + eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID, + participant1Id: "lakers", participant2Id: "celtics", + round: "Finals", gameNumber: 1, scheduledAt: null, + }]; + const allGameRows = [ + { eventId: "e1", gameNumber: 1 }, + { eventId: "e1", gameNumber: 2 }, + ]; + mockDb.selectDistinct + .mockReturnValueOnce(makeSelectChain(mainRows)) + .mockReturnValueOnce(makeMaxGameChain(allGameRows)); + + const result = await getUpcomingEventsForDraftedParticipants( + SPORTS_SEASON_ID, "playoff_bracket", + [makeParticipant("lakers", "Lakers")], + DATE_FROM, DATE_TO + ); + + expect(result[0].matchLabel).toBe("Finals Game #1"); + }); + it("all-compete events have null earliestGameTime and matchLabel", async () => { const participants = [makeParticipant("p1", "Verstappen")]; mockDb.query.scoringEvents.findMany.mockResolvedValue([ @@ -504,11 +544,21 @@ describe("getUpcomingEventsForDraftedParticipants — bracket misc", () => { }; } + function makeMaxGameChain(rows: unknown[]) { + return { + from: vi.fn().mockReturnThis(), + innerJoin: vi.fn().mockReturnThis(), + where: vi.fn().mockResolvedValue(rows), + }; + } + beforeEach(() => vi.clearAllMocks()); it("uses leftJoin so events without any games are still considered", async () => { const chain = makeSelectChain([]); - mockDb.selectDistinct.mockReturnValue(chain); + mockDb.selectDistinct + .mockReturnValueOnce(chain) + .mockReturnValueOnce(makeMaxGameChain([])); await getUpcomingEventsForDraftedParticipants( SPORTS_SEASON_ID, "playoff_bracket", diff --git a/app/models/scoring-event.ts b/app/models/scoring-event.ts index a3b5456..befaf35 100644 --- a/app/models/scoring-event.ts +++ b/app/models/scoring-event.ts @@ -520,6 +520,35 @@ export async function getUpcomingEventsForDraftedParticipants( } } + // 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. + const maxGameNumberByEvent = new Map(); + const eventIds = Array.from(eventMap.keys()); + if (eventIds.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, eventIds)); + + 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)); + } + } + } + for (const [eventId, event] of eventMap) { event.relevantParticipants = Array.from(participantsByEvent.get(eventId)?.values() ?? []); @@ -532,15 +561,21 @@ export async function getUpcomingEventsForDraftedParticipants( const [round, gameNumberStr] = [...gameKeys][0].split("|"); const gameNumber = parseInt(gameNumberStr, 10); if (gameNumber > 1) { - // Game #2+ indicates a multi-game series — show the game number so - // users can tell which game in the series is upcoming. + // Game #2+ in the date window — always show the game number. event.matchLabel = round === event.name ? `Game #${gameNumberStr}` : `${round} Game #${gameNumberStr}`; } else { - // Single game (game #1 only) — no number needed, just show the round. - event.matchLabel = round === event.name ? null : round; + // 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; + } } } else if (gameKeys.size > 1) { // Multiple games across the matchup — use just the round name.