Fix upcoming events grouping to separate individual bracket matches (#302)

Include playoffMatchId in the grouping key so that each match within a
scoring event (e.g. different Round of 32 matchups in snooker) appears
as its own calendar entry instead of being merged together.
This commit is contained in:
Chris Parsons 2026-04-16 14:19:59 -07:00 committed by GitHub
parent 13bc1e3b5f
commit e03bdd538f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 68 additions and 8 deletions

View file

@ -23,7 +23,7 @@ vi.mock("~/database/context", () => ({
vi.mock("~/database/schema", () => ({ vi.mock("~/database/schema", () => ({
scoringEvents: { sportsSeasonId: "se.sports_season_id", isComplete: "se.is_complete", eventDate: "se.event_date", eventType: "se.event_type" }, scoringEvents: { sportsSeasonId: "se.sports_season_id", isComplete: "se.is_complete", eventDate: "se.event_date", eventType: "se.event_type" },
playoffMatches: { scoringEventId: "pm.scoring_event_id", participant1Id: "pm.participant1_id", participant2Id: "pm.participant2_id", round: "pm.round", matchNumber: "pm.match_number", isComplete: "pm.is_complete" }, playoffMatches: { id: "pm.id", scoringEventId: "pm.scoring_event_id", participant1Id: "pm.participant1_id", participant2Id: "pm.participant2_id", round: "pm.round", matchNumber: "pm.match_number", isComplete: "pm.is_complete" },
playoffMatchGames: { playoffMatchId: "pmg.playoff_match_id", scheduledAt: "pmg.scheduled_at", gameNumber: "pmg.game_number" }, playoffMatchGames: { playoffMatchId: "pmg.playoff_match_id", scheduledAt: "pmg.scheduled_at", gameNumber: "pmg.game_number" },
})); }));
@ -183,6 +183,7 @@ describe("getUpcomingEventsForDraftedParticipants — bracket sports", () => {
const rows = [{ const rows = [{
id: "e1", name: "UCL QF", eventDate: "2025-04-09", id: "e1", name: "UCL QF", eventDate: "2025-04-09",
eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID, eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
playoffMatchId: "pm1",
participant1Id: "man-city", participant2Id: "bayern", participant1Id: "man-city", participant2Id: "bayern",
round: "Quarterfinals", gameNumber: 1, scheduledAt: null, round: "Quarterfinals", gameNumber: 1, scheduledAt: null,
}]; }];
@ -207,6 +208,7 @@ describe("getUpcomingEventsForDraftedParticipants — bracket sports", () => {
{ {
id: "e1", name: "UCL QF", eventDate: "2025-04-09", id: "e1", name: "UCL QF", eventDate: "2025-04-09",
eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID, eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
playoffMatchId: "pm1",
participant1Id: "man-city", participant2Id: "arsenal", participant1Id: "man-city", participant2Id: "arsenal",
round: "Quarterfinals", gameNumber: 1, scheduledAt: null, round: "Quarterfinals", gameNumber: 1, scheduledAt: null,
}, },
@ -226,17 +228,19 @@ describe("getUpcomingEventsForDraftedParticipants — bracket sports", () => {
expect(names).toContain("Arsenal"); expect(names).toContain("Arsenal");
}); });
it("returns multiple events when picks are in different matches", async () => { it("returns separate events when picks are in different matches within the same scoring event", async () => {
const rows = [ const rows = [
{ {
id: "e1", name: "UCL QF", eventDate: "2025-04-09", id: "e1", name: "UCL QF", eventDate: "2025-04-09",
eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID, eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
playoffMatchId: "pm1",
participant1Id: "man-city", participant2Id: "real-madrid", participant1Id: "man-city", participant2Id: "real-madrid",
round: "Quarterfinals", gameNumber: 1, scheduledAt: null, round: "Quarterfinals", gameNumber: 1, scheduledAt: null,
}, },
{ {
id: "e1", name: "UCL QF", eventDate: "2025-04-09", id: "e1", name: "UCL QF", eventDate: "2025-04-09",
eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID, eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
playoffMatchId: "pm2",
participant1Id: "arsenal", participant2Id: "psg", participant1Id: "arsenal", participant2Id: "psg",
round: "Quarterfinals", gameNumber: 1, scheduledAt: null, round: "Quarterfinals", gameNumber: 1, scheduledAt: null,
}, },
@ -249,15 +253,21 @@ describe("getUpcomingEventsForDraftedParticipants — bracket sports", () => {
DATE_FROM, DATE_TO DATE_FROM, DATE_TO
); );
// Both matches are in the same event → one result with both picks // Each match is now a separate entry
expect(result).toHaveLength(1); expect(result).toHaveLength(2);
expect(result[0].relevantParticipants).toHaveLength(2); const manCityEntry = result.find((r) => r.relevantParticipants.some((p) => p.id === "man-city"));
const arsenalEntry = result.find((r) => r.relevantParticipants.some((p) => p.id === "arsenal"));
expect(manCityEntry).toBeDefined();
expect(arsenalEntry).toBeDefined();
expect(manCityEntry?.relevantParticipants).toHaveLength(1);
expect(arsenalEntry?.relevantParticipants).toHaveLength(1);
}); });
it("only includes drafted participants in relevantParticipants, not opponents", async () => { it("only includes drafted participants in relevantParticipants, not opponents", async () => {
const rows = [{ const rows = [{
id: "e1", name: "UCL QF", eventDate: "2025-04-09", id: "e1", name: "UCL QF", eventDate: "2025-04-09",
eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID, eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
playoffMatchId: "pm1",
participant1Id: "man-city", participant2Id: "real-madrid", participant1Id: "man-city", participant2Id: "real-madrid",
round: "Quarterfinals", gameNumber: 1, scheduledAt: null, round: "Quarterfinals", gameNumber: 1, scheduledAt: null,
}]; }];
@ -278,6 +288,7 @@ describe("getUpcomingEventsForDraftedParticipants — bracket sports", () => {
mockDb.selectDistinct.mockReturnValueOnce(makeSelectChain([{ mockDb.selectDistinct.mockReturnValueOnce(makeSelectChain([{
id: "e1", name: "UCL SF", eventDate: "2025-04-29", id: "e1", name: "UCL SF", eventDate: "2025-04-29",
eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID, eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
playoffMatchId: "pm1",
participant1Id: "man-city", participant2Id: "arsenal", participant1Id: "man-city", participant2Id: "arsenal",
round: "Semifinals", gameNumber: 1, scheduledAt: null, round: "Semifinals", gameNumber: 1, scheduledAt: null,
}])); }]));
@ -297,6 +308,7 @@ describe("getUpcomingEventsForDraftedParticipants — bracket sports", () => {
id: "e1", name: "UCL QF", id: "e1", name: "UCL QF",
eventDate: null, // ← no date on the event itself eventDate: null, // ← no date on the event itself
eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID, eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
playoffMatchId: "pm1",
participant1Id: "barcelona", participant2Id: "sporting", participant1Id: "barcelona", participant2Id: "sporting",
round: "Knockout Stage", gameNumber: 1, scheduledAt: null, round: "Knockout Stage", gameNumber: 1, scheduledAt: null,
}]; }];
@ -320,6 +332,7 @@ describe("getUpcomingEventsForDraftedParticipants — bracket sports", () => {
id: "e1", name: "UCL QF", id: "e1", name: "UCL QF",
eventDate: null, eventDate: null,
eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID, eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
playoffMatchId: "pm1",
participant1Id: "barcelona", participant2Id: "sporting", participant1Id: "barcelona", participant2Id: "sporting",
round: "Knockout Stage", gameNumber: 1, scheduledAt: gameTime, round: "Knockout Stage", gameNumber: 1, scheduledAt: gameTime,
}]; }];
@ -342,12 +355,14 @@ describe("getUpcomingEventsForDraftedParticipants — bracket sports", () => {
{ {
id: "e1", name: "UCL QF", eventDate: null, id: "e1", name: "UCL QF", eventDate: null,
eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID, eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
playoffMatchId: "pm1",
participant1Id: "barcelona", participant2Id: "sporting", participant1Id: "barcelona", participant2Id: "sporting",
round: "Knockout Stage", gameNumber: 1, scheduledAt: gameTime1, round: "Knockout Stage", gameNumber: 1, scheduledAt: gameTime1,
}, },
{ {
id: "e1", name: "UCL QF", eventDate: null, id: "e1", name: "UCL QF", eventDate: null,
eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID, eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
playoffMatchId: "pm1",
participant1Id: "barcelona", participant2Id: "sporting", participant1Id: "barcelona", participant2Id: "sporting",
round: "Knockout Stage", gameNumber: 2, scheduledAt: gameTime2, round: "Knockout Stage", gameNumber: 2, scheduledAt: gameTime2,
}, },
@ -366,8 +381,8 @@ describe("getUpcomingEventsForDraftedParticipants — bracket sports", () => {
); );
expect(result).toHaveLength(2); expect(result).toHaveLength(2);
const game1 = result.find((r) => r.id === "e1|1"); const game1 = result.find((r) => r.id === "e1|pm1|1");
const game2 = result.find((r) => r.id === "e1|2"); const game2 = result.find((r) => r.id === "e1|pm1|2");
expect(game1?.earliestGameTime).toBe(gameTime1.toISOString()); expect(game1?.earliestGameTime).toBe(gameTime1.toISOString());
expect(game1?.matchLabel).toBe("Knockout Stage Game #1"); expect(game1?.matchLabel).toBe("Knockout Stage Game #1");
expect(game2?.earliestGameTime).toBe(gameTime2.toISOString()); expect(game2?.earliestGameTime).toBe(gameTime2.toISOString());
@ -378,6 +393,7 @@ describe("getUpcomingEventsForDraftedParticipants — bracket sports", () => {
const rows = [{ const rows = [{
id: "e1", name: "PDC World Championship", eventDate: "2025-04-09", id: "e1", name: "PDC World Championship", eventDate: "2025-04-09",
eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID, eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
playoffMatchId: "pm1",
participant1Id: "man-city", participant2Id: "arsenal", participant1Id: "man-city", participant2Id: "arsenal",
round: "Semifinals", gameNumber: 1, scheduledAt: null, round: "Semifinals", gameNumber: 1, scheduledAt: null,
}]; }];
@ -398,6 +414,7 @@ describe("getUpcomingEventsForDraftedParticipants — bracket sports", () => {
const rows = [{ const rows = [{
id: "e1", name: "Knockout Stage", eventDate: null, id: "e1", name: "Knockout Stage", eventDate: null,
eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID, eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
playoffMatchId: "pm1",
participant1Id: "barcelona", participant2Id: "sporting", participant1Id: "barcelona", participant2Id: "sporting",
round: "Knockout Stage", gameNumber: 1, scheduledAt: null, round: "Knockout Stage", gameNumber: 1, scheduledAt: null,
}]; }];
@ -416,6 +433,7 @@ describe("getUpcomingEventsForDraftedParticipants — bracket sports", () => {
const rows = [{ const rows = [{
id: "e1", name: "PDC World Championship", eventDate: "2025-04-10", id: "e1", name: "PDC World Championship", eventDate: "2025-04-10",
eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID, eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
playoffMatchId: "pm1",
participant1Id: "man-city", participant2Id: "arsenal", participant1Id: "man-city", participant2Id: "arsenal",
round: "Semifinals", gameNumber: 2, scheduledAt: null, round: "Semifinals", gameNumber: 2, scheduledAt: null,
}]; }];
@ -441,6 +459,7 @@ describe("getUpcomingEventsForDraftedParticipants — bracket sports", () => {
const mainRows = [{ const mainRows = [{
id: "e1", name: "NBA Finals", eventDate: "2025-04-10", id: "e1", name: "NBA Finals", eventDate: "2025-04-10",
eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID, eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
playoffMatchId: "pm1",
participant1Id: "lakers", participant2Id: "celtics", participant1Id: "lakers", participant2Id: "celtics",
round: "Finals", gameNumber: 1, scheduledAt: null, round: "Finals", gameNumber: 1, scheduledAt: null,
}]; }];
@ -461,6 +480,44 @@ describe("getUpcomingEventsForDraftedParticipants — bracket sports", () => {
expect(result[0].matchLabel).toBe("Finals Game #1"); expect(result[0].matchLabel).toBe("Finals Game #1");
}); });
it("separates matches within the same scoring event by scheduled time (snooker-style bracket)", async () => {
const time1 = new Date("2025-04-18T06:30:00.000Z");
const time2 = new Date("2025-04-19T06:30:00.000Z");
const rows = [
{
id: "e1", name: "Snooker World Championship", eventDate: null,
eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
playoffMatchId: "pm-williams",
participant1Id: "mark-williams", participant2Id: "opponent-a",
round: "Round of 32", gameNumber: 1, scheduledAt: time1,
},
{
id: "e1", name: "Snooker World Championship", eventDate: null,
eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
playoffMatchId: "pm-higgins",
participant1Id: "john-higgins", participant2Id: "opponent-b",
round: "Round of 32", gameNumber: 1, scheduledAt: time2,
},
];
mockDb.selectDistinct.mockReturnValueOnce(makeSelectChain(rows));
const result = await getUpcomingEventsForDraftedParticipants(
SPORTS_SEASON_ID, "playoff_bracket",
[makeParticipant("mark-williams", "Mark Williams"), makeParticipant("john-higgins", "John Higgins")],
DATE_FROM, DATE_TO
);
expect(result).toHaveLength(2);
const williamsEntry = result.find((r) => r.relevantParticipants.some((p) => p.id === "mark-williams"));
const higginsEntry = result.find((r) => r.relevantParticipants.some((p) => p.id === "john-higgins"));
expect(williamsEntry).toBeDefined();
expect(higginsEntry).toBeDefined();
expect(williamsEntry?.earliestGameTime).toBe(time1.toISOString());
expect(higginsEntry?.earliestGameTime).toBe(time2.toISOString());
expect(williamsEntry?.matchLabel).toBe("Round of 32");
expect(higginsEntry?.matchLabel).toBe("Round of 32");
});
it("all-compete events have null earliestGameTime and matchLabel", async () => { it("all-compete events have null earliestGameTime and matchLabel", async () => {
const participants = [makeParticipant("p1", "Verstappen")]; const participants = [makeParticipant("p1", "Verstappen")];
mockDb.query.scoringEvents.findMany.mockResolvedValue([ mockDb.query.scoringEvents.findMany.mockResolvedValue([

View file

@ -425,6 +425,7 @@ export async function getUpcomingEventsForDraftedParticipants(
eventDate: schema.scoringEvents.eventDate, eventDate: schema.scoringEvents.eventDate,
eventType: schema.scoringEvents.eventType, eventType: schema.scoringEvents.eventType,
sportsSeasonId: schema.scoringEvents.sportsSeasonId, sportsSeasonId: schema.scoringEvents.sportsSeasonId,
playoffMatchId: schema.playoffMatches.id,
participant1Id: schema.playoffMatches.participant1Id, participant1Id: schema.playoffMatches.participant1Id,
participant2Id: schema.playoffMatches.participant2Id, participant2Id: schema.playoffMatches.participant2Id,
round: schema.playoffMatches.round, round: schema.playoffMatches.round,
@ -512,7 +513,9 @@ export async function getUpcomingEventsForDraftedParticipants(
for (const row of rows) { for (const row of rows) {
const isSeries = (maxGameNumberByEvent.get(row.id) ?? 1) > 1; const isSeries = (maxGameNumberByEvent.get(row.id) ?? 1) > 1;
const entryKey = const entryKey =
isSeries && row.gameNumber !== null ? `${row.id}|${row.gameNumber}` : row.id; isSeries && row.gameNumber !== null
? `${row.id}|${row.playoffMatchId}|${row.gameNumber}`
: `${row.id}|${row.playoffMatchId}`;
if (!entryMap.has(entryKey)) { if (!entryMap.has(entryKey)) {
entryMap.set(entryKey, { entryMap.set(entryKey, {