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
This commit is contained in:
parent
7001ec1227
commit
01b012e09c
2 changed files with 106 additions and 97 deletions
|
|
@ -335,24 +335,29 @@ describe("getUpcomingEventsForDraftedParticipants — bracket sports", () => {
|
||||||
expect(result[0].eventDate).toBeNull();
|
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 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 = [
|
const rows = [
|
||||||
{
|
{
|
||||||
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,
|
||||||
participant1Id: "barcelona", participant2Id: "sporting",
|
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,
|
id: "e1", name: "UCL QF", eventDate: null,
|
||||||
eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
|
eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
|
||||||
participant1Id: "barcelona", participant2Id: "sporting",
|
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(
|
const result = await getUpcomingEventsForDraftedParticipants(
|
||||||
SPORTS_SEASON_ID, "playoff_bracket",
|
SPORTS_SEASON_ID, "playoff_bracket",
|
||||||
|
|
@ -360,10 +365,13 @@ describe("getUpcomingEventsForDraftedParticipants — bracket sports", () => {
|
||||||
DATE_FROM, DATE_TO
|
DATE_FROM, DATE_TO
|
||||||
);
|
);
|
||||||
|
|
||||||
// Should use the earlier time
|
expect(result).toHaveLength(2);
|
||||||
expect(result[0].earliestGameTime).toBe(gameTime1.toISOString());
|
const game1 = result.find((r) => r.id === "e1|1")!;
|
||||||
// Both games in the window → multi-game series → show lowest game number
|
const game2 = result.find((r) => r.id === "e1|2")!;
|
||||||
expect(result[0].matchLabel).toBe("Knockout Stage Game #1");
|
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 () => {
|
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();
|
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 = [{
|
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,
|
||||||
participant1Id: "man-city", participant2Id: "arsenal",
|
participant1Id: "man-city", participant2Id: "arsenal",
|
||||||
round: "Semifinals", gameNumber: 2, scheduledAt: null,
|
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(
|
const result = await getUpcomingEventsForDraftedParticipants(
|
||||||
SPORTS_SEASON_ID, "playoff_bracket",
|
SPORTS_SEASON_ID, "playoff_bracket",
|
||||||
|
|
|
||||||
|
|
@ -472,59 +472,11 @@ export async function getUpcomingEventsForDraftedParticipants(
|
||||||
)
|
)
|
||||||
.orderBy(asc(schema.scoringEvents.eventDate));
|
.orderBy(asc(schema.scoringEvents.eventDate));
|
||||||
|
|
||||||
// Group rows by event, collecting relevant participants, earliest game time, and match label.
|
|
||||||
const eventMap = new Map<string, UpcomingParticipantEvent>();
|
|
||||||
const participantsByEvent = new Map<string, Map<string, { id: string; name: string }>>();
|
|
||||||
// Unique "round|gameNumber" combos per event — used to build matchLabel
|
|
||||||
const gameKeysByEvent = new Map<string, Set<string>>();
|
|
||||||
// Min scheduledAt per event
|
|
||||||
const earliestTimeByEvent = new Map<string, Date>();
|
|
||||||
|
|
||||||
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)
|
// 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<string, number>();
|
const maxGameNumberByEvent = new Map<string, number>();
|
||||||
const eventIds = Array.from(eventMap.keys());
|
if (eventIdsFromRows.length > 0) {
|
||||||
if (eventIds.length > 0) {
|
|
||||||
const allGameRows = await db
|
const allGameRows = await db
|
||||||
.selectDistinct({
|
.selectDistinct({
|
||||||
eventId: schema.scoringEvents.id,
|
eventId: schema.scoringEvents.id,
|
||||||
|
|
@ -539,7 +491,7 @@ export async function getUpcomingEventsForDraftedParticipants(
|
||||||
schema.playoffMatchGames,
|
schema.playoffMatchGames,
|
||||||
eq(schema.playoffMatchGames.playoffMatchId, schema.playoffMatches.id)
|
eq(schema.playoffMatchGames.playoffMatchId, schema.playoffMatches.id)
|
||||||
)
|
)
|
||||||
.where(inArray(schema.scoringEvents.id, eventIds));
|
.where(inArray(schema.scoringEvents.id, eventIdsFromRows));
|
||||||
|
|
||||||
for (const row of allGameRows) {
|
for (const row of allGameRows) {
|
||||||
if (row.gameNumber !== null) {
|
if (row.gameNumber !== null) {
|
||||||
|
|
@ -549,47 +501,91 @@ export async function getUpcomingEventsForDraftedParticipants(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [eventId, event] of eventMap) {
|
// Group rows into calendar entries.
|
||||||
event.relevantParticipants = Array.from(participantsByEvent.get(eventId)?.values() ?? []);
|
// 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<string, UpcomingParticipantEvent>();
|
||||||
|
const participantsByEntry = new Map<string, Map<string, { id: string; name: string }>>();
|
||||||
|
const gameKeysByEntry = new Map<string, Set<string>>();
|
||||||
|
const earliestTimeByEntry = new Map<string, Date>();
|
||||||
|
|
||||||
const earliest = earliestTimeByEvent.get(eventId);
|
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;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
event.earliestGameTime = earliest ? earliest.toISOString() : null;
|
||||||
|
|
||||||
// Build matchLabel from game keys
|
const eventId = entryKey.split("|")[0];
|
||||||
const gameKeys = gameKeysByEvent.get(eventId) ?? new Set<string>();
|
const isSeries = (maxGameNumberByEvent.get(eventId) ?? 1) > 1;
|
||||||
if (gameKeys.size === 1) {
|
const gameKeys = gameKeysByEntry.get(entryKey) ?? new Set<string>();
|
||||||
|
|
||||||
|
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("|");
|
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 =
|
event.matchLabel =
|
||||||
round === event.name
|
round === event.name ? `Game #${gameNumberStr}` : `${round} Game #${gameNumberStr}`;
|
||||||
? `Game #${gameNumberStr}`
|
}
|
||||||
: `${round} Game #${gameNumberStr}`;
|
|
||||||
} else {
|
} else {
|
||||||
// Game #1 — only show game number if this is a multi-game series
|
// Single-game matchup — show the round name, no game number.
|
||||||
// (i.e. Game #2+ exists in the DB, even if outside the date window).
|
if (gameKeys.size === 1) {
|
||||||
const maxGameNumber = maxGameNumberByEvent.get(eventId) ?? 1;
|
const [round] = [...gameKeys][0].split("|");
|
||||||
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;
|
event.matchLabel = round === event.name ? null : round;
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (gameKeys.size > 1) {
|
} 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]));
|
const rounds = new Set([...gameKeys].map((k) => k.split("|")[0]));
|
||||||
if (rounds.size === 1) {
|
if (rounds.size === 1) {
|
||||||
const round = [...rounds][0];
|
const round = [...rounds][0];
|
||||||
const minGameNumber = Math.min(...[...gameKeys].map((k) => parseInt(k.split("|")[1], 10)));
|
event.matchLabel = round === event.name ? null : round;
|
||||||
event.matchLabel =
|
}
|
||||||
round === event.name ? `Game #${minGameNumber}` : `${round} Game #${minGameNumber}`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.from(eventMap.values());
|
return Array.from(entryMap.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DashboardScoringEvent {
|
export interface DashboardScoringEvent {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue