From 88e18412ecb3b31d25422d00f04c6cdef40e087e Mon Sep 17 00:00:00 2001 From: Chris Parsons <438676+chrisparsons83@users.noreply.github.com> Date: Sun, 22 Mar 2026 16:39:26 -0700 Subject: [PATCH] Don't show Game #1 label for single-game matchups in calendar (#206) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Don't show Game #1 label for single-game matchups in calendar When a matchup has only one game, showing "Game #1" is redundant and unhelpful. Only show game numbers when there are multiple games in a matchup (gameKeys.size > 1). For single-game matchups, show just the round name (or null if it matches the event name). Closes #198 https://claude.ai/code/session_01CRrqu43FKHshpJt1NyrDdX * Show game number for Game #2+ in series, hide Game #1 for single-game matchups If a matchup has only one game (game #1), the number is redundant — show just the round name. If game #2 or higher is upcoming, it clearly belongs to a multi-game series, so show the game number to distinguish it. https://claude.ai/code/session_01CRrqu43FKHshpJt1NyrDdX * Fix Game #1 label for multi-game series when only Game #1 is in date window Previously, Game #1 of a multi-game series showed no game number (e.g. "Semifinals") if Game #2 fell outside the calendar date range. Since at least 2 games are always entered for a series from the start, a second un-date-filtered query now checks the max game number per event. If max > 1, the event is a series and Game #1 is labelled accordingly (e.g. "Semifinals Game #1"). https://claude.ai/code/session_01CRrqu43FKHshpJt1NyrDdX * Show game number when multiple games from same series are in date window The gameKeys.size > 1 branch previously showed only the round name, contradicting the rule that multi-game series always show "Game #x". Now shows the lowest game number visible in the window (e.g. both Game #1 and #2 upcoming → "Semifinals Game #1"). https://claude.ai/code/session_01CRrqu43FKHshpJt1NyrDdX * Split series games into individual calendar entries, each showing Game #x Each game in a multi-game bracket series (e.g. Game #1 on Mar 23, Game #2 on Mar 25) now appears as its own row on the calendar with its own date and "Round Game #x" label. Single-game matchups are unchanged. Grouping key changes from eventId to eventId|gameNumber for series events. The max-game-number lookup now runs before grouping so the correct key can be determined while processing each row. https://claude.ai/code/session_01CRrqu43FKHshpJt1NyrDdX * Cache isSeries per entry to avoid re-deriving it in the finalization loop https://claude.ai/code/session_01CRrqu43FKHshpJt1NyrDdX --------- Co-authored-by: Claude --- .../__tests__/upcoming-calendar.test.ts | 127 +++++++++++++++--- app/models/scoring-event.ts | 109 ++++++++++----- 2 files changed, 179 insertions(+), 57 deletions(-) diff --git a/app/models/__tests__/upcoming-calendar.test.ts b/app/models/__tests__/upcoming-calendar.test.ts index e55a330..7ed2ef7 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", @@ -321,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.mockReturnValue(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", @@ -346,18 +365,23 @@ describe("getUpcomingEventsForDraftedParticipants — bracket sports", () => { DATE_FROM, DATE_TO ); - // Should use the earlier time - expect(result[0].earliestGameTime).toBe(gameTime1.toISOString()); + 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+game when single game key (non-redundant)", async () => { + it("sets matchLabel to round name only (no game number) for single-game matchup", async () => { const rows = [{ id: "e1", name: "PDC World Championship", eventDate: "2025-04-09", eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID, 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", @@ -365,19 +389,19 @@ describe("getUpcomingEventsForDraftedParticipants — bracket sports", () => { DATE_FROM, DATE_TO ); - expect(result[0].matchLabel).toBe("Semifinals Game #1"); + expect(result[0].matchLabel).toBe("Semifinals"); }); - it("omits redundant round prefix when round name matches event name", async () => { + it("sets matchLabel to null when round name matches event name (single game)", async () => { // e.g. event named "Knockout Stage", round also "Knockout Stage", single game - // → show "Game #1" rather than the redundant "Knockout Stage Game #1" + // → show null rather than redundant label const rows = [{ id: "e1", name: "Knockout Stage", eventDate: null, eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID, 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", @@ -385,7 +409,56 @@ describe("getUpcomingEventsForDraftedParticipants — bracket sports", () => { DATE_FROM, DATE_TO ); - expect(result[0].matchLabel).toBe("Game #1"); + expect(result[0].matchLabel).toBeNull(); + }); + + 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)) + .mockReturnValueOnce(makeMaxGameChain([ + { eventId: "e1", gameNumber: 1 }, + { eventId: "e1", gameNumber: 2 }, + ])); + + const result = await getUpcomingEventsForDraftedParticipants( + SPORTS_SEASON_ID, "playoff_bracket", + [makeParticipant("man-city", "Man City")], + DATE_FROM, DATE_TO + ); + + 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 () => { @@ -486,11 +559,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 28478ee..6120309 100644 --- a/app/models/scoring-event.ts +++ b/app/models/scoring-event.ts @@ -472,18 +472,52 @@ 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(); + // 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)); + } + } + } + + // 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 isSeriesByEntry = new Map(); for (const row of rows) { - if (!eventMap.has(row.id)) { - eventMap.set(row.id, { - id: row.id, + const isSeries = (maxGameNumberByEvent.get(row.id) ?? 1) > 1; + const entryKey = + isSeries && row.gameNumber !== null ? `${row.id}|${row.gameNumber}` : row.id; + + if (!entryMap.has(entryKey)) { + entryMap.set(entryKey, { + id: entryKey, name: row.name, eventDate: row.eventDate, earliestGameTime: null, @@ -492,11 +526,12 @@ export async function getUpcomingEventsForDraftedParticipants( sportsSeasonId: row.sportsSeasonId, relevantParticipants: [], }); - participantsByEvent.set(row.id, new Map()); - gameKeysByEvent.set(row.id, new Set()); + participantsByEntry.set(entryKey, new Map()); + gameKeysByEntry.set(entryKey, new Set()); + isSeriesByEntry.set(entryKey, isSeries); } - const pMap = participantsByEvent.get(row.id); + const pMap = participantsByEntry.get(entryKey); if (pMap) { if (row.participant1Id) { const p1 = draftedMap.get(row.participant1Id); @@ -509,44 +544,48 @@ export async function getUpcomingEventsForDraftedParticipants( } if (row.round && row.gameNumber !== null) { - gameKeysByEvent.get(row.id)?.add(`${row.round}|${row.gameNumber}`); + gameKeysByEntry.get(entryKey)?.add(`${row.round}|${row.gameNumber}`); } if (row.scheduledAt) { - const prev = earliestTimeByEvent.get(row.id); + const prev = earliestTimeByEntry.get(entryKey); if (!prev || row.scheduledAt < prev) { - earliestTimeByEvent.set(row.id, row.scheduledAt); + earliestTimeByEntry.set(entryKey, row.scheduledAt); } } } - for (const [eventId, event] of eventMap) { - event.relevantParticipants = Array.from(participantsByEvent.get(eventId)?.values() ?? []); + for (const [entryKey, event] of entryMap) { + event.relevantParticipants = Array.from(participantsByEntry.get(entryKey)?.values() ?? []); - const earliest = earliestTimeByEvent.get(eventId); + const earliest = earliestTimeByEntry.get(entryKey); event.earliestGameTime = earliest ? earliest.toISOString() : null; - // Build matchLabel from game keys - const gameKeys = gameKeysByEvent.get(eventId) ?? new Set(); - if (gameKeys.size === 1) { - const [round, gameNumberStr] = [...gameKeys][0].split("|"); - // When round name duplicates the event name, omit the round to avoid - // "Knockout Stage — Knockout Stage Game #1"; just show "Game #1" instead. - event.matchLabel = - round === event.name - ? `Game #${gameNumberStr}` - : `${round} Game #${gameNumberStr}`; - } else if (gameKeys.size > 1) { - // Multiple games — use just the round if it differs from the event name - const rounds = new Set([...gameKeys].map((k) => k.split("|")[0])); - if (rounds.size === 1) { - const round = [...rounds][0]; + const isSeries = isSeriesByEntry.get(entryKey) ?? false; + const gameKeys = gameKeysByEntry.get(entryKey) ?? new Set(); + + if (isSeries) { + if (gameKeys.size >= 1) { + const [round, gameNumberStr] = [...gameKeys][0].split("|"); + event.matchLabel = + 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; + } 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(eventMap.values()); + return Array.from(entryMap.values()); } export interface DashboardScoringEvent {