brackt/app/models/regular-season-standings.ts
Chris Parsons bcca8b76fa
Add regular season standings for NBA/NHL (fixes #89) (#192)
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>
2026-03-21 00:12:01 -07:00

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 },
});
}