brackt/app/models/pending-standings-mappings.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

100 lines
2.8 KiB
TypeScript

import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq, and } from "drizzle-orm";
import type { FetchedStandingsRecord } from "~/services/standings-sync/types";
/**
* Upsert a pending mapping for an unmatched team from a standings sync.
* Uses onConflictDoUpdate so re-syncing refreshes the standing data.
*/
export async function upsertPendingStandingsMapping(
sportsSeasonId: string,
record: FetchedStandingsRecord,
providedDb?: ReturnType<typeof database>
) {
const db = providedDb || database();
const [row] = await db
.insert(schema.pendingStandingsMappings)
.values({
sportsSeasonId,
externalTeamId: record.externalTeamId,
teamName: record.teamName,
standingData: record as unknown as Record<string, unknown>,
})
.onConflictDoUpdate({
target: [
schema.pendingStandingsMappings.sportsSeasonId,
schema.pendingStandingsMappings.externalTeamId,
],
set: {
teamName: record.teamName,
standingData: record as unknown as Record<string, unknown>,
},
})
.returning();
return row;
}
/**
* Bulk upsert pending mappings for all unmatched teams in a sync.
*/
export async function upsertPendingStandingsMappings(
sportsSeasonId: string,
records: FetchedStandingsRecord[],
providedDb?: ReturnType<typeof database>
) {
const db = providedDb || database();
return Promise.all(records.map((r) => upsertPendingStandingsMapping(sportsSeasonId, r, db)));
}
/**
* Get all pending mappings for a sports season (awaiting admin resolution).
*/
export async function getPendingStandingsMappings(
sportsSeasonId: string,
providedDb?: ReturnType<typeof database>
) {
const db = providedDb || database();
return db.query.pendingStandingsMappings.findMany({
where: eq(schema.pendingStandingsMappings.sportsSeasonId, sportsSeasonId),
orderBy: schema.pendingStandingsMappings.teamName,
});
}
/**
* Delete a resolved pending mapping by its externalTeamId.
*/
export async function deletePendingStandingsMapping(
sportsSeasonId: string,
externalTeamId: string,
providedDb?: ReturnType<typeof database>
) {
const db = providedDb || database();
await db
.delete(schema.pendingStandingsMappings)
.where(
and(
eq(schema.pendingStandingsMappings.sportsSeasonId, sportsSeasonId),
eq(schema.pendingStandingsMappings.externalTeamId, externalTeamId)
)
);
}
/**
* Delete all pending mappings for a sports season.
* Used when manually clearing the queue.
*/
export async function deleteAllPendingStandingsMappings(
sportsSeasonId: string,
providedDb?: ReturnType<typeof database>
) {
const db = providedDb || database();
await db
.delete(schema.pendingStandingsMappings)
.where(eq(schema.pendingStandingsMappings.sportsSeasonId, sportsSeasonId));
}