brackt/app/models/scoring-event.ts
Chris Parsons 4bffa40606
Fix oxlint warnings: no-shadow, consistent-function-scoping, no-non-null-assertion, and others (#196)
* Fix no-shadow and consistent-function-scoping lint violations

Resolves all 11 no-shadow and 16 consistent-function-scoping oxlint
warnings and promotes both rules to errors in .oxlintrc.json.

no-shadow: renamed Drizzle callback params (sports→s, matches→m,
seasons→s) to avoid shadowing outer imports; removed shadowed
destructures (eq, inArray) from where callbacks; renamed inner
template→bracketTemplate, prev→currentTimers, season→ss, name→teamName
(with name: teamName fix to preserve semantics).

consistent-function-scoping: moved formatDate, getRankBadge,
getMovementIndicator, getPositionBadge, getStatusBadge, toDateStr,
elo (×2), weightedPick, sortByMatchNumber (×2) to module scope;
moved formatTime (×2), isValidLeagueName, getDraftTimes,
makeSeasonQueues to file scope in test files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix no-non-null-assertion lint violations and promote to error

Eliminates all 208 no-non-null-assertion warnings across 38 files.
Promotes typescript/no-non-null-assertion from warn to error in
.oxlintrc.json.

Fix patterns applied:
- Map.get(key)! after .has() check → extract with get() + null guard
- Map.get(key)! on pre-populated count maps → ?? 0 default
- .set(id, map.get(id)! + 1) increment → ?? 0 before adding
- participant1Id!/participant2Id! on DB matches → ?? "" fallback
- array.find()! in tests → guard + throw or expect().toBeDefined()
- bracketTemplateCache.get(id)! → null guard extract
- Various nullable field accesses → optional chain or ?? default

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix prefer-add-event-listener, no-unassigned-import, require-module-specifiers

Resolves all 9 remaining non-console lint warnings and promotes all
three rules to errors in .oxlintrc.json.

- prefer-add-event-listener: converted onchange/onclick/onload
  assignments to addEventListener in useDraftNotifications.ts and
  admin.data-sync.tsx; stored changeHandler ref for proper cleanup
  with removeEventListener
- no-unassigned-import: configured rule with allow list for legitimate
  side-effect imports (*.css, @testing-library/jest-dom,
  @testing-library/cypress/add-commands)
- require-module-specifiers: removed redundant `export {}` from
  cypress/support/e2e.ts (file already has an import)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix TypeScript errors from no-non-null-assertion fixes

Two fixes introduced by the non-null assertion cleanup produced type
errors:

- scoring-event.ts: `?? ""` was wrong type for a participant object map;
  restructured to explicit null guards so TypeScript can narrow correctly
- standings-sync/index.ts: `?? null` after name-match lookup lost the
  truthy guarantee, causing TS18047 on the write-back block; added
  `participant &&` guard before accessing its properties

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Add npm run typecheck as Stop hook in Claude settings

Runs a full project typecheck at the end of each Claude turn so type
errors surface as feedback before the next message.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 10:59:51 -07:00

681 lines
22 KiB
TypeScript

import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq, and, desc, asc, gte, lte, or, inArray, isNotNull } 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;
// 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,
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: {
participant: 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.participantId);
wasQPProcessed = results.some(
(r) => r.qualifyingPointsAwarded && parseFloat(r.qualifyingPointsAwarded) > 0
);
}
await db.transaction(async (tx) => {
if (event.eventType === "playoff_game") {
await tx
.delete(schema.participantResults)
.where(eq(schema.participantResults.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 }>,
dateFrom: Date,
dateTo: Date,
providedDb?: ReturnType<typeof database>
): Promise<UpcomingParticipantEvent[]> {
if (draftedParticipants.length === 0) return [];
const db = providedDb || database();
const dateFromStr = dateFrom.toISOString().split("T")[0];
const dateToStr = dateTo.toISOString().split("T")[0];
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,
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),
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));
// 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);
}
}
}
for (const [eventId, event] of eventMap) {
event.relevantParticipants = Array.from(participantsByEvent.get(eventId)?.values() ?? []);
const earliest = earliestTimeByEvent.get(eventId);
event.earliestGameTime = earliest ? earliest.toISOString() : null;
// Build matchLabel from game keys
const gameKeys = gameKeysByEvent.get(eventId) ?? new Set<string>();
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];
event.matchLabel = round === event.name ? null : round;
}
}
}
return Array.from(eventMap.values());
}
export interface DashboardScoringEvent {
id: string;
name: string;
eventDate: 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 distinct event IDs where the event date OR any game's
// scheduledAt falls within the requested dates.
const rows = await db
.selectDistinct({ id: schema.scoringEvents.id })
.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 [];
const eventIds = rows.map((r) => r.id);
// 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 events.map((e) => ({
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,
}));
}
/**
* 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 }>,
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,
isComplete: false,
}));
return db.insert(schema.scoringEvents).values(rows).returning();
}