Adds live standings sync and display for bracket-based sports (NBA/NHL), so league members can see W/L tables and which teams their opponents drafted during the regular season — not just after the playoff bracket is set. - New `regular_season_standings` table with upsert-on-conflict sync - Standings sync service with NHL (api-web.nhle.com) and NBA (ESPN) adapters, externalId write-back for future syncs, and unmatched-team resolution UI - `RegularSeasonStandings` component: flat (NBA) + division/wild-card (NHL) modes, playoff line, TeamOwnerBadge, projected Brackt points (EV), mobile horizontal scroll - Admin "Sync Standings" card + "Resolve Unmatched" UI on sports season page - Admin manual standings edit hatch at /admin/sports-seasons/:id/regular-standings - Show standings above bracket until matches exist; below once bracket is set - `normalize-team-name` utility extracted to shared lib Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
200 lines
6.5 KiB
TypeScript
200 lines
6.5 KiB
TypeScript
import { database } from "~/database/context";
|
|
import * as schema from "~/database/schema";
|
|
import { eq, and, max } from "drizzle-orm";
|
|
|
|
export interface UpsertRegularSeasonStandingData {
|
|
participantId: string;
|
|
sportsSeasonId: string;
|
|
wins: number;
|
|
losses: number;
|
|
otLosses?: number | null;
|
|
ties?: number | null;
|
|
winPct?: number | null;
|
|
gamesPlayed: number;
|
|
gamesBack?: number | null;
|
|
conference?: string | null;
|
|
division?: string | null;
|
|
conferenceRank?: number | null;
|
|
divisionRank?: number | null;
|
|
leagueRank?: number | null;
|
|
streak?: string | null;
|
|
lastTen?: string | null;
|
|
homeRecord?: string | null;
|
|
awayRecord?: string | null;
|
|
externalTeamId?: string | null;
|
|
syncedAt?: Date | null;
|
|
}
|
|
|
|
/**
|
|
* Bulk upsert regular season standings records.
|
|
* Uses onConflictDoUpdate on the (participantId, sportsSeasonId) unique index.
|
|
* Sets syncedAt = now() for auto-synced records; null means manually entered.
|
|
*/
|
|
export async function upsertRegularSeasonStandings(
|
|
records: UpsertRegularSeasonStandingData[],
|
|
providedDb?: ReturnType<typeof database>
|
|
) {
|
|
const db = providedDb || database();
|
|
|
|
if (records.length === 0) return [];
|
|
|
|
const now = new Date();
|
|
const values = records.map((r) => ({
|
|
participantId: r.participantId,
|
|
sportsSeasonId: r.sportsSeasonId,
|
|
wins: r.wins,
|
|
losses: r.losses,
|
|
otLosses: r.otLosses ?? null,
|
|
ties: r.ties ?? null,
|
|
winPct: r.winPct != null ? r.winPct.toString() : null,
|
|
gamesPlayed: r.gamesPlayed,
|
|
gamesBack: r.gamesBack != null ? r.gamesBack.toString() : null,
|
|
conference: r.conference ?? null,
|
|
division: r.division ?? null,
|
|
conferenceRank: r.conferenceRank ?? null,
|
|
divisionRank: r.divisionRank ?? null,
|
|
leagueRank: r.leagueRank ?? null,
|
|
streak: r.streak ?? null,
|
|
lastTen: r.lastTen ?? null,
|
|
homeRecord: r.homeRecord ?? null,
|
|
awayRecord: r.awayRecord ?? null,
|
|
externalTeamId: r.externalTeamId ?? null,
|
|
syncedAt: r.syncedAt !== undefined ? r.syncedAt : now,
|
|
updatedAt: now,
|
|
}));
|
|
|
|
return await db.transaction(async (tx) => {
|
|
return tx
|
|
.insert(schema.regularSeasonStandings)
|
|
.values(values)
|
|
.onConflictDoUpdate({
|
|
target: [
|
|
schema.regularSeasonStandings.participantId,
|
|
schema.regularSeasonStandings.sportsSeasonId,
|
|
],
|
|
set: {
|
|
wins: schema.regularSeasonStandings.wins,
|
|
losses: schema.regularSeasonStandings.losses,
|
|
otLosses: schema.regularSeasonStandings.otLosses,
|
|
ties: schema.regularSeasonStandings.ties,
|
|
winPct: schema.regularSeasonStandings.winPct,
|
|
gamesPlayed: schema.regularSeasonStandings.gamesPlayed,
|
|
gamesBack: schema.regularSeasonStandings.gamesBack,
|
|
conference: schema.regularSeasonStandings.conference,
|
|
division: schema.regularSeasonStandings.division,
|
|
conferenceRank: schema.regularSeasonStandings.conferenceRank,
|
|
divisionRank: schema.regularSeasonStandings.divisionRank,
|
|
leagueRank: schema.regularSeasonStandings.leagueRank,
|
|
streak: schema.regularSeasonStandings.streak,
|
|
lastTen: schema.regularSeasonStandings.lastTen,
|
|
homeRecord: schema.regularSeasonStandings.homeRecord,
|
|
awayRecord: schema.regularSeasonStandings.awayRecord,
|
|
externalTeamId: schema.regularSeasonStandings.externalTeamId,
|
|
syncedAt: schema.regularSeasonStandings.syncedAt,
|
|
updatedAt: schema.regularSeasonStandings.updatedAt,
|
|
},
|
|
})
|
|
.returning();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Upsert a single standing record as a manual admin override.
|
|
* Sets syncedAt = null to indicate manual entry (won't conflict with auto-sync).
|
|
*/
|
|
export async function upsertManualStanding(
|
|
participantId: string,
|
|
sportsSeasonId: string,
|
|
data: Omit<UpsertRegularSeasonStandingData, "participantId" | "sportsSeasonId" | "syncedAt">,
|
|
providedDb?: ReturnType<typeof database>
|
|
) {
|
|
const results = await upsertRegularSeasonStandings(
|
|
[{ ...data, participantId, sportsSeasonId, syncedAt: null }],
|
|
providedDb
|
|
);
|
|
return results[0];
|
|
}
|
|
|
|
/**
|
|
* Get all regular season standings for a sports season.
|
|
* Ordered by conference, division, then divisionRank (or leagueRank as fallback).
|
|
*/
|
|
export async function getRegularSeasonStandings(
|
|
sportsSeasonId: string,
|
|
providedDb?: ReturnType<typeof database>
|
|
) {
|
|
const db = providedDb || database();
|
|
|
|
const rows = await db.query.regularSeasonStandings.findMany({
|
|
where: eq(schema.regularSeasonStandings.sportsSeasonId, sportsSeasonId),
|
|
with: {
|
|
participant: true,
|
|
},
|
|
});
|
|
|
|
// Sort: conference → division → divisionRank ?? leagueRank ?? leagueRank, nulls last
|
|
return rows.sort((a, b) => {
|
|
const confA = a.conference ?? "ZZZ";
|
|
const confB = b.conference ?? "ZZZ";
|
|
if (confA !== confB) return confA.localeCompare(confB);
|
|
|
|
const divA = a.division ?? "ZZZ";
|
|
const divB = b.division ?? "ZZZ";
|
|
if (divA !== divB) return divA.localeCompare(divB);
|
|
|
|
const rankA = a.divisionRank ?? a.leagueRank ?? 999;
|
|
const rankB = b.divisionRank ?? b.leagueRank ?? 999;
|
|
return rankA - rankB;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Returns the most recent syncedAt timestamp across all records for a season.
|
|
* Returns null if no auto-synced records exist.
|
|
*/
|
|
export async function getLastSyncedAt(
|
|
sportsSeasonId: string,
|
|
providedDb?: ReturnType<typeof database>
|
|
): Promise<Date | null> {
|
|
const db = providedDb || database();
|
|
|
|
const result = await db
|
|
.select({ lastSync: max(schema.regularSeasonStandings.syncedAt) })
|
|
.from(schema.regularSeasonStandings)
|
|
.where(eq(schema.regularSeasonStandings.sportsSeasonId, sportsSeasonId));
|
|
|
|
return result[0]?.lastSync ?? null;
|
|
}
|
|
|
|
/**
|
|
* Delete all regular season standings for a sports season.
|
|
*/
|
|
export async function deleteRegularSeasonStandings(
|
|
sportsSeasonId: string,
|
|
providedDb?: ReturnType<typeof database>
|
|
) {
|
|
const db = providedDb || database();
|
|
|
|
await db
|
|
.delete(schema.regularSeasonStandings)
|
|
.where(eq(schema.regularSeasonStandings.sportsSeasonId, sportsSeasonId));
|
|
}
|
|
|
|
/**
|
|
* Get a single standing record for a participant in a season.
|
|
*/
|
|
export async function getParticipantStanding(
|
|
participantId: string,
|
|
sportsSeasonId: string,
|
|
providedDb?: ReturnType<typeof database>
|
|
) {
|
|
const db = providedDb || database();
|
|
|
|
return db.query.regularSeasonStandings.findFirst({
|
|
where: and(
|
|
eq(schema.regularSeasonStandings.participantId, participantId),
|
|
eq(schema.regularSeasonStandings.sportsSeasonId, sportsSeasonId)
|
|
),
|
|
with: { participant: true },
|
|
});
|
|
}
|