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>
This commit is contained in:
parent
2949ca733a
commit
bcca8b76fa
27 changed files with 10224 additions and 9 deletions
430
app/components/sport-season/RegularSeasonStandings.tsx
Normal file
430
app/components/sport-season/RegularSeasonStandings.tsx
Normal file
|
|
@ -0,0 +1,430 @@
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "~/components/ui/card";
|
||||||
|
import { BarChart3 } from "lucide-react";
|
||||||
|
import { TeamOwnerBadge } from "~/components/ui/team-owner-badge";
|
||||||
|
|
||||||
|
interface StandingRow {
|
||||||
|
id: string;
|
||||||
|
participantId: string;
|
||||||
|
wins: number;
|
||||||
|
losses: number;
|
||||||
|
otLosses: number | null;
|
||||||
|
winPct: string | null;
|
||||||
|
gamesPlayed: number;
|
||||||
|
gamesBack: string | 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;
|
||||||
|
syncedAt: string | null;
|
||||||
|
participant: { id: string; name: string; shortName?: string | null };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TeamOwnership {
|
||||||
|
teamName: string;
|
||||||
|
ownerName: string;
|
||||||
|
teamId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
standings: StandingRow[];
|
||||||
|
teamOwnerships: Record<string, TeamOwnership>;
|
||||||
|
userParticipantIds: string[];
|
||||||
|
showOtLosses?: boolean;
|
||||||
|
participantEvs?: Record<string, string>;
|
||||||
|
/** How many teams per conference qualify for playoffs (flat mode only). 0 = no line. */
|
||||||
|
playoffSpots?: number;
|
||||||
|
/** "flat" = single ranked list per conference (NBA). "nhl-divisions" = div top-3 + wild card. */
|
||||||
|
displayMode?: "flat" | "nhl-divisions";
|
||||||
|
}
|
||||||
|
|
||||||
|
type RankMode = "conference" | "division" | "index";
|
||||||
|
|
||||||
|
interface TableSection {
|
||||||
|
heading?: string;
|
||||||
|
rows: StandingRow[];
|
||||||
|
rankMode: RankMode;
|
||||||
|
playoffSpots: number;
|
||||||
|
showDivisionLabel?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Formatting ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function formatWinPct(winPct: string | null, wins: number, gamesPlayed: number): string {
|
||||||
|
if (winPct != null) {
|
||||||
|
const pct = parseFloat(winPct);
|
||||||
|
if (!isNaN(pct)) return pct.toFixed(3);
|
||||||
|
}
|
||||||
|
if (gamesPlayed > 0) return (wins / gamesPlayed).toFixed(3);
|
||||||
|
return "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatGB(gamesBack: string | null): string {
|
||||||
|
if (gamesBack == null) return "—";
|
||||||
|
const gb = parseFloat(gamesBack);
|
||||||
|
if (isNaN(gb) || gb === 0) return "—";
|
||||||
|
return gb % 1 === 0 ? gb.toString() : gb.toFixed(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRank(row: StandingRow, idx: number, mode: RankMode): number {
|
||||||
|
if (mode === "division") return row.divisionRank ?? idx + 1;
|
||||||
|
if (mode === "index") return idx + 1;
|
||||||
|
return row.conferenceRank ?? idx + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Grouping ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function buildFlatSections(
|
||||||
|
standings: StandingRow[],
|
||||||
|
playoffSpots: number
|
||||||
|
): Array<{ conference: string; sections: TableSection[] }> {
|
||||||
|
const hasConferences = standings.some((s) => s.conference);
|
||||||
|
|
||||||
|
if (!hasConferences) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
conference: "",
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
rows: [...standings].sort(
|
||||||
|
(a, b) => (a.leagueRank ?? 99) - (b.leagueRank ?? 99)
|
||||||
|
),
|
||||||
|
rankMode: "conference",
|
||||||
|
playoffSpots,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const confMap = new Map<string, StandingRow[]>();
|
||||||
|
for (const row of standings) {
|
||||||
|
const conf = row.conference ?? "Other";
|
||||||
|
if (!confMap.has(conf)) confMap.set(conf, []);
|
||||||
|
confMap.get(conf)!.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(confMap.entries()).map(([conference, rows]) => ({
|
||||||
|
conference,
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
rows: [...rows].sort(
|
||||||
|
(a, b) =>
|
||||||
|
(a.conferenceRank ?? a.leagueRank ?? 99) -
|
||||||
|
(b.conferenceRank ?? b.leagueRank ?? 99)
|
||||||
|
),
|
||||||
|
rankMode: "conference" as RankMode,
|
||||||
|
playoffSpots,
|
||||||
|
showDivisionLabel: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildNhlSections(
|
||||||
|
standings: StandingRow[]
|
||||||
|
): Array<{ conference: string; sections: TableSection[] }> {
|
||||||
|
const confMap = new Map<string, StandingRow[]>();
|
||||||
|
for (const row of standings) {
|
||||||
|
const conf = row.conference ?? "Other";
|
||||||
|
if (!confMap.has(conf)) confMap.set(conf, []);
|
||||||
|
confMap.get(conf)!.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(confMap.entries()).map(([conference, rows]) => {
|
||||||
|
const divMap = new Map<string, StandingRow[]>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const div = row.division ?? "";
|
||||||
|
if (!divMap.has(div)) divMap.set(div, []);
|
||||||
|
divMap.get(div)!.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
const divisions = Array.from(divMap.entries())
|
||||||
|
.map(([name, divRows]) => ({
|
||||||
|
name,
|
||||||
|
qualifiers: divRows
|
||||||
|
.filter((r) => (r.divisionRank ?? 99) <= 3)
|
||||||
|
.sort((a, b) => (a.divisionRank ?? 99) - (b.divisionRank ?? 99)),
|
||||||
|
}))
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
(a.qualifiers[0]?.conferenceRank ?? 99) -
|
||||||
|
(b.qualifiers[0]?.conferenceRank ?? 99)
|
||||||
|
);
|
||||||
|
|
||||||
|
const wildCard = rows
|
||||||
|
.filter((r) => (r.divisionRank ?? 99) > 3)
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
(a.conferenceRank ?? a.leagueRank ?? 99) -
|
||||||
|
(b.conferenceRank ?? b.leagueRank ?? 99)
|
||||||
|
);
|
||||||
|
|
||||||
|
const sections: TableSection[] = [
|
||||||
|
...divisions.map((div) => ({
|
||||||
|
heading: `${div.name} Division`,
|
||||||
|
rows: div.qualifiers,
|
||||||
|
rankMode: "division" as RankMode,
|
||||||
|
playoffSpots: 0,
|
||||||
|
showDivisionLabel: false,
|
||||||
|
})),
|
||||||
|
...(wildCard.length > 0
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
heading: "Wild Card",
|
||||||
|
rows: wildCard,
|
||||||
|
rankMode: "index" as RankMode,
|
||||||
|
playoffSpots: 2,
|
||||||
|
showDivisionLabel: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
|
||||||
|
return { conference, sections };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Single table that renders all sections ───────────────────────────────────
|
||||||
|
|
||||||
|
function StandingsTable({
|
||||||
|
sections,
|
||||||
|
teamOwnerships,
|
||||||
|
userParticipantIds,
|
||||||
|
showOtLosses,
|
||||||
|
participantEvs,
|
||||||
|
hasEvs,
|
||||||
|
}: {
|
||||||
|
sections: TableSection[];
|
||||||
|
teamOwnerships: Record<string, TeamOwnership>;
|
||||||
|
userParticipantIds: string[];
|
||||||
|
showOtLosses: boolean;
|
||||||
|
participantEvs: Record<string, string>;
|
||||||
|
hasEvs: boolean;
|
||||||
|
}) {
|
||||||
|
// # Team GP W L [OTL] [PTS] PCT GB L10 STK Mgr [PROJ]
|
||||||
|
// OTL and PTS are both shown for hockey (showOtLosses = true)
|
||||||
|
const totalCols = 10 + (showOtLosses ? 2 : 0) + (hasEvs ? 1 : 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto -mx-6 px-6">
|
||||||
|
<table className="w-full min-w-[740px] text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-xs text-muted-foreground uppercase tracking-wide border-b">
|
||||||
|
<th className="text-left py-1.5 pr-2 w-6">#</th>
|
||||||
|
<th className="text-left py-1.5">Team</th>
|
||||||
|
<th className="text-right py-1.5 px-2 w-10">GP</th>
|
||||||
|
<th className="text-right py-1.5 px-2 w-8">W</th>
|
||||||
|
<th className="text-right py-1.5 px-2 w-8">L</th>
|
||||||
|
{showOtLosses && <th className="text-right py-1.5 px-2 w-10">OTL</th>}
|
||||||
|
{showOtLosses && <th className="text-right py-1.5 px-2 w-10">PTS</th>}
|
||||||
|
<th className="text-right py-1.5 px-2 w-12">PCT</th>
|
||||||
|
<th className="text-right py-1.5 px-2 w-10">GB</th>
|
||||||
|
<th className="text-right py-1.5 px-2 w-12">L10</th>
|
||||||
|
<th className="text-right py-1.5 px-2 w-12">STK</th>
|
||||||
|
<th className="text-right py-1.5 pl-4 w-40">Mgr</th>
|
||||||
|
{hasEvs && <th className="text-right py-1.5 pl-2 w-14">PROJ</th>}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sections.flatMap((section, sIdx) => {
|
||||||
|
const sectionRows: React.ReactNode[] = [];
|
||||||
|
let playoffLineShown = false;
|
||||||
|
|
||||||
|
if (section.heading) {
|
||||||
|
sectionRows.push(
|
||||||
|
<tr key={`h-${sIdx}`}>
|
||||||
|
<td
|
||||||
|
colSpan={totalCols}
|
||||||
|
className={`text-xs font-medium text-muted-foreground/60 uppercase tracking-wider pb-1 ${
|
||||||
|
sIdx > 0 ? "pt-5" : "pt-2"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{section.heading}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
section.rows.forEach((row, i) => {
|
||||||
|
const rank = getRank(row, i, section.rankMode);
|
||||||
|
|
||||||
|
if (!playoffLineShown && section.playoffSpots > 0 && rank > section.playoffSpots) {
|
||||||
|
playoffLineShown = true;
|
||||||
|
sectionRows.push(
|
||||||
|
<tr key={`pl-${sIdx}`}>
|
||||||
|
<td colSpan={totalCols} className="py-0.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1 border-t border-dashed border-amber-500/40" />
|
||||||
|
<span className="text-[10px] text-amber-600/70 dark:text-amber-500/70 uppercase tracking-wide whitespace-nowrap font-medium px-1">
|
||||||
|
Playoff Line
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 border-t border-dashed border-amber-500/40" />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isUserTeam = userParticipantIds.includes(row.participantId);
|
||||||
|
const ownership = teamOwnerships[row.participantId];
|
||||||
|
const ev = participantEvs[row.participantId];
|
||||||
|
|
||||||
|
sectionRows.push(
|
||||||
|
<tr
|
||||||
|
key={row.id}
|
||||||
|
className={`border-b border-border/50 last:border-0 ${
|
||||||
|
isUserTeam ? "bg-primary/5" : "hover:bg-muted/30"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<td className="py-2 pr-2 text-muted-foreground tabular-nums">{rank}</td>
|
||||||
|
<td className="py-2">
|
||||||
|
<span className={`font-medium ${isUserTeam ? "text-primary" : ""}`}>
|
||||||
|
{row.participant.shortName ?? row.participant.name}
|
||||||
|
</span>
|
||||||
|
{section.showDivisionLabel && row.division && (
|
||||||
|
<span className="ml-1.5 text-[10px] text-muted-foreground/50 uppercase tracking-wide">
|
||||||
|
{row.division}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-2 text-right tabular-nums text-muted-foreground">
|
||||||
|
{row.gamesPlayed}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-2 text-right tabular-nums font-medium">{row.wins}</td>
|
||||||
|
<td className="py-2 px-2 text-right tabular-nums">{row.losses}</td>
|
||||||
|
{showOtLosses && (
|
||||||
|
<td className="py-2 px-2 text-right tabular-nums">{row.otLosses ?? 0}</td>
|
||||||
|
)}
|
||||||
|
{showOtLosses && (
|
||||||
|
<td className="py-2 px-2 text-right tabular-nums font-medium">
|
||||||
|
{row.wins * 2 + (row.otLosses ?? 0)}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
<td className="py-2 px-2 text-right tabular-nums text-muted-foreground">
|
||||||
|
{formatWinPct(row.winPct, row.wins, row.gamesPlayed)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-2 text-right tabular-nums text-muted-foreground">
|
||||||
|
{formatGB(row.gamesBack)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-2 text-right tabular-nums text-muted-foreground">
|
||||||
|
{row.lastTen ?? "—"}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-2 text-right tabular-nums">
|
||||||
|
{row.streak ? (
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
row.streak.startsWith("W")
|
||||||
|
? "text-emerald-500"
|
||||||
|
: "text-destructive/80"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{row.streak}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pl-4 text-right">
|
||||||
|
{ownership ? (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<TeamOwnerBadge
|
||||||
|
teamName={ownership.teamName}
|
||||||
|
ownerName={ownership.ownerName || undefined}
|
||||||
|
align="right"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
{hasEvs && (
|
||||||
|
<td className="py-2 pl-2 text-right tabular-nums">
|
||||||
|
{ev != null ? (
|
||||||
|
<span className="text-blue-500 dark:text-blue-400 font-medium">
|
||||||
|
{parseFloat(ev).toFixed(1)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return sectionRows;
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main export ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function RegularSeasonStandings({
|
||||||
|
standings,
|
||||||
|
teamOwnerships,
|
||||||
|
userParticipantIds,
|
||||||
|
showOtLosses = false,
|
||||||
|
participantEvs = {},
|
||||||
|
playoffSpots = 8,
|
||||||
|
displayMode = "flat",
|
||||||
|
}: Props) {
|
||||||
|
if (standings.length === 0) return null;
|
||||||
|
|
||||||
|
const hasEvs = Object.keys(participantEvs).length > 0;
|
||||||
|
const lastSyncedAt = standings
|
||||||
|
.map((s) => s.syncedAt)
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort()
|
||||||
|
.at(-1);
|
||||||
|
|
||||||
|
const groups =
|
||||||
|
displayMode === "nhl-divisions"
|
||||||
|
? buildNhlSections(standings)
|
||||||
|
: buildFlatSections(standings, playoffSpots);
|
||||||
|
|
||||||
|
const tableProps = { teamOwnerships, userParticipantIds, showOtLosses, participantEvs, hasEvs };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<BarChart3 className="h-5 w-5" />
|
||||||
|
Regular Season Standings
|
||||||
|
</CardTitle>
|
||||||
|
{lastSyncedAt && (
|
||||||
|
<CardDescription className="text-xs tabular-nums">
|
||||||
|
Updated {new Date(lastSyncedAt).toLocaleDateString()}
|
||||||
|
</CardDescription>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{groups.map((group) => (
|
||||||
|
<div key={group.conference} className="mb-6 last:mb-0">
|
||||||
|
{group.conference && (
|
||||||
|
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
||||||
|
{group.conference} Conference
|
||||||
|
</h3>
|
||||||
|
)}
|
||||||
|
<StandingsTable sections={group.sections} {...tableProps} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,182 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { RegularSeasonStandings } from "../RegularSeasonStandings";
|
||||||
|
|
||||||
|
function makeRow(overrides: Partial<{
|
||||||
|
id: string;
|
||||||
|
participantId: string;
|
||||||
|
wins: number;
|
||||||
|
losses: number;
|
||||||
|
otLosses: number | null;
|
||||||
|
gamesPlayed: number;
|
||||||
|
winPct: string | null;
|
||||||
|
gamesBack: string | 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;
|
||||||
|
syncedAt: string | null;
|
||||||
|
participant: { id: string; name: string; shortName?: string | null };
|
||||||
|
}> = {}) {
|
||||||
|
return {
|
||||||
|
id: overrides.id ?? "row-1",
|
||||||
|
participantId: overrides.participantId ?? "p-1",
|
||||||
|
wins: overrides.wins ?? 40,
|
||||||
|
losses: overrides.losses ?? 28,
|
||||||
|
otLosses: overrides.otLosses ?? null,
|
||||||
|
gamesPlayed: overrides.gamesPlayed ?? 68,
|
||||||
|
winPct: overrides.winPct ?? "0.5882",
|
||||||
|
gamesBack: overrides.gamesBack ?? null,
|
||||||
|
conference: overrides.conference ?? null,
|
||||||
|
division: overrides.division ?? null,
|
||||||
|
conferenceRank: overrides.conferenceRank ?? null,
|
||||||
|
divisionRank: overrides.divisionRank ?? null,
|
||||||
|
leagueRank: overrides.leagueRank ?? 1,
|
||||||
|
streak: overrides.streak ?? null,
|
||||||
|
lastTen: overrides.lastTen ?? null,
|
||||||
|
homeRecord: overrides.homeRecord ?? null,
|
||||||
|
awayRecord: overrides.awayRecord ?? null,
|
||||||
|
syncedAt: overrides.syncedAt ?? null,
|
||||||
|
participant: overrides.participant ?? { id: "p-1", name: "Boston Celtics" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const NO_OWNERSHIPS = {};
|
||||||
|
const NO_USER_IDS: string[] = [];
|
||||||
|
|
||||||
|
describe("RegularSeasonStandings", () => {
|
||||||
|
it("renders nothing when standings array is empty", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<RegularSeasonStandings
|
||||||
|
standings={[]}
|
||||||
|
teamOwnerships={NO_OWNERSHIPS}
|
||||||
|
userParticipantIds={NO_USER_IDS}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(container.firstChild).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders team names", () => {
|
||||||
|
render(
|
||||||
|
<RegularSeasonStandings
|
||||||
|
standings={[
|
||||||
|
makeRow({ participant: { id: "p-1", name: "Boston Celtics" } }),
|
||||||
|
makeRow({ id: "row-2", participantId: "p-2", participant: { id: "p-2", name: "Toronto Raptors" } }),
|
||||||
|
]}
|
||||||
|
teamOwnerships={NO_OWNERSHIPS}
|
||||||
|
userParticipantIds={NO_USER_IDS}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Boston Celtics")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Toronto Raptors")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows OTL column when showOtLosses=true", () => {
|
||||||
|
render(
|
||||||
|
<RegularSeasonStandings
|
||||||
|
standings={[makeRow({ otLosses: 5 })]}
|
||||||
|
teamOwnerships={NO_OWNERSHIPS}
|
||||||
|
userParticipantIds={NO_USER_IDS}
|
||||||
|
showOtLosses={true}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(screen.getByText("OTL")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides OTL column when showOtLosses=false (default)", () => {
|
||||||
|
render(
|
||||||
|
<RegularSeasonStandings
|
||||||
|
standings={[makeRow()]}
|
||||||
|
teamOwnerships={NO_OWNERSHIPS}
|
||||||
|
userParticipantIds={NO_USER_IDS}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(screen.queryByText("OTL")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders conference headings and inline division labels when present", () => {
|
||||||
|
render(
|
||||||
|
<RegularSeasonStandings
|
||||||
|
standings={[
|
||||||
|
makeRow({ conference: "Eastern", division: "Atlantic", conferenceRank: 1, participant: { id: "p-1", name: "Boston Celtics" } }),
|
||||||
|
makeRow({ id: "row-2", participantId: "p-2", conference: "Western", division: "Pacific", conferenceRank: 1, participant: { id: "p-2", name: "LA Lakers" } }),
|
||||||
|
]}
|
||||||
|
teamOwnerships={NO_OWNERSHIPS}
|
||||||
|
userParticipantIds={NO_USER_IDS}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
// Conference group headings are rendered as <h3>
|
||||||
|
expect(screen.getByText(/Eastern Conference/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Western Conference/i)).toBeInTheDocument();
|
||||||
|
// Division is now shown as an inline label per row (not a section header)
|
||||||
|
expect(screen.getAllByText(/Atlantic/i).length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getAllByText(/Pacific/i).length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows ownership badge for drafted teams", () => {
|
||||||
|
render(
|
||||||
|
<RegularSeasonStandings
|
||||||
|
standings={[makeRow({ participantId: "p-1" })]}
|
||||||
|
teamOwnerships={{
|
||||||
|
"p-1": { teamName: "Team Alpha", ownerName: "Alice", teamId: "t-1" },
|
||||||
|
}}
|
||||||
|
userParticipantIds={NO_USER_IDS}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Alice")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show badge for undrafted teams", () => {
|
||||||
|
render(
|
||||||
|
<RegularSeasonStandings
|
||||||
|
standings={[makeRow({ participantId: "p-1" })]}
|
||||||
|
teamOwnerships={{}}
|
||||||
|
userParticipantIds={NO_USER_IDS}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
// No owner badge should be present
|
||||||
|
expect(screen.queryByText(/Alice/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies different styling for user's own team", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<RegularSeasonStandings
|
||||||
|
standings={[makeRow({ participantId: "p-1" })]}
|
||||||
|
teamOwnerships={{}}
|
||||||
|
userParticipantIds={["p-1"]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
// The user's row should have a primary styling class
|
||||||
|
const userRow = container.querySelector("tr.bg-primary\\/5");
|
||||||
|
expect(userRow).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders streak with correct color class for wins", () => {
|
||||||
|
render(
|
||||||
|
<RegularSeasonStandings
|
||||||
|
standings={[makeRow({ streak: "W5" })]}
|
||||||
|
teamOwnerships={NO_OWNERSHIPS}
|
||||||
|
userParticipantIds={NO_USER_IDS}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const streakEl = screen.getByText("W5");
|
||||||
|
expect(streakEl.className).toContain("emerald");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders streak with destructive class for losses", () => {
|
||||||
|
render(
|
||||||
|
<RegularSeasonStandings
|
||||||
|
standings={[makeRow({ streak: "L3" })]}
|
||||||
|
teamOwnerships={NO_OWNERSHIPS}
|
||||||
|
userParticipantIds={NO_USER_IDS}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const streakEl = screen.getByText("L3");
|
||||||
|
expect(streakEl.className).toContain("destructive");
|
||||||
|
});
|
||||||
|
});
|
||||||
48
app/lib/__tests__/normalize-team-name.test.ts
Normal file
48
app/lib/__tests__/normalize-team-name.test.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { normalizeTeamName, findMatchingTeamName } from "../normalize-team-name";
|
||||||
|
|
||||||
|
describe("normalizeTeamName", () => {
|
||||||
|
it("lowercases and trims", () => {
|
||||||
|
expect(normalizeTeamName(" Boston Celtics ")).toBe("boston celtics");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapses multiple spaces", () => {
|
||||||
|
expect(normalizeTeamName("Los Angeles Lakers")).toBe("los angeles lakers");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is idempotent", () => {
|
||||||
|
const name = "oklahoma city thunder";
|
||||||
|
expect(normalizeTeamName(name)).toBe(name);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findMatchingTeamName", () => {
|
||||||
|
const participants = [
|
||||||
|
"Boston Celtics",
|
||||||
|
"Los Angeles Lakers",
|
||||||
|
"Oklahoma City Thunder",
|
||||||
|
"Golden State Warriors",
|
||||||
|
"Toronto Raptors",
|
||||||
|
];
|
||||||
|
|
||||||
|
it("returns exact case-insensitive match", () => {
|
||||||
|
expect(findMatchingTeamName("Boston Celtics", participants)).toBe("Boston Celtics");
|
||||||
|
expect(findMatchingTeamName("boston celtics", participants)).toBe("Boston Celtics");
|
||||||
|
expect(findMatchingTeamName("TORONTO RAPTORS", participants)).toBe("Toronto Raptors");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches partial city+team via substring (e.g. API returns city only)", () => {
|
||||||
|
// "Golden State" is a substring of "Golden State Warriors"
|
||||||
|
expect(findMatchingTeamName("Golden State", participants)).toBe("Golden State Warriors");
|
||||||
|
// "Oklahoma City" is a substring of "Oklahoma City Thunder"
|
||||||
|
expect(findMatchingTeamName("Oklahoma City", participants)).toBe("Oklahoma City Thunder");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for unmatched name", () => {
|
||||||
|
expect(findMatchingTeamName("Phoenix Suns", participants)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for empty participants list", () => {
|
||||||
|
expect(findMatchingTeamName("Boston Celtics", [])).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
49
app/lib/normalize-team-name.ts
Normal file
49
app/lib/normalize-team-name.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
/**
|
||||||
|
* Normalize a team name for fuzzy matching across data sources.
|
||||||
|
* Lowercases, trims, and collapses whitespace.
|
||||||
|
*/
|
||||||
|
export function normalizeTeamName(name: string): string {
|
||||||
|
return name.toLowerCase().trim().replace(/\s+/g, " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to match `apiName` to one of the participant names.
|
||||||
|
* Returns the matched participant name, or null if no match found.
|
||||||
|
*
|
||||||
|
* Strategy:
|
||||||
|
* 1. Exact normalized match (case-insensitive)
|
||||||
|
* 2. Substring: one contains the other (handles "Golden State" vs "Golden State Warriors")
|
||||||
|
*
|
||||||
|
* Note: City abbreviations like "LA" ↔ "Los Angeles" are NOT handled here — the
|
||||||
|
* NBA/NHL APIs typically return full city names so the admin should enter full names
|
||||||
|
* in the participant list to ensure reliable matching.
|
||||||
|
*/
|
||||||
|
export function findMatchingTeamName(
|
||||||
|
apiName: string,
|
||||||
|
participantNames: string[]
|
||||||
|
): string | null {
|
||||||
|
const normalizedApi = normalizeTeamName(apiName);
|
||||||
|
|
||||||
|
// Guard: empty API name should never match anything
|
||||||
|
if (!normalizedApi) return null;
|
||||||
|
|
||||||
|
// 1. Exact match
|
||||||
|
for (const name of participantNames) {
|
||||||
|
if (normalizeTeamName(name) === normalizedApi) return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Substring match (only when the shorter string is at least 4 chars to avoid false positives)
|
||||||
|
for (const name of participantNames) {
|
||||||
|
const normalizedParticipant = normalizeTeamName(name);
|
||||||
|
if (
|
||||||
|
normalizedApi.length >= 4 &&
|
||||||
|
normalizedParticipant.length >= 4 &&
|
||||||
|
(normalizedApi.includes(normalizedParticipant) ||
|
||||||
|
normalizedParticipant.includes(normalizedApi))
|
||||||
|
) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
100
app/models/pending-standings-mappings.ts
Normal file
100
app/models/pending-standings-mappings.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
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));
|
||||||
|
}
|
||||||
200
app/models/regular-season-standings.ts
Normal file
200
app/models/regular-season-standings.ts
Normal file
|
|
@ -0,0 +1,200 @@
|
||||||
|
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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -85,6 +85,10 @@ export default [
|
||||||
"sports-seasons/:id/standings",
|
"sports-seasons/:id/standings",
|
||||||
"routes/admin.sports-seasons.$id.standings.tsx"
|
"routes/admin.sports-seasons.$id.standings.tsx"
|
||||||
),
|
),
|
||||||
|
route(
|
||||||
|
"sports-seasons/:id/regular-standings",
|
||||||
|
"routes/admin.sports-seasons.$id.regular-standings.tsx"
|
||||||
|
),
|
||||||
route(
|
route(
|
||||||
"sports-seasons/:id/simulate",
|
"sports-seasons/:id/simulate",
|
||||||
"routes/admin.sports-seasons.$id.simulate.tsx"
|
"routes/admin.sports-seasons.$id.simulate.tsx"
|
||||||
|
|
|
||||||
253
app/routes/admin.sports-seasons.$id.regular-standings.tsx
Normal file
253
app/routes/admin.sports-seasons.$id.regular-standings.tsx
Normal file
|
|
@ -0,0 +1,253 @@
|
||||||
|
import { Form, Link } from "react-router";
|
||||||
|
import type { Route } from "./+types/admin.sports-seasons.$id.regular-standings";
|
||||||
|
|
||||||
|
import { findSportsSeasonById } from "~/models/sports-season";
|
||||||
|
import { findParticipantsBySportsSeasonId } from "~/models/participant";
|
||||||
|
import { getRegularSeasonStandings, upsertManualStanding } from "~/models/regular-season-standings";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "~/components/ui/card";
|
||||||
|
import { ArrowLeft, Save, BarChart3 } from "lucide-react";
|
||||||
|
|
||||||
|
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||||
|
return [{ title: `Regular Season Standings — ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loader({ params }: Route.LoaderArgs) {
|
||||||
|
const sportsSeason = await findSportsSeasonById(params.id);
|
||||||
|
if (!sportsSeason) {
|
||||||
|
throw new Response("Sports season not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const participants = await findParticipantsBySportsSeasonId(params.id);
|
||||||
|
const standingsRecords = await getRegularSeasonStandings(params.id);
|
||||||
|
|
||||||
|
// Build a map of existing records keyed by participantId
|
||||||
|
const standingsMap = Object.fromEntries(
|
||||||
|
standingsRecords.map((r) => [r.participantId, r])
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sportsSeason: sportsSeason as typeof sportsSeason & { sport: { id: string; name: string; type: string } },
|
||||||
|
participants,
|
||||||
|
standingsMap,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function action({ request, params }: Route.ActionArgs) {
|
||||||
|
const formData = await request.formData();
|
||||||
|
|
||||||
|
const isNhl = formData.get("_isNhl") === "true";
|
||||||
|
|
||||||
|
// Parse updates from form fields: wins_<id>, losses_<id>, otLosses_<id>, conference_<id>, division_<id>
|
||||||
|
const byId = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
wins?: number;
|
||||||
|
losses?: number;
|
||||||
|
otLosses?: number | null;
|
||||||
|
conference?: string | null;
|
||||||
|
division?: string | null;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
for (const [key, value] of formData.entries()) {
|
||||||
|
const winsMatch = key.match(/^wins_(.+)$/);
|
||||||
|
const lossesMatch = key.match(/^losses_(.+)$/);
|
||||||
|
const otMatch = key.match(/^otLosses_(.+)$/);
|
||||||
|
const confMatch = key.match(/^conference_(.+)$/);
|
||||||
|
const divMatch = key.match(/^division_(.+)$/);
|
||||||
|
|
||||||
|
if (winsMatch) {
|
||||||
|
const id = winsMatch[1];
|
||||||
|
const wins = parseInt(value as string, 10);
|
||||||
|
if (!isNaN(wins)) byId.set(id, { ...byId.get(id), wins });
|
||||||
|
} else if (lossesMatch) {
|
||||||
|
const id = lossesMatch[1];
|
||||||
|
const losses = parseInt(value as string, 10);
|
||||||
|
if (!isNaN(losses)) byId.set(id, { ...byId.get(id), losses });
|
||||||
|
} else if (otMatch) {
|
||||||
|
const id = otMatch[1];
|
||||||
|
const otLosses = value === "" ? null : parseInt(value as string, 10);
|
||||||
|
byId.set(id, { ...byId.get(id), otLosses: isNaN(otLosses as number) ? null : otLosses });
|
||||||
|
} else if (confMatch) {
|
||||||
|
const id = confMatch[1];
|
||||||
|
byId.set(id, { ...byId.get(id), conference: (value as string).trim() || null });
|
||||||
|
} else if (divMatch) {
|
||||||
|
const id = divMatch[1];
|
||||||
|
byId.set(id, { ...byId.get(id), division: (value as string).trim() || null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const [participantId, data] of byId.entries()) {
|
||||||
|
if (data.wins == null && data.losses == null) continue;
|
||||||
|
const wins = data.wins ?? 0;
|
||||||
|
const losses = data.losses ?? 0;
|
||||||
|
const gamesPlayed = wins + losses + (data.otLosses ?? 0);
|
||||||
|
const winPct = gamesPlayed > 0 ? wins / gamesPlayed : 0;
|
||||||
|
|
||||||
|
await upsertManualStanding(participantId, params.id, {
|
||||||
|
wins,
|
||||||
|
losses,
|
||||||
|
otLosses: data.otLosses ?? null,
|
||||||
|
gamesPlayed,
|
||||||
|
winPct,
|
||||||
|
conference: data.conference ?? null,
|
||||||
|
division: data.division ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error saving regular season standings:", error);
|
||||||
|
return { error: "Failed to save standings. Please try again." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ManageRegularStandings({ loaderData, actionData }: Route.ComponentProps) {
|
||||||
|
const { sportsSeason, participants, standingsMap } = loaderData;
|
||||||
|
const isNhl = sportsSeason.sport?.name?.toLowerCase().includes("nhl") ||
|
||||||
|
sportsSeason.sport?.name?.toLowerCase().includes("hockey");
|
||||||
|
|
||||||
|
// Sort participants: those with records first (by wins desc), then unrecorded
|
||||||
|
const sorted = [...participants].sort((a, b) => {
|
||||||
|
const recA = standingsMap[a.id];
|
||||||
|
const recB = standingsMap[b.id];
|
||||||
|
if (recA && recB) return (recB.wins ?? 0) - (recA.wins ?? 0);
|
||||||
|
if (recA) return -1;
|
||||||
|
if (recB) return 1;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<div className="max-w-3xl">
|
||||||
|
<div className="mb-6">
|
||||||
|
<Button variant="ghost" size="sm" asChild className="mb-2">
|
||||||
|
<Link to={`/admin/sports-seasons/${sportsSeason.id}`}>
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back to Sports Season
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<h1 className="text-3xl font-bold">Regular Season Standings</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
{sportsSeason.sport.name} — {sportsSeason.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Manually enter W/L records. These will be overwritten on the next auto-sync unless you use this page again afterward.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{actionData?.error && (
|
||||||
|
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
|
||||||
|
{actionData.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{actionData?.success && (
|
||||||
|
<div className="bg-emerald-500/15 text-emerald-400 px-4 py-3 rounded-md text-sm">
|
||||||
|
Standings saved.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<BarChart3 className="h-5 w-5" />
|
||||||
|
W/L Records
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Enter wins, losses{isNhl ? ", and OT losses" : ""} for each team. Conference and division are optional but improve the standings display.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Form method="post">
|
||||||
|
<input type="hidden" name="_isNhl" value={isNhl ? "true" : "false"} />
|
||||||
|
|
||||||
|
<div className="space-y-1 mb-4">
|
||||||
|
<div className={`grid gap-2 px-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wide border-b ${isNhl ? "grid-cols-[1fr_3.5rem_3.5rem_3.5rem_6rem_6rem]" : "grid-cols-[1fr_3.5rem_3.5rem_6rem_6rem]"}`}>
|
||||||
|
<span>Team</span>
|
||||||
|
<span>W</span>
|
||||||
|
<span>L</span>
|
||||||
|
{isNhl && <span>OTL</span>}
|
||||||
|
<span>Conference</span>
|
||||||
|
<span>Division</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sorted.map((participant) => {
|
||||||
|
const record = standingsMap[participant.id];
|
||||||
|
const isSynced = record && record.syncedAt !== null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={participant.id}
|
||||||
|
className={`grid gap-2 items-center px-2 py-1.5 rounded hover:bg-muted/40 ${isNhl ? "grid-cols-[1fr_3.5rem_3.5rem_3.5rem_6rem_6rem]" : "grid-cols-[1fr_3.5rem_3.5rem_6rem_6rem]"}`}
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium truncate flex items-center gap-1.5">
|
||||||
|
{participant.name}
|
||||||
|
{isSynced && (
|
||||||
|
<span className="text-xs text-muted-foreground font-normal">(synced)</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
name={`wins_${participant.id}`}
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
defaultValue={record?.wins ?? ""}
|
||||||
|
placeholder="—"
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
name={`losses_${participant.id}`}
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
defaultValue={record?.losses ?? ""}
|
||||||
|
placeholder="—"
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
{isNhl && (
|
||||||
|
<Input
|
||||||
|
name={`otLosses_${participant.id}`}
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
defaultValue={record?.otLosses ?? ""}
|
||||||
|
placeholder="—"
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Input
|
||||||
|
name={`conference_${participant.id}`}
|
||||||
|
type="text"
|
||||||
|
defaultValue={record?.conference ?? ""}
|
||||||
|
placeholder="Eastern"
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
name={`division_${participant.id}`}
|
||||||
|
type="text"
|
||||||
|
defaultValue={record?.division ?? ""}
|
||||||
|
placeholder="Atlantic"
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full">
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
Save Standings
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Form, Link, redirect, useNavigate } from "react-router";
|
import { Form, Link, redirect, useNavigate, useNavigation } from "react-router";
|
||||||
import { getAuth } from "@clerk/react-router/server";
|
import { getAuth } from "@clerk/react-router/server";
|
||||||
import { isUserAdminByClerkId } from "~/models/user";
|
import { isUserAdminByClerkId } from "~/models/user";
|
||||||
import type { Route } from "./+types/admin.sports-seasons.$id";
|
import type { Route } from "./+types/admin.sports-seasons.$id";
|
||||||
|
|
@ -11,6 +11,14 @@ import { database } from "~/database/context";
|
||||||
import { participantEvSnapshots, seasonSports } from "~/database/schema";
|
import { participantEvSnapshots, seasonSports } from "~/database/schema";
|
||||||
import { eq, desc } from "drizzle-orm";
|
import { eq, desc } from "drizzle-orm";
|
||||||
import { getSimulatorInfo, type SimulatorType } from "~/services/simulations/registry";
|
import { getSimulatorInfo, type SimulatorType } from "~/services/simulations/registry";
|
||||||
|
import { syncStandings } from "~/services/standings-sync/index";
|
||||||
|
import { getLastSyncedAt } from "~/models/regular-season-standings";
|
||||||
|
import {
|
||||||
|
getPendingStandingsMappings,
|
||||||
|
deletePendingStandingsMapping,
|
||||||
|
} from "~/models/pending-standings-mappings";
|
||||||
|
import { updateParticipant } from "~/models/participant";
|
||||||
|
import { upsertRegularSeasonStandings } from "~/models/regular-season-standings";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Input } from "~/components/ui/input";
|
import { Input } from "~/components/ui/input";
|
||||||
import { Label } from "~/components/ui/label";
|
import { Label } from "~/components/ui/label";
|
||||||
|
|
@ -41,7 +49,7 @@ import {
|
||||||
} from "~/components/ui/alert-dialog";
|
} from "~/components/ui/alert-dialog";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { Checkbox } from "~/components/ui/checkbox";
|
import { Checkbox } from "~/components/ui/checkbox";
|
||||||
import { Trash2, Users, Trophy, Calculator, CheckCircle2, Zap, AlertTriangle, Loader2 } from "lucide-react";
|
import { Trash2, Users, Trophy, Calculator, CheckCircle2, Zap, AlertTriangle, Loader2, RefreshCw } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||||
|
|
@ -72,11 +80,23 @@ export async function loader({ params }: Route.LoaderArgs) {
|
||||||
? getSimulatorInfo(sportsSeason.sport.simulatorType as SimulatorType)
|
? getSimulatorInfo(sportsSeason.sport.simulatorType as SimulatorType)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const lastStandingsSyncedAt =
|
||||||
|
sportsSeason.sport?.type === "team"
|
||||||
|
? await getLastSyncedAt(params.id)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const pendingMappings =
|
||||||
|
sportsSeason.sport?.type === "team"
|
||||||
|
? await getPendingStandingsMappings(params.id)
|
||||||
|
: [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sportsSeason,
|
sportsSeason,
|
||||||
participants,
|
participants,
|
||||||
lastSimulatedDate,
|
lastSimulatedDate,
|
||||||
simulatorInfo,
|
simulatorInfo,
|
||||||
|
lastStandingsSyncedAt: lastStandingsSyncedAt?.toISOString() ?? null,
|
||||||
|
pendingMappings,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -114,6 +134,76 @@ export async function action(args: Route.ActionArgs) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (intent === "sync-standings") {
|
||||||
|
try {
|
||||||
|
const result = await syncStandings(params.id);
|
||||||
|
return { success: true, intent: "sync-standings", syncResult: result };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error syncing standings:", error);
|
||||||
|
return {
|
||||||
|
syncError:
|
||||||
|
error instanceof Error ? error.message : "Failed to sync standings. Please try again.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent === "resolve-mapping") {
|
||||||
|
const externalTeamId = formData.get("externalTeamId");
|
||||||
|
const participantId = formData.get("participantId");
|
||||||
|
const standingDataRaw = formData.get("standingData");
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof externalTeamId !== "string" ||
|
||||||
|
!externalTeamId.trim() ||
|
||||||
|
typeof participantId !== "string" ||
|
||||||
|
!participantId.trim() ||
|
||||||
|
typeof standingDataRaw !== "string"
|
||||||
|
) {
|
||||||
|
return { error: "Invalid resolve-mapping payload." };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const standingData = JSON.parse(standingDataRaw) as Record<string, unknown>;
|
||||||
|
|
||||||
|
// Write externalId onto the participant for future ID-first matching
|
||||||
|
await updateParticipant(participantId, { externalId: externalTeamId });
|
||||||
|
|
||||||
|
// Upsert the standing record from the stored standingData
|
||||||
|
await upsertRegularSeasonStandings([
|
||||||
|
{
|
||||||
|
participantId,
|
||||||
|
sportsSeasonId: params.id,
|
||||||
|
wins: (standingData.wins as number) ?? 0,
|
||||||
|
losses: (standingData.losses as number) ?? 0,
|
||||||
|
otLosses: (standingData.otLosses as number | null) ?? null,
|
||||||
|
ties: (standingData.ties as number | null) ?? null,
|
||||||
|
winPct: (standingData.winPct as number) ?? 0,
|
||||||
|
gamesPlayed: (standingData.gamesPlayed as number) ?? 0,
|
||||||
|
gamesBack: (standingData.gamesBack as number | null) ?? null,
|
||||||
|
conference: (standingData.conference as string | null) ?? null,
|
||||||
|
division: (standingData.division as string | null) ?? null,
|
||||||
|
conferenceRank: (standingData.conferenceRank as number | null) ?? null,
|
||||||
|
divisionRank: (standingData.divisionRank as number | null) ?? null,
|
||||||
|
leagueRank: (standingData.leagueRank as number) ?? 0,
|
||||||
|
streak: (standingData.streak as string | null) ?? null,
|
||||||
|
lastTen: (standingData.lastTen as string | null) ?? null,
|
||||||
|
homeRecord: (standingData.homeRecord as string | null) ?? null,
|
||||||
|
awayRecord: (standingData.awayRecord as string | null) ?? null,
|
||||||
|
externalTeamId,
|
||||||
|
syncedAt: new Date(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Remove from pending queue
|
||||||
|
await deletePendingStandingsMapping(params.id, externalTeamId);
|
||||||
|
|
||||||
|
return { success: true, intent: "resolve-mapping", resolvedTeam: String(standingData.teamName ?? "") };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error resolving mapping:", error);
|
||||||
|
return { error: "Failed to resolve mapping. Please try again." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (intent === "finalize-standings") {
|
if (intent === "finalize-standings") {
|
||||||
try {
|
try {
|
||||||
await processSeasonStandings(params.id);
|
await processSeasonStandings(params.id);
|
||||||
|
|
@ -195,8 +285,12 @@ export async function action(args: Route.ActionArgs) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EditSportsSeason({ loaderData, actionData }: Route.ComponentProps) {
|
export default function EditSportsSeason({ loaderData, actionData }: Route.ComponentProps) {
|
||||||
const { sportsSeason, participants, lastSimulatedDate, simulatorInfo } = loaderData;
|
const { sportsSeason, participants, lastSimulatedDate, simulatorInfo, lastStandingsSyncedAt, pendingMappings } = loaderData;
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const isSyncingStandings =
|
||||||
|
navigation.state === "submitting" &&
|
||||||
|
(navigation.formData?.get("intent") as string) === "sync-standings";
|
||||||
const [scoringPattern, setScoringPattern] = useState<string>(sportsSeason.scoringPattern || "");
|
const [scoringPattern, setScoringPattern] = useState<string>(sportsSeason.scoringPattern || "");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -498,6 +592,134 @@ export default function EditSportsSeason({ loaderData, actionData }: Route.Compo
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{sportsSeason.sport?.type === "team" && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Regular Season Standings</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Sync current W/L standings from the official API, or edit manually.
|
||||||
|
{lastStandingsSyncedAt && (
|
||||||
|
<span className="ml-1 text-muted-foreground">
|
||||||
|
Last synced: {new Date(lastStandingsSyncedAt).toLocaleString()}.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => navigate(`/admin/sports-seasons/${sportsSeason.id}/regular-standings`)}
|
||||||
|
>
|
||||||
|
Edit Manually
|
||||||
|
</Button>
|
||||||
|
<Form method="post">
|
||||||
|
<input type="hidden" name="intent" value="sync-standings" />
|
||||||
|
<Button type="submit" size="sm" disabled={isSyncingStandings}>
|
||||||
|
{isSyncingStandings ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Syncing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
|
Sync Standings
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
{(actionData?.success && actionData.intent === "sync-standings") || actionData?.syncError ? (
|
||||||
|
<CardContent>
|
||||||
|
{actionData?.success && actionData.intent === "sync-standings" && actionData.syncResult && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="bg-emerald-500/15 text-emerald-400 px-4 py-3 rounded-md text-sm">
|
||||||
|
Synced {actionData.syncResult.synced} team{actionData.syncResult.synced !== 1 ? "s" : ""} successfully.
|
||||||
|
</div>
|
||||||
|
{actionData.syncResult.unmatched.length > 0 && (
|
||||||
|
<div className="bg-amber-500/15 text-amber-600 dark:text-amber-400 px-4 py-3 rounded-md text-sm">
|
||||||
|
<p className="font-medium mb-1">
|
||||||
|
{actionData.syncResult.unmatched.length} team{actionData.syncResult.unmatched.length !== 1 ? "s" : ""} could not be matched to participants:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc list-inside space-y-0.5">
|
||||||
|
{actionData.syncResult.unmatched.map((u: { teamName: string; externalTeamId: string }) => (
|
||||||
|
<li key={u.externalTeamId}>{u.teamName}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<p className="mt-2 text-xs">
|
||||||
|
Use the "Unmatched Teams" card below to assign these to participants. Future syncs will use the saved ID.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{actionData?.syncError && (
|
||||||
|
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
|
||||||
|
{actionData.syncError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sportsSeason.sport?.type === "team" && pendingMappings.length > 0 && (
|
||||||
|
<Card className="border-amber-500/30">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className="h-4 w-4 text-amber-500" />
|
||||||
|
Unmatched Teams ({pendingMappings.length})
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
These teams from the last sync could not be automatically matched to a participant.
|
||||||
|
Assign each one to the correct participant to resolve.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{actionData?.success && actionData.intent === "resolve-mapping" && (
|
||||||
|
<div className="bg-emerald-500/15 text-emerald-400 px-4 py-3 rounded-md text-sm">
|
||||||
|
Resolved: {actionData.resolvedTeam}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{pendingMappings.map((mapping) => (
|
||||||
|
<Form key={mapping.externalTeamId} method="post" className="flex items-center gap-3">
|
||||||
|
<input type="hidden" name="intent" value="resolve-mapping" />
|
||||||
|
<input type="hidden" name="externalTeamId" value={mapping.externalTeamId} />
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="standingData"
|
||||||
|
value={JSON.stringify(mapping.standingData)}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate">{mapping.teamName}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">ID: {mapping.externalTeamId}</p>
|
||||||
|
</div>
|
||||||
|
<Select name="participantId" required>
|
||||||
|
<SelectTrigger className="w-56">
|
||||||
|
<SelectValue placeholder="Select participant…" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{participants.map((p) => (
|
||||||
|
<SelectItem key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button type="submit" size="sm">
|
||||||
|
Resolve
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{sportsSeason.scoringPattern === "season_standings" && (
|
{sportsSeason.scoringPattern === "season_standings" && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ import {
|
||||||
getUpcomingScoringEvents,
|
getUpcomingScoringEvents,
|
||||||
getRecentCompletedEvents,
|
getRecentCompletedEvents,
|
||||||
} from "~/models/scoring-event";
|
} from "~/models/scoring-event";
|
||||||
|
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
|
||||||
|
import { getAllParticipantEVsForSeason } from "~/models/participant-expected-value";
|
||||||
import { getBracketTemplate, getOrderedRoundsFromMatches } from "~/lib/bracket-templates";
|
import { getBracketTemplate, getOrderedRoundsFromMatches } from "~/lib/bracket-templates";
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
|
|
@ -242,9 +244,24 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
getRecentCompletedEvents(sportsSeasonId, 3),
|
getRecentCompletedEvents(sportsSeasonId, 3),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Fetch regular season standings for team-sport playoff_bracket seasons (NBA, NHL)
|
||||||
|
const isTeamBracket =
|
||||||
|
scoringPattern === "playoff_bracket" && sportsSeason.sport?.type === "team";
|
||||||
|
const [regularSeasonStandings, regularSeasonEvs] = isTeamBracket
|
||||||
|
? await Promise.all([
|
||||||
|
getRegularSeasonStandings(sportsSeasonId),
|
||||||
|
getAllParticipantEVsForSeason(sportsSeasonId),
|
||||||
|
])
|
||||||
|
: [[], []];
|
||||||
|
|
||||||
// Derive seasonIsFinalized from status
|
// Derive seasonIsFinalized from status
|
||||||
const seasonIsFinalized = sportsSeason.status === "completed";
|
const seasonIsFinalized = sportsSeason.status === "completed";
|
||||||
|
|
||||||
|
// Build participantId → expectedValue map for display
|
||||||
|
const participantEvs = Object.fromEntries(
|
||||||
|
regularSeasonEvs.map((ev) => [ev.participantId, ev.expectedValue])
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
league,
|
league,
|
||||||
season,
|
season,
|
||||||
|
|
@ -262,5 +279,7 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
upcomingEvents,
|
upcomingEvents,
|
||||||
recentEvents,
|
recentEvents,
|
||||||
seasonIsFinalized,
|
seasonIsFinalized,
|
||||||
|
regularSeasonStandings,
|
||||||
|
participantEvs,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import type { Route } from "./+types/$leagueId.sports-seasons.$sportsSeasonId";
|
||||||
import { loader } from "./$leagueId.sports-seasons.$sportsSeasonId.server";
|
import { loader } from "./$leagueId.sports-seasons.$sportsSeasonId.server";
|
||||||
import { SportSeasonDisplay } from "~/components/scoring/SportSeasonDisplay";
|
import { SportSeasonDisplay } from "~/components/scoring/SportSeasonDisplay";
|
||||||
import { EventSchedule } from "~/components/sport-season/EventSchedule";
|
import { EventSchedule } from "~/components/sport-season/EventSchedule";
|
||||||
|
import { RegularSeasonStandings } from "~/components/sport-season/RegularSeasonStandings";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { ArrowLeft, CheckCircle2, Clock, Zap } from "lucide-react";
|
import { ArrowLeft, CheckCircle2, Clock, Zap } from "lucide-react";
|
||||||
|
|
@ -62,8 +63,29 @@ export default function SportSeasonDetail({
|
||||||
upcomingEvents,
|
upcomingEvents,
|
||||||
recentEvents,
|
recentEvents,
|
||||||
seasonIsFinalized,
|
seasonIsFinalized,
|
||||||
|
regularSeasonStandings,
|
||||||
|
participantEvs,
|
||||||
} = loaderData;
|
} = loaderData;
|
||||||
|
|
||||||
|
const hasBracket = playoffMatches && playoffMatches.length > 0;
|
||||||
|
const hasStandings = regularSeasonStandings && regularSeasonStandings.length > 0;
|
||||||
|
const showOtLosses = hasStandings && regularSeasonStandings.some((s: any) => s.otLosses != null);
|
||||||
|
|
||||||
|
const simulatorType = sportsSeason.sport?.simulatorType;
|
||||||
|
const standingsDisplayMode =
|
||||||
|
simulatorType === "nhl_bracket" ? "nhl-divisions" : "flat";
|
||||||
|
// NBA: 10 teams make the postseason (6 auto-qualify + 4 play-in)
|
||||||
|
// NHL: handled by the nhl-divisions mode (3 per div + 2 wild cards)
|
||||||
|
const playoffSpots = simulatorType === "nba_bracket" ? 10 : 8;
|
||||||
|
|
||||||
|
// Build ownership map for RegularSeasonStandings
|
||||||
|
const ownershipMap = Object.fromEntries(
|
||||||
|
teamOwnerships.map((o: any) => [
|
||||||
|
o.participantId,
|
||||||
|
{ teamName: o.teamName, ownerName: o.ownerName ?? "", teamId: o.teamId },
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto py-8 px-4">
|
<div className="container mx-auto py-8 px-4">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
|
|
@ -87,6 +109,21 @@ export default function SportSeasonDetail({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Regular season standings — show above bracket when no bracket exists yet */}
|
||||||
|
{hasStandings && !hasBracket && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<RegularSeasonStandings
|
||||||
|
standings={regularSeasonStandings as any}
|
||||||
|
teamOwnerships={ownershipMap}
|
||||||
|
userParticipantIds={userParticipantIds}
|
||||||
|
showOtLosses={showOtLosses}
|
||||||
|
participantEvs={participantEvs}
|
||||||
|
displayMode={standingsDisplayMode as any}
|
||||||
|
playoffSpots={playoffSpots}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Event schedule - hidden for bracket sports (the bracket itself is the event) */}
|
{/* Event schedule - hidden for bracket sports (the bracket itself is the event) */}
|
||||||
{scoringPattern !== "playoff_bracket" &&
|
{scoringPattern !== "playoff_bracket" &&
|
||||||
(upcomingEvents.length > 0 || recentEvents.length > 0) && (
|
(upcomingEvents.length > 0 || recentEvents.length > 0) && (
|
||||||
|
|
@ -98,7 +135,8 @@ export default function SportSeasonDetail({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SportSeasonDisplay
|
{/* Hide bracket display when standings exist but no bracket matches have been set yet */}
|
||||||
|
{!(scoringPattern === "playoff_bracket" && hasStandings && !hasBracket) && <SportSeasonDisplay
|
||||||
scoringPattern={scoringPattern as any}
|
scoringPattern={scoringPattern as any}
|
||||||
sportSeasonName={sportsSeason.name}
|
sportSeasonName={sportsSeason.name}
|
||||||
sportName={sportsSeason.sport.name}
|
sportName={sportsSeason.sport.name}
|
||||||
|
|
@ -122,7 +160,22 @@ export default function SportSeasonDetail({
|
||||||
userParticipantIds={userParticipantIds}
|
userParticipantIds={userParticipantIds}
|
||||||
scoringRules={season}
|
scoringRules={season}
|
||||||
showOwnership={true}
|
showOwnership={true}
|
||||||
/>
|
/>}
|
||||||
|
|
||||||
|
{/* Regular season standings — show below bracket once bracket exists */}
|
||||||
|
{hasStandings && hasBracket && (
|
||||||
|
<div className="mt-8">
|
||||||
|
<RegularSeasonStandings
|
||||||
|
standings={regularSeasonStandings as any}
|
||||||
|
teamOwnerships={ownershipMap}
|
||||||
|
userParticipantIds={userParticipantIds}
|
||||||
|
showOtLosses={showOtLosses}
|
||||||
|
participantEvs={participantEvs}
|
||||||
|
displayMode={standingsDisplayMode as any}
|
||||||
|
playoffSpots={playoffSpots}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ import { database } from "~/database/context";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import type { Simulator, SimulationResult } from "./types";
|
import type { Simulator, SimulationResult } from "./types";
|
||||||
|
import { normalizeTeamName } from "~/lib/normalize-team-name";
|
||||||
|
|
||||||
// ─── Simulation parameters ────────────────────────────────────────────────────
|
// ─── Simulation parameters ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -211,10 +212,7 @@ const TEAMS_DATA: Record<string, NbaTeamData> = {
|
||||||
|
|
||||||
// ─── Public helpers (exported for unit testing) ───────────────────────────────
|
// ─── Public helpers (exported for unit testing) ───────────────────────────────
|
||||||
|
|
||||||
/** Normalize a team name for lookup (lowercase, trimmed, collapsed whitespace). */
|
export { normalizeTeamName };
|
||||||
export function normalizeTeamName(name: string): string {
|
|
||||||
return name.toLowerCase().trim().replace(/\s+/g, " ");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Look up team data by participant name (case-insensitive). */
|
/** Look up team data by participant name (case-insensitive). */
|
||||||
export function getTeamData(name: string): NbaTeamData | undefined {
|
export function getTeamData(name: string): NbaTeamData | undefined {
|
||||||
|
|
|
||||||
171
app/services/standings-sync/__tests__/nba.test.ts
Normal file
171
app/services/standings-sync/__tests__/nba.test.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { NbaStandingsAdapter } from "../nba";
|
||||||
|
|
||||||
|
function makeStat(name: string, value: number, displayValue?: string) {
|
||||||
|
return { name, value, displayValue: displayValue ?? String(value) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const SAMPLE_NBA_RESPONSE = {
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
name: "Eastern Conference",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
name: "Atlantic Division",
|
||||||
|
standings: {
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
team: { id: "2", displayName: "Boston Celtics", abbreviation: "BOS" },
|
||||||
|
stats: [
|
||||||
|
makeStat("wins", 52),
|
||||||
|
makeStat("losses", 16),
|
||||||
|
makeStat("winPercent", 0.765),
|
||||||
|
makeStat("gamesBehind", 0),
|
||||||
|
makeStat("playoffSeed", 1),
|
||||||
|
{ name: "streak", value: 5, displayValue: "W5" },
|
||||||
|
makeStat("homeWins", 28),
|
||||||
|
makeStat("homeLosses", 7),
|
||||||
|
makeStat("awayWins", 24),
|
||||||
|
makeStat("awayLosses", 9),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
team: { id: "7", displayName: "Toronto Raptors", abbreviation: "TOR" },
|
||||||
|
stats: [
|
||||||
|
makeStat("wins", 22),
|
||||||
|
makeStat("losses", 46),
|
||||||
|
makeStat("winPercent", 0.324),
|
||||||
|
makeStat("gamesBehind", 30),
|
||||||
|
makeStat("playoffSeed", 12),
|
||||||
|
{ name: "streak", value: 2, displayValue: "L2" },
|
||||||
|
makeStat("homeWins", 12),
|
||||||
|
makeStat("homeLosses", 22),
|
||||||
|
makeStat("awayWins", 10),
|
||||||
|
makeStat("awayLosses", 24),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Western Conference",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
name: "Northwest Division",
|
||||||
|
standings: {
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
team: { id: "21", displayName: "Oklahoma City Thunder", abbreviation: "OKC" },
|
||||||
|
stats: [
|
||||||
|
makeStat("wins", 58),
|
||||||
|
makeStat("losses", 10),
|
||||||
|
makeStat("winPercent", 0.853),
|
||||||
|
makeStat("gamesBehind", 0),
|
||||||
|
makeStat("playoffSeed", 1),
|
||||||
|
{ name: "streak", value: 4, displayValue: "W4" },
|
||||||
|
makeStat("homeWins", 30),
|
||||||
|
makeStat("homeLosses", 4),
|
||||||
|
makeStat("awayWins", 28),
|
||||||
|
makeStat("awayLosses", 6),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("NbaStandingsAdapter", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal("fetch", vi.fn());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps ESPN API response to FetchedStandingsRecord[]", async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => SAMPLE_NBA_RESPONSE,
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const adapter = new NbaStandingsAdapter();
|
||||||
|
const records = await adapter.fetchStandings();
|
||||||
|
|
||||||
|
expect(records).toHaveLength(3);
|
||||||
|
|
||||||
|
const okc = records.find((r) => r.teamName === "Oklahoma City Thunder")!;
|
||||||
|
expect(okc).toBeDefined();
|
||||||
|
expect(okc.wins).toBe(58);
|
||||||
|
expect(okc.losses).toBe(10);
|
||||||
|
expect(okc.conference).toBe("Western Conference");
|
||||||
|
expect(okc.division).toBe("Northwest Division");
|
||||||
|
expect(okc.otLosses).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("assigns conference correctly", async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => SAMPLE_NBA_RESPONSE,
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const adapter = new NbaStandingsAdapter();
|
||||||
|
const records = await adapter.fetchStandings();
|
||||||
|
|
||||||
|
const bos = records.find((r) => r.teamName === "Boston Celtics")!;
|
||||||
|
expect(bos.conference).toBe("Eastern Conference");
|
||||||
|
expect(bos.division).toBe("Atlantic Division");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts streak from stats array", async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => SAMPLE_NBA_RESPONSE,
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const adapter = new NbaStandingsAdapter();
|
||||||
|
const records = await adapter.fetchStandings();
|
||||||
|
|
||||||
|
const bos = records.find((r) => r.teamName === "Boston Celtics")!;
|
||||||
|
expect(bos.streak).toBe("W5");
|
||||||
|
|
||||||
|
const tor = records.find((r) => r.teamName === "Toronto Raptors")!;
|
||||||
|
expect(tor.streak).toBe("L2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not set otLosses (NBA has no OT losses)", async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => SAMPLE_NBA_RESPONSE,
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const adapter = new NbaStandingsAdapter();
|
||||||
|
const records = await adapter.fetchStandings();
|
||||||
|
|
||||||
|
for (const record of records) {
|
||||||
|
expect(record.otLosses).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on non-ok response", async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 429,
|
||||||
|
statusText: "Too Many Requests",
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const adapter = new NbaStandingsAdapter();
|
||||||
|
await expect(adapter.fetchStandings()).rejects.toThrow("429");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when no entries returned", async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ children: [] }),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const adapter = new NbaStandingsAdapter();
|
||||||
|
await expect(adapter.fetchStandings()).rejects.toThrow("no entries");
|
||||||
|
});
|
||||||
|
});
|
||||||
141
app/services/standings-sync/__tests__/nhl.test.ts
Normal file
141
app/services/standings-sync/__tests__/nhl.test.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { NhlStandingsAdapter } from "../nhl";
|
||||||
|
|
||||||
|
const SAMPLE_NHL_RESPONSE = {
|
||||||
|
standings: [
|
||||||
|
{
|
||||||
|
teamName: { default: "Boston Bruins" },
|
||||||
|
teamAbbrev: { default: "BOS" },
|
||||||
|
conferenceName: "Eastern",
|
||||||
|
divisionName: "Atlantic",
|
||||||
|
gamesPlayed: 68,
|
||||||
|
wins: 42,
|
||||||
|
losses: 20,
|
||||||
|
otLosses: 6,
|
||||||
|
points: 90,
|
||||||
|
pointPctg: 0.662,
|
||||||
|
winPctg: 0.618,
|
||||||
|
l10Wins: 7,
|
||||||
|
l10Losses: 2,
|
||||||
|
l10OtLosses: 1,
|
||||||
|
streakCode: "W",
|
||||||
|
streakCount: 3,
|
||||||
|
homeWins: 24,
|
||||||
|
homeLosses: 9,
|
||||||
|
homeOtLosses: 2,
|
||||||
|
roadWins: 18,
|
||||||
|
roadLosses: 11,
|
||||||
|
roadOtLosses: 4,
|
||||||
|
conferenceSequence: 1,
|
||||||
|
divisionSequence: 1,
|
||||||
|
leagueSequence: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
teamName: { default: "Toronto Maple Leafs" },
|
||||||
|
teamAbbrev: { default: "TOR" },
|
||||||
|
conferenceName: "Eastern",
|
||||||
|
divisionName: "Atlantic",
|
||||||
|
gamesPlayed: 70,
|
||||||
|
wins: 38,
|
||||||
|
losses: 25,
|
||||||
|
otLosses: 7,
|
||||||
|
points: 83,
|
||||||
|
pointPctg: 0.593,
|
||||||
|
winPctg: 0.543,
|
||||||
|
l10Wins: 5,
|
||||||
|
l10Losses: 4,
|
||||||
|
l10OtLosses: 1,
|
||||||
|
streakCode: "L",
|
||||||
|
streakCount: 2,
|
||||||
|
homeWins: 20,
|
||||||
|
homeLosses: 12,
|
||||||
|
homeOtLosses: 3,
|
||||||
|
roadWins: 18,
|
||||||
|
roadLosses: 13,
|
||||||
|
roadOtLosses: 4,
|
||||||
|
conferenceSequence: 3,
|
||||||
|
divisionSequence: 2,
|
||||||
|
leagueSequence: 5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("NhlStandingsAdapter", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal("fetch", vi.fn());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps NHL API response to FetchedStandingsRecord[]", async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => SAMPLE_NHL_RESPONSE,
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const adapter = new NhlStandingsAdapter();
|
||||||
|
const records = await adapter.fetchStandings();
|
||||||
|
|
||||||
|
expect(records).toHaveLength(2);
|
||||||
|
|
||||||
|
const bos = records[0];
|
||||||
|
expect(bos.teamName).toBe("Boston Bruins");
|
||||||
|
expect(bos.externalTeamId).toBe("BOS");
|
||||||
|
expect(bos.wins).toBe(42);
|
||||||
|
expect(bos.losses).toBe(20);
|
||||||
|
expect(bos.otLosses).toBe(6);
|
||||||
|
expect(bos.gamesPlayed).toBe(68);
|
||||||
|
expect(bos.conference).toBe("Eastern");
|
||||||
|
expect(bos.division).toBe("Atlantic");
|
||||||
|
expect(bos.conferenceRank).toBe(1);
|
||||||
|
expect(bos.divisionRank).toBe(1);
|
||||||
|
expect(bos.leagueRank).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats streak correctly", async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => SAMPLE_NHL_RESPONSE,
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const adapter = new NhlStandingsAdapter();
|
||||||
|
const records = await adapter.fetchStandings();
|
||||||
|
|
||||||
|
expect(records[0].streak).toBe("W3");
|
||||||
|
expect(records[1].streak).toBe("L2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats lastTen as W-L-OTL", async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => SAMPLE_NHL_RESPONSE,
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const adapter = new NhlStandingsAdapter();
|
||||||
|
const records = await adapter.fetchStandings();
|
||||||
|
|
||||||
|
expect(records[0].lastTen).toBe("7-2-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats home and away records", async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => SAMPLE_NHL_RESPONSE,
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const adapter = new NhlStandingsAdapter();
|
||||||
|
const records = await adapter.fetchStandings();
|
||||||
|
|
||||||
|
expect(records[0].homeRecord).toBe("24-9-2");
|
||||||
|
expect(records[0].awayRecord).toBe("18-11-4");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on non-ok response", async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 503,
|
||||||
|
statusText: "Service Unavailable",
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const adapter = new NhlStandingsAdapter();
|
||||||
|
await expect(adapter.fetchStandings()).rejects.toThrow("503");
|
||||||
|
});
|
||||||
|
});
|
||||||
48
app/services/standings-sync/__tests__/sync.test.ts
Normal file
48
app/services/standings-sync/__tests__/sync.test.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { findMatchingTeamName } from "~/lib/normalize-team-name";
|
||||||
|
|
||||||
|
// Pure unit tests for the name-matching logic used by syncStandings.
|
||||||
|
// The full orchestrator requires DB context; those are covered by integration tests.
|
||||||
|
|
||||||
|
describe("findMatchingTeamName (sync name-matching logic)", () => {
|
||||||
|
const participants = [
|
||||||
|
"Boston Celtics",
|
||||||
|
"Los Angeles Lakers",
|
||||||
|
"Golden State Warriors",
|
||||||
|
"Oklahoma City Thunder",
|
||||||
|
"San Antonio Spurs",
|
||||||
|
"New York Knicks",
|
||||||
|
];
|
||||||
|
|
||||||
|
it("matches exact names", () => {
|
||||||
|
expect(findMatchingTeamName("Boston Celtics", participants)).toBe("Boston Celtics");
|
||||||
|
expect(findMatchingTeamName("New York Knicks", participants)).toBe("New York Knicks");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is case-insensitive", () => {
|
||||||
|
expect(findMatchingTeamName("golden state warriors", participants)).toBe(
|
||||||
|
"Golden State Warriors"
|
||||||
|
);
|
||||||
|
expect(findMatchingTeamName("SAN ANTONIO SPURS", participants)).toBe("San Antonio Spurs");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches partial city+team via substring", () => {
|
||||||
|
expect(findMatchingTeamName("Golden State", participants)).toBe("Golden State Warriors");
|
||||||
|
expect(findMatchingTeamName("Oklahoma City", participants)).toBe("Oklahoma City Thunder");
|
||||||
|
// "New York" is a substring of "New York Knicks"
|
||||||
|
expect(findMatchingTeamName("New York", participants)).toBe("New York Knicks");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for truly unmatched names", () => {
|
||||||
|
expect(findMatchingTeamName("Phoenix Suns", participants)).toBeNull();
|
||||||
|
expect(findMatchingTeamName("Charlotte Hornets", participants)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for empty string", () => {
|
||||||
|
expect(findMatchingTeamName("", participants)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles extra whitespace gracefully", () => {
|
||||||
|
expect(findMatchingTeamName(" Boston Celtics ", participants)).toBe("Boston Celtics");
|
||||||
|
});
|
||||||
|
});
|
||||||
31
app/services/standings-sync/f1.ts
Normal file
31
app/services/standings-sync/f1.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import type { FetchedStandingsRecord, StandingsSyncAdapter } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* F1 / IndyCar championship standings adapter.
|
||||||
|
*
|
||||||
|
* TODO: Implement using one of:
|
||||||
|
* - OpenF1 API: https://openf1.org/ (free, real-time, no key required)
|
||||||
|
* - Ergast API: https://ergast.com/mrd/ (free, historical, being deprecated)
|
||||||
|
* - Official F1 API: requires a key but is comprehensive
|
||||||
|
*
|
||||||
|
* NOTE: For season_standings sports, the sync result should upsert into
|
||||||
|
* participantSeasonResults (currentPoints + currentPosition) rather than
|
||||||
|
* regularSeasonStandings. The orchestrator in index.ts handles the routing.
|
||||||
|
*/
|
||||||
|
export class F1StandingsAdapter implements StandingsSyncAdapter {
|
||||||
|
async fetchStandings(): Promise<FetchedStandingsRecord[]> {
|
||||||
|
throw new Error(
|
||||||
|
"F1/IndyCar standings sync not yet implemented. " +
|
||||||
|
"Implement using OpenF1 API (https://openf1.org/) or Ergast API."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class IndyCarStandingsAdapter implements StandingsSyncAdapter {
|
||||||
|
async fetchStandings(): Promise<FetchedStandingsRecord[]> {
|
||||||
|
throw new Error(
|
||||||
|
"IndyCar standings sync not yet implemented. " +
|
||||||
|
"Implement using the IndyCar official results API."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
139
app/services/standings-sync/index.ts
Normal file
139
app/services/standings-sync/index.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
import { database } from "~/database/context";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import * as schema from "~/database/schema";
|
||||||
|
import { findParticipantsBySportsSeasonId, updateParticipant } from "~/models/participant";
|
||||||
|
import { upsertRegularSeasonStandings } from "~/models/regular-season-standings";
|
||||||
|
import { upsertPendingStandingsMappings } from "~/models/pending-standings-mappings";
|
||||||
|
import { findMatchingTeamName } from "~/lib/normalize-team-name";
|
||||||
|
import { NhlStandingsAdapter } from "./nhl";
|
||||||
|
import { NbaStandingsAdapter } from "./nba";
|
||||||
|
import type { StandingsSyncAdapter, SyncResult, UnmatchedTeam } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a simulator type to its standings sync adapter.
|
||||||
|
* Returns null for sports that don't use regularSeasonStandings
|
||||||
|
* (e.g. season_standings sports like F1 will use a different path when implemented).
|
||||||
|
*/
|
||||||
|
function getAdapter(simulatorType: string): StandingsSyncAdapter {
|
||||||
|
switch (simulatorType) {
|
||||||
|
case "nba_bracket":
|
||||||
|
return new NbaStandingsAdapter();
|
||||||
|
case "nhl_bracket":
|
||||||
|
return new NhlStandingsAdapter();
|
||||||
|
case "f1_standings":
|
||||||
|
throw new Error(
|
||||||
|
"F1 standings sync is not yet implemented. Use the manual standings page."
|
||||||
|
);
|
||||||
|
case "indycar_standings":
|
||||||
|
throw new Error(
|
||||||
|
"IndyCar standings sync is not yet implemented. Use the manual standings page."
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
throw new Error(
|
||||||
|
`No standings sync adapter available for simulator type "${simulatorType}". ` +
|
||||||
|
"Only NBA and NHL are currently supported."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync current standings from the appropriate external API for a sports season.
|
||||||
|
* Matches API team names to participants by name and bulk-upserts the results.
|
||||||
|
*
|
||||||
|
* Returns the count of synced teams and any API team names that couldn't be matched.
|
||||||
|
*/
|
||||||
|
export async function syncStandings(sportsSeasonId: string): Promise<SyncResult> {
|
||||||
|
const db = database();
|
||||||
|
|
||||||
|
// Load sports season with sport relation
|
||||||
|
const sportsSeason = await db.query.sportsSeasons.findFirst({
|
||||||
|
where: eq(schema.sportsSeasons.id, sportsSeasonId),
|
||||||
|
with: { sport: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!sportsSeason) {
|
||||||
|
throw new Error(`Sports season ${sportsSeasonId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const simulatorType = sportsSeason.sport?.simulatorType;
|
||||||
|
if (!simulatorType) {
|
||||||
|
const sportName = sportsSeason.sport?.name ?? "(no sport linked)";
|
||||||
|
throw new Error(`Sport "${sportName}" has no simulator type configured`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const adapter = getAdapter(simulatorType);
|
||||||
|
const fetchedRecords = await adapter.fetchStandings();
|
||||||
|
|
||||||
|
// Load all participants for this season
|
||||||
|
const participants = await findParticipantsBySportsSeasonId(sportsSeasonId);
|
||||||
|
const participantNames = participants.map((p) => p.name);
|
||||||
|
const participantByName = new Map(participants.map((p) => [p.name, p]));
|
||||||
|
// Build a map of externalId → participant for ID-first matching
|
||||||
|
const participantByExternalId = new Map(
|
||||||
|
participants.filter((p) => p.externalId).map((p) => [p.externalId!, p])
|
||||||
|
);
|
||||||
|
|
||||||
|
const toUpsert: Parameters<typeof upsertRegularSeasonStandings>[0] = [];
|
||||||
|
const unmatchedRecords: typeof fetchedRecords = [];
|
||||||
|
|
||||||
|
for (const record of fetchedRecords) {
|
||||||
|
// Strategy A: try externalId-first (bypasses name matching entirely after first sync)
|
||||||
|
let participant = participantByExternalId.get(record.externalTeamId) ?? null;
|
||||||
|
|
||||||
|
if (!participant) {
|
||||||
|
// Fall back to name matching
|
||||||
|
const matchedName = findMatchingTeamName(record.teamName, participantNames);
|
||||||
|
if (matchedName) {
|
||||||
|
participant = participantByName.get(matchedName)!;
|
||||||
|
// Write-back: persist externalId so future syncs skip name matching for this team
|
||||||
|
if (!participant.externalId) {
|
||||||
|
await updateParticipant(participant.id, { externalId: record.externalTeamId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!participant) {
|
||||||
|
unmatchedRecords.push(record);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
toUpsert.push({
|
||||||
|
participantId: participant.id,
|
||||||
|
sportsSeasonId,
|
||||||
|
wins: record.wins,
|
||||||
|
losses: record.losses,
|
||||||
|
otLosses: record.otLosses ?? null,
|
||||||
|
ties: record.ties ?? null,
|
||||||
|
winPct: record.winPct,
|
||||||
|
gamesPlayed: record.gamesPlayed,
|
||||||
|
gamesBack: record.gamesBack ?? null,
|
||||||
|
conference: record.conference ?? null,
|
||||||
|
division: record.division ?? null,
|
||||||
|
conferenceRank: record.conferenceRank ?? null,
|
||||||
|
divisionRank: record.divisionRank ?? null,
|
||||||
|
leagueRank: record.leagueRank,
|
||||||
|
streak: record.streak ?? null,
|
||||||
|
lastTen: record.lastTen ?? null,
|
||||||
|
homeRecord: record.homeRecord ?? null,
|
||||||
|
awayRecord: record.awayRecord ?? null,
|
||||||
|
externalTeamId: record.externalTeamId,
|
||||||
|
syncedAt: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toUpsert.length > 0) {
|
||||||
|
await upsertRegularSeasonStandings(toUpsert);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist unmatched records so admin can resolve them without losing data on page reload
|
||||||
|
if (unmatchedRecords.length > 0) {
|
||||||
|
await upsertPendingStandingsMappings(sportsSeasonId, unmatchedRecords);
|
||||||
|
}
|
||||||
|
|
||||||
|
const unmatched: UnmatchedTeam[] = unmatchedRecords.map((r) => ({
|
||||||
|
teamName: r.teamName,
|
||||||
|
externalTeamId: r.externalTeamId,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { synced: toUpsert.length, unmatched };
|
||||||
|
}
|
||||||
156
app/services/standings-sync/nba.ts
Normal file
156
app/services/standings-sync/nba.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
import type { FetchedStandingsRecord, StandingsSyncAdapter } from "./types";
|
||||||
|
|
||||||
|
const NBA_STANDINGS_URL =
|
||||||
|
"https://site.api.espn.com/apis/v2/sports/basketball/nba/standings";
|
||||||
|
|
||||||
|
interface EspnStat {
|
||||||
|
name: string;
|
||||||
|
displayName?: string;
|
||||||
|
shortDisplayName?: string;
|
||||||
|
description?: string;
|
||||||
|
abbreviation?: string;
|
||||||
|
type?: string;
|
||||||
|
value?: number;
|
||||||
|
displayValue?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EspnTeam {
|
||||||
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
shortDisplayName?: string;
|
||||||
|
abbreviation?: string;
|
||||||
|
location?: string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EspnStandingsEntry {
|
||||||
|
team: EspnTeam;
|
||||||
|
stats: EspnStat[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EspnStandingsGroup {
|
||||||
|
name?: string;
|
||||||
|
standings?: { entries?: EspnStandingsEntry[] };
|
||||||
|
children?: EspnStandingsGroup[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EspnStandingsResponse {
|
||||||
|
children?: EspnStandingsGroup[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function statsMap(stats: EspnStat[]): Map<string, EspnStat> {
|
||||||
|
const map = new Map<string, EspnStat>();
|
||||||
|
for (const stat of stats) {
|
||||||
|
map.set(stat.name, stat);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flatten the ESPN standings response (conference → division → entries).
|
||||||
|
* Returns enriched entries with conference and division names attached.
|
||||||
|
*/
|
||||||
|
function flattenEspnStandings(
|
||||||
|
response: EspnStandingsResponse
|
||||||
|
): Array<{ entry: EspnStandingsEntry; conference: string; division: string }> {
|
||||||
|
const results: Array<{
|
||||||
|
entry: EspnStandingsEntry;
|
||||||
|
conference: string;
|
||||||
|
division: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (const confGroup of response.children ?? []) {
|
||||||
|
const conferenceName = confGroup.name ?? "";
|
||||||
|
|
||||||
|
// Some ESPN responses have nested division children
|
||||||
|
if (confGroup.children && confGroup.children.length > 0) {
|
||||||
|
for (const divGroup of confGroup.children) {
|
||||||
|
const divisionName = divGroup.name ?? "";
|
||||||
|
for (const entry of divGroup.standings?.entries ?? []) {
|
||||||
|
results.push({ entry, conference: conferenceName, division: divisionName });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Flat conference without division sub-groups
|
||||||
|
for (const entry of confGroup.standings?.entries ?? []) {
|
||||||
|
results.push({ entry, conference: conferenceName, division: "" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NbaStandingsAdapter implements StandingsSyncAdapter {
|
||||||
|
async fetchStandings(): Promise<FetchedStandingsRecord[]> {
|
||||||
|
const response = await fetch(NBA_STANDINGS_URL);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`NBA standings API returned ${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = (await response.json()) as EspnStandingsResponse;
|
||||||
|
const flattened = flattenEspnStandings(json);
|
||||||
|
|
||||||
|
if (flattened.length === 0) {
|
||||||
|
throw new Error("NBA standings API returned no entries — response shape may have changed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign league rank by sorting on wins desc, then losses asc
|
||||||
|
const sorted = [...flattened].sort((a, b) => {
|
||||||
|
const winsA = statsMap(a.entry.stats).get("wins")?.value ?? 0;
|
||||||
|
const winsB = statsMap(b.entry.stats).get("wins")?.value ?? 0;
|
||||||
|
if (winsB !== winsA) return winsB - winsA;
|
||||||
|
const lossA = statsMap(a.entry.stats).get("losses")?.value ?? 0;
|
||||||
|
const lossB = statsMap(b.entry.stats).get("losses")?.value ?? 0;
|
||||||
|
return lossA - lossB;
|
||||||
|
});
|
||||||
|
|
||||||
|
return sorted.map(({ entry, conference, division }, leagueIdx): FetchedStandingsRecord => {
|
||||||
|
const sm = statsMap(entry.stats);
|
||||||
|
const wins = sm.get("wins")?.value ?? 0;
|
||||||
|
const losses = sm.get("losses")?.value ?? 0;
|
||||||
|
const winPercent = sm.get("winPercent")?.value ?? sm.get("winPct")?.value ?? 0;
|
||||||
|
const gamesBehind = sm.get("gamesBehind")?.value;
|
||||||
|
const streak = sm.get("streak")?.displayValue ?? sm.get("streakSummary")?.displayValue;
|
||||||
|
|
||||||
|
// ESPN returns last-10 as "Last Ten Games" with a displayValue like "7-3"
|
||||||
|
const lastTen =
|
||||||
|
sm.get("Last Ten Games")?.displayValue ??
|
||||||
|
sm.get("L10")?.displayValue ??
|
||||||
|
undefined;
|
||||||
|
|
||||||
|
// Seed within conference — ESPN key is "playoffSeed", value is a float (1.0, 2.0 ...)
|
||||||
|
const playoffSeedStat = sm.get("playoffSeed");
|
||||||
|
const conferenceRank =
|
||||||
|
playoffSeedStat?.value != null
|
||||||
|
? Math.round(playoffSeedStat.value)
|
||||||
|
: playoffSeedStat?.displayValue
|
||||||
|
? parseInt(playoffSeedStat.displayValue, 10) || undefined
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const gamesPlayed = wins + losses;
|
||||||
|
|
||||||
|
// Home/road records — ESPN returns these as displayValue strings (e.g. "24-17")
|
||||||
|
const homeRecord = sm.get("Home")?.displayValue ?? undefined;
|
||||||
|
const awayRecord = sm.get("Road")?.displayValue ?? undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
teamName: entry.team.displayName,
|
||||||
|
externalTeamId: entry.team.id,
|
||||||
|
wins: Math.round(wins),
|
||||||
|
losses: Math.round(losses),
|
||||||
|
winPct: winPercent,
|
||||||
|
gamesPlayed,
|
||||||
|
gamesBack: gamesBehind,
|
||||||
|
conference,
|
||||||
|
division: division || undefined,
|
||||||
|
conferenceRank,
|
||||||
|
leagueRank: leagueIdx + 1,
|
||||||
|
streak,
|
||||||
|
lastTen,
|
||||||
|
homeRecord,
|
||||||
|
awayRecord,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
78
app/services/standings-sync/nhl.ts
Normal file
78
app/services/standings-sync/nhl.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
import type { FetchedStandingsRecord, StandingsSyncAdapter } from "./types";
|
||||||
|
|
||||||
|
const NHL_STANDINGS_URL = "https://api-web.nhle.com/v1/standings/now";
|
||||||
|
|
||||||
|
interface NhlTeamRecord {
|
||||||
|
teamName: { default: string };
|
||||||
|
teamAbbrev: { default: string };
|
||||||
|
teamLogo?: string;
|
||||||
|
conferenceName: string;
|
||||||
|
divisionName: string;
|
||||||
|
gamesPlayed: number;
|
||||||
|
wins: number;
|
||||||
|
losses: number;
|
||||||
|
otLosses: number;
|
||||||
|
points: number;
|
||||||
|
pointPctg: number;
|
||||||
|
winPctg: number;
|
||||||
|
l10Wins: number;
|
||||||
|
l10Losses: number;
|
||||||
|
l10OtLosses: number;
|
||||||
|
streakCode: string; // "W" or "L"
|
||||||
|
streakCount: number;
|
||||||
|
homeWins: number;
|
||||||
|
homeLosses: number;
|
||||||
|
homeOtLosses: number;
|
||||||
|
roadWins: number;
|
||||||
|
roadLosses: number;
|
||||||
|
roadOtLosses: number;
|
||||||
|
conferenceSequence: number;
|
||||||
|
divisionSequence: number;
|
||||||
|
leagueSequence: number;
|
||||||
|
teamId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NhlStandingsResponse {
|
||||||
|
standings: NhlTeamRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NhlStandingsAdapter implements StandingsSyncAdapter {
|
||||||
|
async fetchStandings(): Promise<FetchedStandingsRecord[]> {
|
||||||
|
const response = await fetch(NHL_STANDINGS_URL);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`NHL standings API returned ${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = (await response.json()) as NhlStandingsResponse;
|
||||||
|
|
||||||
|
if (!json.standings || !Array.isArray(json.standings)) {
|
||||||
|
throw new Error("Unexpected NHL standings API response shape");
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.standings.map((team, index): FetchedStandingsRecord => {
|
||||||
|
const homeRecord = `${team.homeWins}-${team.homeLosses}-${team.homeOtLosses}`;
|
||||||
|
const awayRecord = `${team.roadWins}-${team.roadLosses}-${team.roadOtLosses}`;
|
||||||
|
const streak = `${team.streakCode}${team.streakCount}`;
|
||||||
|
const lastTen = `${team.l10Wins}-${team.l10Losses}-${team.l10OtLosses}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
teamName: team.teamName.default,
|
||||||
|
externalTeamId: team.teamAbbrev?.default ?? String(index),
|
||||||
|
wins: team.wins,
|
||||||
|
losses: team.losses,
|
||||||
|
otLosses: team.otLosses,
|
||||||
|
winPct: team.winPctg ?? 0,
|
||||||
|
gamesPlayed: team.gamesPlayed,
|
||||||
|
conference: team.conferenceName,
|
||||||
|
division: team.divisionName,
|
||||||
|
conferenceRank: team.conferenceSequence,
|
||||||
|
divisionRank: team.divisionSequence,
|
||||||
|
leagueRank: team.leagueSequence,
|
||||||
|
streak,
|
||||||
|
lastTen,
|
||||||
|
homeRecord,
|
||||||
|
awayRecord,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
25
app/services/standings-sync/thesportsdb.ts
Normal file
25
app/services/standings-sync/thesportsdb.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import type { FetchedStandingsRecord, StandingsSyncAdapter } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TheSportsDB standings adapter.
|
||||||
|
*
|
||||||
|
* TODO: Implement when TheSportsDB adds reliable NBA/NHL standings support.
|
||||||
|
*
|
||||||
|
* Status (as of 2026-03): The free-tier lookuptable.php endpoint is documented
|
||||||
|
* as "soccer only" and returns empty results for NBA (4387) and NHL (4380).
|
||||||
|
* A premium v2 API key ($9/mo) may unlock broader support — test with:
|
||||||
|
* GET https://www.thesportsdb.com/api/v2/json/lookuptable.php?l={leagueId}
|
||||||
|
* Headers: { "X-API-KEY": process.env.THESPORTSDB_API_KEY }
|
||||||
|
*
|
||||||
|
* Reference: https://www.thesportsdb.com/forum_topic.php?t=5772
|
||||||
|
*/
|
||||||
|
export class TheSportsDbAdapter implements StandingsSyncAdapter {
|
||||||
|
constructor(private readonly leagueId: string) {}
|
||||||
|
|
||||||
|
async fetchStandings(): Promise<FetchedStandingsRecord[]> {
|
||||||
|
throw new Error(
|
||||||
|
`TheSportsDB standings sync not yet implemented (league ${this.leagueId}). ` +
|
||||||
|
"Use NhlStandingsAdapter or NbaStandingsAdapter instead."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
34
app/services/standings-sync/types.ts
Normal file
34
app/services/standings-sync/types.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
export interface FetchedStandingsRecord {
|
||||||
|
teamName: string; // matched against participants.name
|
||||||
|
externalTeamId: string;
|
||||||
|
wins: number;
|
||||||
|
losses: number;
|
||||||
|
otLosses?: number;
|
||||||
|
ties?: number;
|
||||||
|
winPct: number;
|
||||||
|
gamesPlayed: number;
|
||||||
|
gamesBack?: number;
|
||||||
|
conference?: string;
|
||||||
|
division?: string;
|
||||||
|
conferenceRank?: number;
|
||||||
|
divisionRank?: number;
|
||||||
|
leagueRank: number;
|
||||||
|
streak?: string;
|
||||||
|
lastTen?: string;
|
||||||
|
homeRecord?: string;
|
||||||
|
awayRecord?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StandingsSyncAdapter {
|
||||||
|
fetchStandings(): Promise<FetchedStandingsRecord[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnmatchedTeam {
|
||||||
|
teamName: string;
|
||||||
|
externalTeamId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SyncResult {
|
||||||
|
synced: number;
|
||||||
|
unmatched: UnmatchedTeam[];
|
||||||
|
}
|
||||||
|
|
@ -683,6 +683,8 @@ export const sportsSeasonsRelations = relations(sportsSeasons, ({ one, many }) =
|
||||||
seasonTemplateSports: many(seasonTemplateSports),
|
seasonTemplateSports: many(seasonTemplateSports),
|
||||||
seasonSports: many(seasonSports),
|
seasonSports: many(seasonSports),
|
||||||
participantResults: many(participantResults),
|
participantResults: many(participantResults),
|
||||||
|
regularSeasonStandings: many(regularSeasonStandings),
|
||||||
|
pendingStandingsMappings: many(pendingStandingsMappings),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const participantsRelations = relations(participants, ({ one, many }) => ({
|
export const participantsRelations = relations(participants, ({ one, many }) => ({
|
||||||
|
|
@ -691,6 +693,7 @@ export const participantsRelations = relations(participants, ({ one, many }) =>
|
||||||
references: [sportsSeasons.id],
|
references: [sportsSeasons.id],
|
||||||
}),
|
}),
|
||||||
results: many(participantResults),
|
results: many(participantResults),
|
||||||
|
regularSeasonStandings: many(regularSeasonStandings),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const seasonTemplatesRelations = relations(seasonTemplates, ({ many }) => ({
|
export const seasonTemplatesRelations = relations(seasonTemplates, ({ many }) => ({
|
||||||
|
|
@ -991,3 +994,74 @@ export const teamEvSnapshotsRelations = relations(teamEvSnapshots, ({ one }) =>
|
||||||
references: [seasons.id],
|
references: [seasons.id],
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Regular season W/L standings for team-sport playoff_bracket seasons (NBA, NHL, etc.)
|
||||||
|
// Populated by the standings sync service; syncedAt = null means manually entered
|
||||||
|
export const regularSeasonStandings = pgTable("regular_season_standings", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
participantId: uuid("participant_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => participants.id, { onDelete: "cascade" }),
|
||||||
|
sportsSeasonId: uuid("sports_season_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => sportsSeasons.id, { onDelete: "cascade" }),
|
||||||
|
wins: integer("wins").notNull().default(0),
|
||||||
|
losses: integer("losses").notNull().default(0),
|
||||||
|
otLosses: integer("ot_losses"), // NHL overtime losses; null for NBA
|
||||||
|
ties: integer("ties"), // future sports (e.g. soccer)
|
||||||
|
winPct: decimal("win_pct", { precision: 5, scale: 4 }),
|
||||||
|
gamesPlayed: integer("games_played").notNull().default(0),
|
||||||
|
gamesBack: decimal("games_back", { precision: 5, scale: 1 }),
|
||||||
|
conference: varchar("conference", { length: 100 }),
|
||||||
|
division: varchar("division", { length: 100 }),
|
||||||
|
conferenceRank: integer("conference_rank"),
|
||||||
|
divisionRank: integer("division_rank"),
|
||||||
|
leagueRank: integer("league_rank"),
|
||||||
|
streak: varchar("streak", { length: 20 }), // e.g. "W3", "L2"
|
||||||
|
lastTen: varchar("last_ten", { length: 15 }), // e.g. "7-2-1"
|
||||||
|
homeRecord: varchar("home_record", { length: 15 }),
|
||||||
|
awayRecord: varchar("away_record", { length: 15 }),
|
||||||
|
externalTeamId: varchar("external_team_id", { length: 255 }),
|
||||||
|
syncedAt: timestamp("synced_at"), // null = manually entered
|
||||||
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||||
|
}, (table) => ({
|
||||||
|
uniqueParticipantSeason: uniqueIndex("rss_participant_season_idx")
|
||||||
|
.on(table.participantId, table.sportsSeasonId),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const regularSeasonStandingsRelations = relations(regularSeasonStandings, ({ one }) => ({
|
||||||
|
participant: one(participants, {
|
||||||
|
fields: [regularSeasonStandings.participantId],
|
||||||
|
references: [participants.id],
|
||||||
|
}),
|
||||||
|
sportsSeason: one(sportsSeasons, {
|
||||||
|
fields: [regularSeasonStandings.sportsSeasonId],
|
||||||
|
references: [sportsSeasons.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Unmatched teams from a standings sync — awaiting admin resolution.
|
||||||
|
// Once the admin maps an external team to a participant, this record is deleted
|
||||||
|
// and the standing + participant.externalId are written.
|
||||||
|
export const pendingStandingsMappings = pgTable("pending_standings_mappings", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
sportsSeasonId: uuid("sports_season_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => sportsSeasons.id, { onDelete: "cascade" }),
|
||||||
|
externalTeamId: varchar("external_team_id", { length: 255 }).notNull(),
|
||||||
|
teamName: varchar("team_name", { length: 255 }).notNull(),
|
||||||
|
// Full standing data from the API, stored as JSON for use when resolving
|
||||||
|
standingData: jsonb("standing_data").notNull(),
|
||||||
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
|
}, (table) => ({
|
||||||
|
uniqueSeasonExternalId: uniqueIndex("psm_season_external_id_idx")
|
||||||
|
.on(table.sportsSeasonId, table.externalTeamId),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const pendingStandingsMappingsRelations = relations(pendingStandingsMappings, ({ one }) => ({
|
||||||
|
sportsSeason: one(sportsSeasons, {
|
||||||
|
fields: [pendingStandingsMappings.sportsSeasonId],
|
||||||
|
references: [sportsSeasons.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
|
||||||
39
drizzle/0054_massive_vulcan.sql
Normal file
39
drizzle/0054_massive_vulcan.sql
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS "regular_season_standings" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"participant_id" uuid NOT NULL,
|
||||||
|
"sports_season_id" uuid NOT NULL,
|
||||||
|
"wins" integer DEFAULT 0 NOT NULL,
|
||||||
|
"losses" integer DEFAULT 0 NOT NULL,
|
||||||
|
"ot_losses" integer,
|
||||||
|
"ties" integer,
|
||||||
|
"win_pct" numeric(5, 4),
|
||||||
|
"games_played" integer DEFAULT 0 NOT NULL,
|
||||||
|
"games_back" numeric(5, 1),
|
||||||
|
"conference" varchar(100),
|
||||||
|
"division" varchar(100),
|
||||||
|
"conference_rank" integer,
|
||||||
|
"division_rank" integer,
|
||||||
|
"league_rank" integer,
|
||||||
|
"streak" varchar(20),
|
||||||
|
"last_ten" varchar(15),
|
||||||
|
"home_record" varchar(15),
|
||||||
|
"away_record" varchar(15),
|
||||||
|
"external_team_id" varchar(255),
|
||||||
|
"synced_at" timestamp,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "regular_season_standings" ADD CONSTRAINT "regular_season_standings_participant_id_participants_id_fk" FOREIGN KEY ("participant_id") REFERENCES "public"."participants"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "regular_season_standings" ADD CONSTRAINT "regular_season_standings_sports_season_id_sports_seasons_id_fk" FOREIGN KEY ("sports_season_id") REFERENCES "public"."sports_seasons"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "rss_participant_season_idx" ON "regular_season_standings" USING btree ("participant_id","sports_season_id");
|
||||||
16
drizzle/0055_special_vampiro.sql
Normal file
16
drizzle/0055_special_vampiro.sql
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS "pending_standings_mappings" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"sports_season_id" uuid NOT NULL,
|
||||||
|
"external_team_id" varchar(255) NOT NULL,
|
||||||
|
"team_name" varchar(255) NOT NULL,
|
||||||
|
"standing_data" jsonb NOT NULL,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "pending_standings_mappings" ADD CONSTRAINT "pending_standings_mappings_sports_season_id_sports_seasons_id_fk" FOREIGN KEY ("sports_season_id") REFERENCES "public"."sports_seasons"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "psm_season_external_id_idx" ON "pending_standings_mappings" USING btree ("sports_season_id","external_team_id");
|
||||||
3802
drizzle/meta/0054_snapshot.json
Normal file
3802
drizzle/meta/0054_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
3889
drizzle/meta/0055_snapshot.json
Normal file
3889
drizzle/meta/0055_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -379,6 +379,20 @@
|
||||||
"when": 1773903201189,
|
"when": 1773903201189,
|
||||||
"tag": "0053_smooth_kingpin",
|
"tag": "0053_smooth_kingpin",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 54,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1774070769112,
|
||||||
|
"tag": "0054_massive_vulcan",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 55,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1774072156237,
|
||||||
|
"tag": "0055_special_vampiro",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Loading…
Add table
Reference in a new issue