Compare commits
3 commits
main
...
claude/cha
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff9bc6717c | ||
|
|
e5728fd7ed | ||
|
|
f91f7dd7ad |
4 changed files with 212 additions and 41 deletions
|
|
@ -459,6 +459,129 @@ describe("getUpcomingEventsForDraftedParticipants — bracket sports", () => {
|
||||||
expect(result[0].matchLabel).toBeNull();
|
expect(result[0].matchLabel).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows just the P1 vs P2 matchup in matchLabel (round name dropped)", async () => {
|
||||||
|
const rows = [{
|
||||||
|
id: "e1", name: "IEM Cologne 2026", eventDate: "2026-06-19",
|
||||||
|
eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
|
||||||
|
playoffMatchId: "pm1",
|
||||||
|
participant1Id: "team-spirit", participant2Id: "g2-esports",
|
||||||
|
round: "Quarterfinals", gameNumber: 1, scheduledAt: null,
|
||||||
|
}];
|
||||||
|
mockDb.selectDistinct.mockReturnValueOnce(makeSelectChain(rows));
|
||||||
|
mockDb.query.seasonParticipants.findMany.mockResolvedValueOnce([
|
||||||
|
{ id: "team-spirit", name: "Team Spirit" },
|
||||||
|
{ id: "g2-esports", name: "G2 Esports" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await getUpcomingEventsForDraftedParticipants(
|
||||||
|
SPORTS_SEASON_ID, "playoff_bracket",
|
||||||
|
[makeParticipant("g2-esports", "G2 Esports")],
|
||||||
|
DATE_FROM, DATE_TO
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result[0].matchLabel).toBe("Team Spirit vs G2 Esports");
|
||||||
|
// relevantParticipants stays drafted-only — the opponent is only surfaced via matchLabel.
|
||||||
|
expect(result[0].relevantParticipants).toEqual([{ id: "g2-esports", name: "G2 Esports" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows just the matchup (no round prefix) when round name is redundant with event name", async () => {
|
||||||
|
const rows = [{
|
||||||
|
id: "e1", name: "Knockout Stage", eventDate: null,
|
||||||
|
eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
|
||||||
|
playoffMatchId: "pm1",
|
||||||
|
participant1Id: "barcelona", participant2Id: "sporting",
|
||||||
|
round: "Knockout Stage", gameNumber: 1, scheduledAt: null,
|
||||||
|
}];
|
||||||
|
mockDb.selectDistinct.mockReturnValueOnce(makeSelectChain(rows));
|
||||||
|
mockDb.query.seasonParticipants.findMany.mockResolvedValueOnce([
|
||||||
|
{ id: "barcelona", name: "Barcelona" },
|
||||||
|
{ id: "sporting", name: "Sporting" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await getUpcomingEventsForDraftedParticipants(
|
||||||
|
SPORTS_SEASON_ID, "playoff_bracket",
|
||||||
|
[makeParticipant("barcelona", "Barcelona")],
|
||||||
|
DATE_FROM, DATE_TO
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result[0].matchLabel).toBe("Barcelona vs Sporting");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows just the matchup in matchLabel for series games (round/game number dropped)", async () => {
|
||||||
|
const gameTime1 = new Date("2025-04-09T13:00:00.000Z");
|
||||||
|
const rows = [{
|
||||||
|
id: "e1", name: "UCL QF", eventDate: null,
|
||||||
|
eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
|
||||||
|
playoffMatchId: "pm1",
|
||||||
|
participant1Id: "barcelona", participant2Id: "sporting",
|
||||||
|
round: "Knockout Stage", gameNumber: 1, scheduledAt: gameTime1,
|
||||||
|
}];
|
||||||
|
mockDb.selectDistinct
|
||||||
|
.mockReturnValueOnce(makeSelectChain(rows))
|
||||||
|
.mockReturnValueOnce(makeMaxGameChain([
|
||||||
|
{ eventId: "e1", gameNumber: 1 },
|
||||||
|
{ eventId: "e1", gameNumber: 2 },
|
||||||
|
]));
|
||||||
|
mockDb.query.seasonParticipants.findMany.mockResolvedValueOnce([
|
||||||
|
{ id: "barcelona", name: "Barcelona" },
|
||||||
|
{ id: "sporting", name: "Sporting" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await getUpcomingEventsForDraftedParticipants(
|
||||||
|
SPORTS_SEASON_ID, "playoff_bracket",
|
||||||
|
[makeParticipant("barcelona", "Barcelona")],
|
||||||
|
DATE_FROM, DATE_TO
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result[0].matchLabel).toBe("Barcelona vs Sporting");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the matchup even when no games are scheduled yet (gameKeys empty)", async () => {
|
||||||
|
const rows = [{
|
||||||
|
id: "e1", name: "IEM Cologne 2026", eventDate: "2026-06-19",
|
||||||
|
eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
|
||||||
|
playoffMatchId: "pm1",
|
||||||
|
participant1Id: "team-spirit", participant2Id: "g2-esports",
|
||||||
|
round: "Quarterfinals", gameNumber: null, scheduledAt: null,
|
||||||
|
}];
|
||||||
|
mockDb.selectDistinct.mockReturnValueOnce(makeSelectChain(rows));
|
||||||
|
mockDb.query.seasonParticipants.findMany.mockResolvedValueOnce([
|
||||||
|
{ id: "team-spirit", name: "Team Spirit" },
|
||||||
|
{ id: "g2-esports", name: "G2 Esports" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await getUpcomingEventsForDraftedParticipants(
|
||||||
|
SPORTS_SEASON_ID, "playoff_bracket",
|
||||||
|
[makeParticipant("g2-esports", "G2 Esports")],
|
||||||
|
DATE_FROM, DATE_TO
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result[0].matchLabel).toBe("Team Spirit vs G2 Esports");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to '?' for the side whose participant name didn't resolve", async () => {
|
||||||
|
const rows = [{
|
||||||
|
id: "e1", name: "IEM Cologne 2026", eventDate: "2026-06-19",
|
||||||
|
eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
|
||||||
|
playoffMatchId: "pm1",
|
||||||
|
participant1Id: "team-spirit", participant2Id: "g2-esports",
|
||||||
|
round: "Quarterfinals", gameNumber: 1, scheduledAt: null,
|
||||||
|
}];
|
||||||
|
mockDb.selectDistinct.mockReturnValueOnce(makeSelectChain(rows));
|
||||||
|
// Only one of the two participant ids resolves (e.g. orphaned/deleted row).
|
||||||
|
mockDb.query.seasonParticipants.findMany.mockResolvedValueOnce([
|
||||||
|
{ id: "g2-esports", name: "G2 Esports" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await getUpcomingEventsForDraftedParticipants(
|
||||||
|
SPORTS_SEASON_ID, "playoff_bracket",
|
||||||
|
[makeParticipant("g2-esports", "G2 Esports")],
|
||||||
|
DATE_FROM, DATE_TO
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result[0].matchLabel).toBe("? vs G2 Esports");
|
||||||
|
});
|
||||||
|
|
||||||
it("shows game number when only game #2 is in the window (game #1 already played)", 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",
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { eq, and, desc, asc, gte, lte, or, inArray, isNotNull, sql } from "drizz
|
||||||
import type { BracketRegion } from "~/lib/bracket-templates";
|
import type { BracketRegion } from "~/lib/bracket-templates";
|
||||||
import { recalculateAffectedLeagues } from "./scoring-calculator";
|
import { recalculateAffectedLeagues } from "./scoring-calculator";
|
||||||
import { hasProcessedQualifyingPlacement, recalculateParticipantQP } from "./qualifying-points";
|
import { hasProcessedQualifyingPlacement, recalculateParticipantQP } from "./qualifying-points";
|
||||||
|
import { findParticipantNamesByIds } from "./season-participant";
|
||||||
|
|
||||||
export type EventType = "playoff_game" | "major_tournament" | "final_standings" | "schedule_event";
|
export type EventType = "playoff_game" | "major_tournament" | "final_standings" | "schedule_event";
|
||||||
|
|
||||||
|
|
@ -408,8 +409,11 @@ export interface UpcomingParticipantEvent {
|
||||||
*/
|
*/
|
||||||
earliestGameTime: string | null;
|
earliestGameTime: string | null;
|
||||||
/**
|
/**
|
||||||
* e.g. "Semifinals Game #1" — derived from playoffMatches.round and
|
* e.g. "Team Spirit vs G2 Esports" — the bracket matchup, derived from
|
||||||
* playoffMatchGames.gameNumber. null when unavailable or redundant with event name.
|
* playoffMatches.participant1Id/participant2Id (or group-stage match
|
||||||
|
* participants). Falls back to a round/game-number label (e.g. "Semifinals
|
||||||
|
* Game #1") when neither participant's name resolves. null when unavailable
|
||||||
|
* or redundant with event name.
|
||||||
*/
|
*/
|
||||||
matchLabel: string | null;
|
matchLabel: string | null;
|
||||||
eventType: string;
|
eventType: string;
|
||||||
|
|
@ -607,34 +611,50 @@ export async function getUpcomingEventsForDraftedParticipants(
|
||||||
)
|
)
|
||||||
.orderBy(asc(schema.scoringEvents.eventDate));
|
.orderBy(asc(schema.scoringEvents.eventDate));
|
||||||
|
|
||||||
// Determine max game number per event across ALL games (not date-filtered)
|
// Look up names for ALL participants in these matches (not just drafted
|
||||||
// so we can distinguish single-game matchups from multi-game series.
|
// ones) so matchLabel can show the "P1 vs P2" matchup, mirroring the
|
||||||
|
// group-stage matchLabel below. Run alongside the max-game-number lookup
|
||||||
|
// below since neither depends on the other's result.
|
||||||
|
const allMatchParticipantIds = [
|
||||||
|
...new Set(
|
||||||
|
rows.flatMap((r) => [r.participant1Id, r.participant2Id]).filter((id): id is string => !!id)
|
||||||
|
),
|
||||||
|
];
|
||||||
const eventIdsFromRows = [...new Set(rows.map((r) => r.id))];
|
const eventIdsFromRows = [...new Set(rows.map((r) => r.id))];
|
||||||
const maxGameNumberByEvent = new Map<string, number>();
|
|
||||||
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) {
|
const [matchParticipantNameById, maxGameNumberByEvent] = await Promise.all([
|
||||||
if (row.gameNumber !== null) {
|
findParticipantNamesByIds(db, allMatchParticipantIds),
|
||||||
const current = maxGameNumberByEvent.get(row.eventId) ?? 0;
|
(async () => {
|
||||||
maxGameNumberByEvent.set(row.eventId, Math.max(current, row.gameNumber));
|
// Determine max game number per event across ALL games (not date-filtered)
|
||||||
|
// so we can distinguish single-game matchups from multi-game series.
|
||||||
|
const map = new Map<string, number>();
|
||||||
|
if (eventIdsFromRows.length === 0) return map;
|
||||||
|
|
||||||
|
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 = map.get(row.eventId) ?? 0;
|
||||||
|
map.set(row.eventId, Math.max(current, row.gameNumber));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
return map;
|
||||||
}
|
})(),
|
||||||
|
]);
|
||||||
|
|
||||||
// Group rows into calendar entries.
|
// Group rows into calendar entries.
|
||||||
// Series events (max game number > 1) get one entry per game so each game
|
// Series events (max game number > 1) get one entry per game so each game
|
||||||
|
|
@ -644,6 +664,7 @@ export async function getUpcomingEventsForDraftedParticipants(
|
||||||
const gameKeysByEntry = new Map<string, Set<string>>();
|
const gameKeysByEntry = new Map<string, Set<string>>();
|
||||||
const earliestTimeByEntry = new Map<string, Date>();
|
const earliestTimeByEntry = new Map<string, Date>();
|
||||||
const isSeriesByEntry = new Map<string, boolean>();
|
const isSeriesByEntry = new Map<string, boolean>();
|
||||||
|
const matchupIdsByEntry = new Map<string, { p1Id: string | null; p2Id: string | null }>();
|
||||||
|
|
||||||
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;
|
||||||
|
|
@ -667,6 +688,7 @@ export async function getUpcomingEventsForDraftedParticipants(
|
||||||
participantsByEntry.set(entryKey, new Map());
|
participantsByEntry.set(entryKey, new Map());
|
||||||
gameKeysByEntry.set(entryKey, new Set());
|
gameKeysByEntry.set(entryKey, new Set());
|
||||||
isSeriesByEntry.set(entryKey, isSeries);
|
isSeriesByEntry.set(entryKey, isSeries);
|
||||||
|
matchupIdsByEntry.set(entryKey, { p1Id: row.participant1Id, p2Id: row.participant2Id });
|
||||||
}
|
}
|
||||||
|
|
||||||
const pMap = participantsByEntry.get(entryKey);
|
const pMap = participantsByEntry.get(entryKey);
|
||||||
|
|
@ -702,25 +724,38 @@ export async function getUpcomingEventsForDraftedParticipants(
|
||||||
const isSeries = isSeriesByEntry.get(entryKey) ?? false;
|
const isSeries = isSeriesByEntry.get(entryKey) ?? false;
|
||||||
const gameKeys = gameKeysByEntry.get(entryKey) ?? new Set<string>();
|
const gameKeys = gameKeysByEntry.get(entryKey) ?? new Set<string>();
|
||||||
|
|
||||||
|
// matchLabel shows the "P1 vs P2" matchup whenever either participant's
|
||||||
|
// name is known — the round/game-number info is intentionally NOT
|
||||||
|
// included (the event name + sport already give that context); it's
|
||||||
|
// only used as a last-resort fallback when no participant name resolved
|
||||||
|
// at all (e.g. a deleted/orphaned participant row).
|
||||||
|
const matchupIds = matchupIdsByEntry.get(entryKey);
|
||||||
|
const p1Name = matchupIds?.p1Id ? matchParticipantNameById.get(matchupIds.p1Id) : undefined;
|
||||||
|
const p2Name = matchupIds?.p2Id ? matchParticipantNameById.get(matchupIds.p2Id) : undefined;
|
||||||
|
const matchupLabel = p1Name || p2Name ? `${p1Name ?? "?"} vs ${p2Name ?? "?"}` : null;
|
||||||
|
|
||||||
|
let roundLabel: string | null = null;
|
||||||
if (isSeries) {
|
if (isSeries) {
|
||||||
if (gameKeys.size >= 1) {
|
if (gameKeys.size >= 1) {
|
||||||
const [round, gameNumberStr] = [...gameKeys][0].split("|");
|
const [round, gameNumberStr] = [...gameKeys][0].split("|");
|
||||||
event.matchLabel =
|
roundLabel =
|
||||||
round === event.name ? `Game #${gameNumberStr}` : `${round} Game #${gameNumberStr}`;
|
round === event.name ? `Game #${gameNumberStr}` : `${round} Game #${gameNumberStr}`;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Single-game: omit the game number, just show the round name.
|
// Single-game: omit the game number, just show the round name.
|
||||||
if (gameKeys.size === 1) {
|
if (gameKeys.size === 1) {
|
||||||
const [round] = [...gameKeys][0].split("|");
|
const [round] = [...gameKeys][0].split("|");
|
||||||
event.matchLabel = round === event.name ? null : round;
|
roundLabel = round === event.name ? null : round;
|
||||||
} else if (gameKeys.size > 1) {
|
} else if (gameKeys.size > 1) {
|
||||||
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];
|
||||||
event.matchLabel = round === event.name ? null : round;
|
roundLabel = round === event.name ? null : round;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
event.matchLabel = matchupLabel ?? roundLabel;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bracketResults = Array.from(entryMap.values());
|
const bracketResults = Array.from(entryMap.values());
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,28 @@ export async function findParticipantsBySportsSeasonId(sportsSeasonId: string):
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch-resolve participant names by id. Used wherever a set of season
|
||||||
|
* participant ids (e.g. from a bracket match or a stored array of ids) needs
|
||||||
|
* to be displayed by name without N+1 lookups.
|
||||||
|
*/
|
||||||
|
export async function findParticipantNamesByIds(
|
||||||
|
db: ReturnType<typeof database>,
|
||||||
|
ids: string[]
|
||||||
|
): Promise<Map<string, string>> {
|
||||||
|
const nameById = new Map<string, string>();
|
||||||
|
if (ids.length === 0) return nameById;
|
||||||
|
|
||||||
|
const rows = await db.query.seasonParticipants.findMany({
|
||||||
|
where: inArray(schema.seasonParticipants.id, ids),
|
||||||
|
columns: { id: true, name: true },
|
||||||
|
});
|
||||||
|
for (const row of rows) {
|
||||||
|
nameById.set(row.id, row.name);
|
||||||
|
}
|
||||||
|
return nameById;
|
||||||
|
}
|
||||||
|
|
||||||
export async function findParticipantsByExternalId(
|
export async function findParticipantsByExternalId(
|
||||||
externalId: string
|
externalId: string
|
||||||
): Promise<Participant[]> {
|
): Promise<Participant[]> {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { eq, inArray, desc, sql, and } from "drizzle-orm";
|
import { eq, inArray, desc, sql, and } from "drizzle-orm";
|
||||||
import { calculateBracketPoints, type ScoringRules } from "~/models/scoring-rules";
|
import { calculateBracketPoints, type ScoringRules } from "~/models/scoring-rules";
|
||||||
|
import { findParticipantNamesByIds } from "~/models/season-participant";
|
||||||
import { logger } from "~/lib/logger";
|
import { logger } from "~/lib/logger";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -227,17 +228,7 @@ export async function getRecentTeamScoreEvents(
|
||||||
const allParticipantIds = [
|
const allParticipantIds = [
|
||||||
...new Set(rows.flatMap((r) => r.participantIds ?? [])),
|
...new Set(rows.flatMap((r) => r.participantIds ?? [])),
|
||||||
];
|
];
|
||||||
|
const participantNameById = await findParticipantNamesByIds(db, allParticipantIds);
|
||||||
const participantNameById = new Map<string, string>();
|
|
||||||
if (allParticipantIds.length > 0) {
|
|
||||||
const participantRows = await db.query.seasonParticipants.findMany({
|
|
||||||
where: inArray(schema.seasonParticipants.id, allParticipantIds),
|
|
||||||
columns: { id: true, name: true },
|
|
||||||
});
|
|
||||||
for (const p of participantRows) {
|
|
||||||
participantNameById.set(p.id, p.name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue