brackt/app/models/regular-season-standings.ts
Chris Parsons e2b178221a
Add oxlint linting setup with zero errors (#194)
* Add oxlint and fix all lint errors

- Install oxlint, add .oxlintrc.json with rules for TypeScript/React
- Add npm run lint / lint:fix scripts
- Add Claude PostToolUse hook to run oxlint on every edited file
- Fix 101 errors: unused vars/imports, eqeqeq, prefer-const, no-new-array
- Fix no-array-index-key (use stable keys or suppress positional cases)
- Fix exhaustive-deps missing dependency in useEffect
- Promote exhaustive-deps and no-array-index-key to errors
- Fix Map.get() !== null bug in $leagueId.server.ts (should be !== undefined)

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

* Fix no-explicit-any warnings and upgrade tsconfig to ES2023

- Replace all `any` types with proper types or `unknown` across ~20 files
- Add typed socket payload interfaces in draft route and useDraftSocket
- Use any[] with eslint-disable for socket.io callbacks (legitimate escape hatch)
- Bump all tsconfigs from ES2022 → ES2023 to support toSorted/toReversed
- Fix cascading type errors uncovered by removing any: Map.get narrowing,
  participant relation types, ChartDataPoint, Partial<NewSeason> indexing
- Add ParticipantResultWithParticipant type to participant-result model
- Fix test fixtures to match updated interfaces (DraftCell, ParticipantResult)
- Fix duplicate getQPStandings import in sportsSeasonId.server.ts

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

* Promote no-explicit-any to error

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 09:44:05 -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 !== undefined ? r.winPct.toString() : null,
gamesPlayed: r.gamesPlayed,
gamesBack: r.gamesBack !== null && r.gamesBack !== undefined ? 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.toSorted((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 },
});
}