The junction table was a redundant second source of truth: syncTournamentResults already fans out by querying scoring_events.tournamentId directly, so the table only served the admin UI and could drift out of sync (causing orphaned events). - Drop sports_season_tournaments table and all link/unlink admin actions - Add getTournamentsBySportsSeason / getSportsSeasonsByTournament helpers that derive the same information from scoring_events.tournamentId - Add "Add Existing Tournament" dropdown to the events admin page (qualifying_points seasons only) — selecting a tournament creates the scoring event in one step - Fix clone: scoring events now carry tournamentId, fixing a latent check-constraint violation when cloning qualifying_points seasons - Tournament admin page "Linked Sports Seasons" is now a read-only derived view Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
799 lines
27 KiB
TypeScript
799 lines
27 KiB
TypeScript
import { database } from "~/database/context";
|
|
import * as schema from "~/database/schema";
|
|
import { eq, and, desc, asc, gte, lte, or, inArray, isNotNull, sql } from "drizzle-orm";
|
|
import type { BracketRegion } from "~/lib/bracket-templates";
|
|
import { recalculateAffectedLeagues } from "./scoring-calculator";
|
|
import { recalculateParticipantQP } from "./qualifying-points";
|
|
|
|
export type EventType = "playoff_game" | "major_tournament" | "final_standings" | "schedule_event";
|
|
|
|
export function getEventTypeLabel(eventType: string): string {
|
|
switch (eventType) {
|
|
case "playoff_game": return "Bracket";
|
|
case "major_tournament": return "Major Tournament";
|
|
case "final_standings": return "Final Standings";
|
|
case "schedule_event": return "Non-Scoring";
|
|
default: return eventType;
|
|
}
|
|
}
|
|
|
|
export interface CreateScoringEventData {
|
|
sportsSeasonId: string;
|
|
name: string;
|
|
eventDate?: Date;
|
|
eventStartsAt?: Date;
|
|
eventType: EventType;
|
|
isQualifyingEvent?: boolean;
|
|
/** Required when isQualifyingEvent=true (DB check constraint). */
|
|
tournamentId?: string;
|
|
// Template system fields (Phase 2.6)
|
|
bracketTemplateId?: string;
|
|
scoringStartsAtRound?: string;
|
|
}
|
|
|
|
export interface UpdateScoringEventData {
|
|
name?: string;
|
|
eventDate?: Date | null;
|
|
eventStartsAt?: Date | null;
|
|
isComplete?: boolean;
|
|
completedAt?: Date;
|
|
bracketTemplateId?: string;
|
|
scoringStartsAtRound?: string;
|
|
/** Per-event region config for NCAA-style brackets (overrides template defaults) */
|
|
bracketRegionConfig?: BracketRegion[] | null;
|
|
}
|
|
|
|
/**
|
|
* Create a new scoring event
|
|
*/
|
|
export async function createScoringEvent(
|
|
data: CreateScoringEventData,
|
|
providedDb?: ReturnType<typeof database>
|
|
) {
|
|
const db = providedDb || database();
|
|
|
|
const [event] = await db
|
|
.insert(schema.scoringEvents)
|
|
.values({
|
|
sportsSeasonId: data.sportsSeasonId,
|
|
name: data.name,
|
|
eventDate: data.eventDate ? data.eventDate.toISOString().split('T')[0] : undefined,
|
|
eventStartsAt: data.eventStartsAt,
|
|
eventType: data.eventType,
|
|
isQualifyingEvent: data.isQualifyingEvent || false,
|
|
tournamentId: data.tournamentId,
|
|
bracketTemplateId: data.bracketTemplateId,
|
|
scoringStartsAtRound: data.scoringStartsAtRound,
|
|
isComplete: false,
|
|
})
|
|
.returning();
|
|
|
|
return event;
|
|
}
|
|
|
|
/**
|
|
* Get a scoring event by ID
|
|
*/
|
|
export async function getScoringEventById(
|
|
eventId: string,
|
|
providedDb?: ReturnType<typeof database>
|
|
) {
|
|
const db = providedDb || database();
|
|
|
|
return await db.query.scoringEvents.findFirst({
|
|
where: eq(schema.scoringEvents.id, eventId),
|
|
with: {
|
|
sportsSeason: {
|
|
with: {
|
|
sport: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get all scoring events for a sports season
|
|
*/
|
|
export async function getScoringEventsForSportsSeason(
|
|
sportsSeasonId: string,
|
|
providedDb?: ReturnType<typeof database>
|
|
) {
|
|
const db = providedDb || database();
|
|
|
|
return await db.query.scoringEvents.findMany({
|
|
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
|
orderBy: [desc(schema.scoringEvents.eventDate), desc(schema.scoringEvents.eventStartsAt), desc(schema.scoringEvents.createdAt)],
|
|
with: {
|
|
eventResults: {
|
|
with: {
|
|
seasonParticipant: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get incomplete scoring events for a sports season
|
|
*/
|
|
export async function getIncompleteScoringEvents(
|
|
sportsSeasonId: string,
|
|
providedDb?: ReturnType<typeof database>
|
|
) {
|
|
const db = providedDb || database();
|
|
|
|
return await db.query.scoringEvents.findMany({
|
|
where: and(
|
|
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
|
eq(schema.scoringEvents.isComplete, false)
|
|
),
|
|
orderBy: [desc(schema.scoringEvents.eventDate), desc(schema.scoringEvents.createdAt)],
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get qualifying events for a sports season
|
|
*/
|
|
export async function getQualifyingEvents(
|
|
sportsSeasonId: string,
|
|
providedDb?: ReturnType<typeof database>
|
|
) {
|
|
const db = providedDb || database();
|
|
|
|
return await db.query.scoringEvents.findMany({
|
|
where: and(
|
|
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
|
eq(schema.scoringEvents.isQualifyingEvent, true)
|
|
),
|
|
orderBy: [desc(schema.scoringEvents.eventDate), desc(schema.scoringEvents.createdAt)],
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Update a scoring event
|
|
*/
|
|
export async function updateScoringEvent(
|
|
eventId: string,
|
|
data: UpdateScoringEventData,
|
|
providedDb?: ReturnType<typeof database>
|
|
) {
|
|
const db = providedDb || database();
|
|
|
|
const updateData: Partial<typeof schema.scoringEvents.$inferInsert> = { updatedAt: new Date() };
|
|
|
|
if (data.name !== undefined) updateData.name = data.name;
|
|
if (data.eventDate !== undefined) {
|
|
updateData.eventDate = data.eventDate ? data.eventDate.toISOString().split('T')[0] : null;
|
|
}
|
|
if (data.eventStartsAt !== undefined) updateData.eventStartsAt = data.eventStartsAt;
|
|
if (data.isComplete !== undefined) updateData.isComplete = data.isComplete;
|
|
if (data.completedAt !== undefined) updateData.completedAt = data.completedAt;
|
|
if (data.bracketTemplateId !== undefined) updateData.bracketTemplateId = data.bracketTemplateId;
|
|
if (data.scoringStartsAtRound !== undefined) updateData.scoringStartsAtRound = data.scoringStartsAtRound;
|
|
if (data.bracketRegionConfig !== undefined) updateData.bracketRegionConfig = data.bracketRegionConfig;
|
|
|
|
const [updated] = await db
|
|
.update(schema.scoringEvents)
|
|
.set(updateData)
|
|
.where(eq(schema.scoringEvents.id, eventId))
|
|
.returning();
|
|
|
|
return updated;
|
|
}
|
|
|
|
/**
|
|
* Mark a scoring event as complete
|
|
*/
|
|
export async function completeScoringEvent(
|
|
eventId: string,
|
|
providedDb?: ReturnType<typeof database>
|
|
) {
|
|
const db = providedDb || database();
|
|
|
|
return await updateScoringEvent(
|
|
eventId,
|
|
{
|
|
isComplete: true,
|
|
completedAt: new Date(),
|
|
},
|
|
db
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Delete a scoring event.
|
|
* For bracket events (playoff_game), also clears all participant results for the
|
|
* sports season (placements are stored there with no FK back to the event) and
|
|
* recalculates league standings.
|
|
*/
|
|
export async function deleteScoringEvent(
|
|
eventId: string,
|
|
providedDb?: ReturnType<typeof database>
|
|
) {
|
|
const db = providedDb || database();
|
|
|
|
const event = await db.query.scoringEvents.findFirst({
|
|
where: eq(schema.scoringEvents.id, eventId),
|
|
});
|
|
|
|
if (!event) return;
|
|
|
|
// For qualifying events: capture affected participant IDs before the cascade deletes
|
|
// eventResults (which is how we know who had QP awarded from this event).
|
|
let affectedParticipantIds: string[] = [];
|
|
let wasQPProcessed = false;
|
|
if (event.isQualifyingEvent) {
|
|
const results = await db.query.eventResults.findMany({
|
|
where: eq(schema.eventResults.scoringEventId, eventId),
|
|
});
|
|
affectedParticipantIds = results.map((r) => r.seasonParticipantId);
|
|
wasQPProcessed = results.some(
|
|
(r) => r.qualifyingPointsAwarded && parseFloat(r.qualifyingPointsAwarded) > 0
|
|
);
|
|
}
|
|
|
|
await db.transaction(async (tx) => {
|
|
if (event.eventType === "playoff_game") {
|
|
await tx
|
|
.delete(schema.seasonParticipantResults)
|
|
.where(eq(schema.seasonParticipantResults.sportsSeasonId, event.sportsSeasonId));
|
|
}
|
|
// Cascades: playoffMatches, eventResults, tournamentGroups/Members
|
|
await tx.delete(schema.scoringEvents).where(eq(schema.scoringEvents.id, eventId));
|
|
});
|
|
|
|
if (event.eventType === "playoff_game") {
|
|
await recalculateAffectedLeagues(event.sportsSeasonId, db);
|
|
}
|
|
|
|
if (event.isQualifyingEvent && affectedParticipantIds.length > 0) {
|
|
// eventResults have been cascade-deleted; recalculate QP totals from remaining events
|
|
for (const participantId of affectedParticipantIds) {
|
|
await recalculateParticipantQP(participantId, event.sportsSeasonId, db);
|
|
}
|
|
|
|
// Decrement majorsCompleted if this event had already been processed
|
|
if (wasQPProcessed) {
|
|
const sportsSeason = await db.query.sportsSeasons.findFirst({
|
|
where: eq(schema.sportsSeasons.id, event.sportsSeasonId),
|
|
});
|
|
if (sportsSeason && (sportsSeason.majorsCompleted ?? 0) > 0) {
|
|
await db
|
|
.update(schema.sportsSeasons)
|
|
.set({ majorsCompleted: (sportsSeason.majorsCompleted ?? 1) - 1, updatedAt: new Date() })
|
|
.where(eq(schema.sportsSeasons.id, event.sportsSeasonId));
|
|
}
|
|
}
|
|
|
|
await recalculateAffectedLeagues(event.sportsSeasonId, db);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if all events for a sports season are complete
|
|
*/
|
|
export async function areAllEventsComplete(
|
|
sportsSeasonId: string,
|
|
providedDb?: ReturnType<typeof database>
|
|
): Promise<boolean> {
|
|
const db = providedDb || database();
|
|
|
|
const incompleteEvents = await getIncompleteScoringEvents(sportsSeasonId, db);
|
|
return incompleteEvents.length === 0;
|
|
}
|
|
|
|
/**
|
|
* Get upcoming (not yet complete) scoring events for a sports season,
|
|
* ordered by eventDate ascending (soonest first).
|
|
* Events without a date are included last.
|
|
*/
|
|
export async function getUpcomingScoringEvents(
|
|
sportsSeasonId: string,
|
|
limit = 5,
|
|
providedDb?: ReturnType<typeof database>
|
|
) {
|
|
const db = providedDb || database();
|
|
|
|
return db.query.scoringEvents.findMany({
|
|
where: and(
|
|
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
|
eq(schema.scoringEvents.isComplete, false)
|
|
),
|
|
orderBy: [asc(schema.scoringEvents.eventDate), asc(schema.scoringEvents.eventStartsAt), asc(schema.scoringEvents.createdAt)],
|
|
limit,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get the single next upcoming event for a sports season (for card display).
|
|
*/
|
|
export async function getNextScoringEvent(
|
|
sportsSeasonId: string,
|
|
providedDb?: ReturnType<typeof database>
|
|
) {
|
|
const upcoming = await getUpcomingScoringEvents(sportsSeasonId, 1, providedDb);
|
|
return upcoming[0] || null;
|
|
}
|
|
|
|
/**
|
|
* Get recently completed scoring events for a sports season,
|
|
* ordered by completedAt descending (most recent first).
|
|
*/
|
|
export async function getRecentCompletedEvents(
|
|
sportsSeasonId: string,
|
|
limit = 3,
|
|
providedDb?: ReturnType<typeof database>
|
|
) {
|
|
const db = providedDb || database();
|
|
|
|
return db.query.scoringEvents.findMany({
|
|
where: and(
|
|
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
|
eq(schema.scoringEvents.isComplete, true)
|
|
),
|
|
orderBy: [desc(schema.scoringEvents.completedAt), desc(schema.scoringEvents.eventDate)],
|
|
limit,
|
|
});
|
|
}
|
|
|
|
// Scoring patterns where each participant competes in every event.
|
|
// Valid scoringPatternEnum values: playoff_bracket | season_standings | qualifying_points
|
|
const ALL_COMPETE_PATTERNS = new Set(["season_standings", "qualifying_points"]);
|
|
|
|
export interface UpcomingParticipantEvent {
|
|
id: string;
|
|
name: string;
|
|
eventDate: string | null;
|
|
/**
|
|
* Earliest scheduledAt timestamp (ISO string) across individual playoff match
|
|
* games for this event — bracket sports only. Used when eventDate is null.
|
|
*/
|
|
earliestGameTime: string | null;
|
|
/**
|
|
* e.g. "Semifinals Game #1" — derived from playoffMatches.round and
|
|
* playoffMatchGames.gameNumber. null when unavailable or redundant with event name.
|
|
*/
|
|
matchLabel: string | null;
|
|
eventType: string;
|
|
sportsSeasonId: string;
|
|
/** Only the current user's drafted participants involved in this event */
|
|
relevantParticipants: Array<{ id: string; name: string }>;
|
|
}
|
|
|
|
/**
|
|
* Get upcoming scoring events (within the given date window) that involve
|
|
* at least one of the given drafted participants.
|
|
*
|
|
* - Bracket sports (playoff_bracket, ucl_bracket): only events where a
|
|
* participant appears in a playoff match.
|
|
* - All-compete sports (season_standings, qualifying_points, etc.): every
|
|
* upcoming event; all draftedParticipants are attached.
|
|
*
|
|
* Events without a date or that are already complete are excluded.
|
|
*/
|
|
export async function getUpcomingEventsForDraftedParticipants(
|
|
sportsSeasonId: string,
|
|
scoringPattern: string,
|
|
draftedParticipants: Array<{ id: string; name: string }>,
|
|
dateFromStr: string,
|
|
dateToStr: string,
|
|
providedDb?: ReturnType<typeof database>
|
|
): Promise<UpcomingParticipantEvent[]> {
|
|
if (draftedParticipants.length === 0) return [];
|
|
|
|
const db = providedDb || database();
|
|
const draftedIds = draftedParticipants.map((p) => p.id);
|
|
const draftedMap = new Map(draftedParticipants.map((p) => [p.id, p]));
|
|
|
|
if (ALL_COMPETE_PATTERNS.has(scoringPattern)) {
|
|
const events = await db.query.scoringEvents.findMany({
|
|
where: and(
|
|
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
|
eq(schema.scoringEvents.isComplete, false),
|
|
isNotNull(schema.scoringEvents.eventDate),
|
|
gte(schema.scoringEvents.eventDate, dateFromStr),
|
|
lte(schema.scoringEvents.eventDate, dateToStr)
|
|
),
|
|
orderBy: [asc(schema.scoringEvents.eventDate)],
|
|
});
|
|
|
|
return events.map((e) => ({
|
|
id: e.id,
|
|
name: e.name,
|
|
eventDate: e.eventDate,
|
|
earliestGameTime: e.eventStartsAt ? e.eventStartsAt.toISOString() : null,
|
|
matchLabel: null,
|
|
eventType: e.eventType,
|
|
sportsSeasonId: e.sportsSeasonId,
|
|
relevantParticipants: draftedParticipants,
|
|
}));
|
|
}
|
|
|
|
// Bracket sports: join through playoff_matches → playoff_match_games to find relevant events.
|
|
// scoringEvent.eventDate may be null — admins often set dates on individual games
|
|
// (playoffMatchGames.scheduledAt) rather than on the event record itself.
|
|
// We include an event if EITHER its eventDate OR any of its game scheduledAt timestamps
|
|
// falls within the window.
|
|
//
|
|
// Use start-of-day for the timestamp lower bound so games that already happened
|
|
// earlier today are still included.
|
|
const dateFromTimestamp = new Date(dateFromStr + "T00:00:00.000Z");
|
|
const dateToTimestamp = new Date(dateToStr + "T23:59:59.999Z");
|
|
|
|
const rows = await db
|
|
.selectDistinct({
|
|
id: schema.scoringEvents.id,
|
|
name: schema.scoringEvents.name,
|
|
eventDate: schema.scoringEvents.eventDate,
|
|
eventType: schema.scoringEvents.eventType,
|
|
sportsSeasonId: schema.scoringEvents.sportsSeasonId,
|
|
playoffMatchId: schema.playoffMatches.id,
|
|
participant1Id: schema.playoffMatches.participant1Id,
|
|
participant2Id: schema.playoffMatches.participant2Id,
|
|
round: schema.playoffMatches.round,
|
|
gameNumber: schema.playoffMatchGames.gameNumber,
|
|
scheduledAt: schema.playoffMatchGames.scheduledAt,
|
|
})
|
|
.from(schema.scoringEvents)
|
|
.innerJoin(
|
|
schema.playoffMatches,
|
|
eq(schema.playoffMatches.scoringEventId, schema.scoringEvents.id)
|
|
)
|
|
.leftJoin(
|
|
schema.playoffMatchGames,
|
|
eq(schema.playoffMatchGames.playoffMatchId, schema.playoffMatches.id)
|
|
)
|
|
.where(
|
|
and(
|
|
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
|
eq(schema.scoringEvents.isComplete, false),
|
|
eq(schema.playoffMatches.isComplete, false),
|
|
or(
|
|
inArray(schema.playoffMatches.participant1Id, draftedIds),
|
|
// participant2Id is nullable UUID — same underlying type as participant1Id;
|
|
// the notNull difference only exists at the TypeScript layer
|
|
// oxlint-disable-next-line typescript/no-explicit-any -- participant2Id is nullable UUID; notNull difference only exists at the TypeScript layer
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
inArray(schema.playoffMatches.participant2Id as any, draftedIds)
|
|
),
|
|
or(
|
|
// scoringEvent has an explicit date within the window
|
|
and(
|
|
isNotNull(schema.scoringEvents.eventDate),
|
|
gte(schema.scoringEvents.eventDate, dateFromStr),
|
|
lte(schema.scoringEvents.eventDate, dateToStr)
|
|
),
|
|
// Or one of the individual games is scheduled within the window
|
|
and(
|
|
isNotNull(schema.playoffMatchGames.scheduledAt),
|
|
gte(schema.playoffMatchGames.scheduledAt, dateFromTimestamp),
|
|
lte(schema.playoffMatchGames.scheduledAt, dateToTimestamp)
|
|
)
|
|
)
|
|
)
|
|
)
|
|
.orderBy(asc(schema.scoringEvents.eventDate));
|
|
|
|
// 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<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) {
|
|
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<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 isSeriesByEntry = new Map<string, boolean>();
|
|
|
|
for (const row of rows) {
|
|
const isSeries = (maxGameNumberByEvent.get(row.id) ?? 1) > 1;
|
|
const entryKey =
|
|
isSeries && row.gameNumber !== null
|
|
? `${row.id}|${row.playoffMatchId}|${row.gameNumber}`
|
|
: `${row.id}|${row.playoffMatchId}`;
|
|
|
|
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());
|
|
isSeriesByEntry.set(entryKey, isSeries);
|
|
}
|
|
|
|
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;
|
|
|
|
const isSeries = isSeriesByEntry.get(entryKey) ?? false;
|
|
const gameKeys = gameKeysByEntry.get(entryKey) ?? new Set<string>();
|
|
|
|
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(entryMap.values());
|
|
}
|
|
|
|
export interface DashboardScoringEvent {
|
|
id: string;
|
|
name: string;
|
|
eventDate: string | null;
|
|
/** Resolved date for tab bucketing: eventDate if set, else earliest matched game date. */
|
|
displayDate: string | null;
|
|
eventStartsAt: Date | null;
|
|
eventType: string;
|
|
isComplete: boolean;
|
|
completedAt: Date | null;
|
|
sportsSeasonId: string;
|
|
sportsSeasonName: string;
|
|
sportName: string;
|
|
sportSlug: string | null;
|
|
}
|
|
|
|
/**
|
|
* Get all scoring events (excluding schedule_event type) for the given dates,
|
|
* joined with their sports season and sport info. Used for the admin dashboard
|
|
* "Games to Score" widget.
|
|
*
|
|
* An event is included if EITHER its own eventDate falls on one of the given
|
|
* dates, OR any of its playoffMatchGames has a scheduledAt timestamp on one of
|
|
* those dates. This ensures bracket events where the admin set dates on
|
|
* individual games (rather than the event itself) are still surfaced.
|
|
*/
|
|
export async function getEventsForDates(
|
|
dates: string[],
|
|
providedDb?: ReturnType<typeof database>
|
|
): Promise<DashboardScoringEvent[]> {
|
|
if (dates.length === 0) return [];
|
|
|
|
const db = providedDb || database();
|
|
|
|
// Build timestamp bounds covering the full span of the requested dates so we
|
|
// can range-check the playoffMatchGames.scheduledAt timestamp column.
|
|
const sortedDates = [...dates].toSorted();
|
|
const dateFromTimestamp = new Date(sortedDates[0] + "T00:00:00.000Z");
|
|
const dateToTimestamp = new Date(sortedDates[sortedDates.length - 1] + "T23:59:59.999Z");
|
|
|
|
// Step 1: collect event IDs where the event date OR any game's scheduledAt
|
|
// falls within the requested dates. Also capture the matched date so we can
|
|
// bucket events that have no eventDate (bracket events) into the right tab.
|
|
const rows = await db
|
|
.select({
|
|
id: schema.scoringEvents.id,
|
|
eventDate: schema.scoringEvents.eventDate,
|
|
gameDate: sql<string | null>`DATE(${schema.playoffMatchGames.scheduledAt} AT TIME ZONE 'UTC')`,
|
|
})
|
|
.from(schema.scoringEvents)
|
|
.leftJoin(
|
|
schema.playoffMatches,
|
|
eq(schema.playoffMatches.scoringEventId, schema.scoringEvents.id)
|
|
)
|
|
.leftJoin(
|
|
schema.playoffMatchGames,
|
|
eq(schema.playoffMatchGames.playoffMatchId, schema.playoffMatches.id)
|
|
)
|
|
.where(
|
|
and(
|
|
inArray(schema.scoringEvents.eventType, [
|
|
"playoff_game",
|
|
"major_tournament",
|
|
"final_standings",
|
|
]),
|
|
or(
|
|
inArray(schema.scoringEvents.eventDate, dates),
|
|
and(
|
|
isNotNull(schema.playoffMatchGames.scheduledAt),
|
|
gte(schema.playoffMatchGames.scheduledAt, dateFromTimestamp),
|
|
lte(schema.playoffMatchGames.scheduledAt, dateToTimestamp)
|
|
)
|
|
)
|
|
)
|
|
);
|
|
|
|
if (rows.length === 0) return [];
|
|
|
|
// Collect ALL dates each event qualifies for. For bracket events, the
|
|
// game's scheduledAt date (gameDate) takes priority over the event-level
|
|
// eventDate — the event-level date might be set to the overall round date
|
|
// (e.g. "April 5") while an individual game is actually scheduled today.
|
|
// Using a Set per event also de-duplicates naturally when multiple matches
|
|
// in the same bracket have games on the same day.
|
|
const eventDatesMap = new Map<string, Set<string>>();
|
|
for (const row of rows) {
|
|
if (!eventDatesMap.has(row.id)) {
|
|
eventDatesMap.set(row.id, new Set());
|
|
}
|
|
const dateSet = eventDatesMap.get(row.id);
|
|
if (!dateSet) continue;
|
|
if (row.eventDate && dates.includes(row.eventDate)) {
|
|
dateSet.add(row.eventDate);
|
|
}
|
|
if (row.gameDate && dates.includes(row.gameDate)) {
|
|
dateSet.add(row.gameDate);
|
|
}
|
|
// Fallback: row passed the WHERE clause so at least one date is relevant;
|
|
// if neither mapped to a requested date (shouldn't happen), keep something.
|
|
if (dateSet.size === 0) {
|
|
const fallback = row.gameDate ?? row.eventDate ?? null;
|
|
if (fallback) dateSet.add(fallback);
|
|
}
|
|
}
|
|
const eventIds = [...eventDatesMap.keys()];
|
|
|
|
// Step 2: fetch the matched events with sport season + sport info.
|
|
const events = await db.query.scoringEvents.findMany({
|
|
where: inArray(schema.scoringEvents.id, eventIds),
|
|
orderBy: [
|
|
asc(schema.scoringEvents.eventDate),
|
|
asc(schema.scoringEvents.eventStartsAt),
|
|
asc(schema.scoringEvents.createdAt),
|
|
],
|
|
with: {
|
|
sportsSeason: {
|
|
with: {
|
|
sport: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
// Return one entry per (event, date) pair so that an event with games on
|
|
// both today and tomorrow appears in both tabs of the dashboard widget.
|
|
return events.flatMap((e) => {
|
|
const matchingDates = [...(eventDatesMap.get(e.id) ?? [])].toSorted();
|
|
const base = {
|
|
id: e.id,
|
|
name: e.name,
|
|
eventDate: e.eventDate,
|
|
eventStartsAt: e.eventStartsAt,
|
|
eventType: e.eventType,
|
|
isComplete: e.isComplete,
|
|
completedAt: e.completedAt,
|
|
sportsSeasonId: e.sportsSeasonId,
|
|
sportsSeasonName: e.sportsSeason.name,
|
|
sportName: e.sportsSeason.sport.name,
|
|
sportSlug: e.sportsSeason.sport.slug,
|
|
};
|
|
if (matchingDates.length === 0) {
|
|
return [{ ...base, displayDate: e.eventDate ?? null }];
|
|
}
|
|
return matchingDates.map((date) => ({ ...base, displayDate: date }));
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Bulk create scoring events for a sports season.
|
|
* Returns created events in order.
|
|
*/
|
|
export async function bulkCreateScoringEvents(
|
|
sportsSeasonId: string,
|
|
events: Array<{ name: string; eventDate?: Date; eventStartsAt?: Date; eventType: EventType; isQualifyingEvent?: boolean; tournamentId?: string }>,
|
|
providedDb?: ReturnType<typeof database>
|
|
) {
|
|
const db = providedDb || database();
|
|
|
|
if (events.length === 0) return [];
|
|
|
|
const rows = events.map((e) => ({
|
|
sportsSeasonId,
|
|
name: e.name,
|
|
eventDate: e.eventDate ? e.eventDate.toISOString().split("T")[0] : undefined,
|
|
eventStartsAt: e.eventStartsAt,
|
|
eventType: e.eventType,
|
|
isQualifyingEvent: e.isQualifyingEvent ?? false,
|
|
tournamentId: e.tournamentId,
|
|
isComplete: false,
|
|
}));
|
|
|
|
return db.insert(schema.scoringEvents).values(rows).returning();
|
|
}
|
|
|
|
export async function getTournamentsBySportsSeason(sportsSeasonId: string) {
|
|
const db = database();
|
|
const rows = await db.query.scoringEvents.findMany({
|
|
where: and(
|
|
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
|
isNotNull(schema.scoringEvents.tournamentId)
|
|
),
|
|
with: { tournament: true },
|
|
});
|
|
const seen = new Set<string>();
|
|
return rows.filter((r) => {
|
|
if (!r.tournamentId || seen.has(r.tournamentId)) return false;
|
|
seen.add(r.tournamentId);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
export async function getSportsSeasonsByTournament(tournamentId: string) {
|
|
const db = database();
|
|
const rows = await db.query.scoringEvents.findMany({
|
|
where: eq(schema.scoringEvents.tournamentId, tournamentId),
|
|
with: { sportsSeason: { with: { sport: true } } },
|
|
});
|
|
const seen = new Set<string>();
|
|
return rows.filter((r) => {
|
|
if (seen.has(r.sportsSeasonId)) return false;
|
|
seen.add(r.sportsSeasonId);
|
|
return true;
|
|
});
|
|
}
|