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: Date | string | null; participant: { id: string; name: string; shortName?: string | null }; } interface TeamOwnership { teamName: string; ownerName: string; teamId: string; } interface Props { standings: StandingRow[]; teamOwnerships: Record; userParticipantIds: string[]; showOtLosses?: boolean; participantEvs?: Record; /** 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. "mlb-divisions" = same structure with 3 WC spots, uses "League" label. */ displayMode?: "flat" | "nhl-divisions" | "mlb-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 ───────────────────────────────────────────────────────────────── type ConferenceGroup = { conference: string; conferenceLabel: string; sections: TableSection[] }; function buildFlatSections( standings: StandingRow[], playoffSpots: number ): ConferenceGroup[] { const hasConferences = standings.some((s) => s.conference); if (!hasConferences) { return [ { conference: "", conferenceLabel: "Conference", sections: [ { rows: [...standings].toSorted( (a, b) => (a.leagueRank ?? 99) - (b.leagueRank ?? 99) ), rankMode: "conference", playoffSpots, }, ], }, ]; } const confMap = new Map(); 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, conferenceLabel: "Conference", sections: [ { rows: [...rows].toSorted( (a, b) => (a.conferenceRank ?? a.leagueRank ?? 99) - (b.conferenceRank ?? b.leagueRank ?? 99) ), rankMode: "conference" as RankMode, playoffSpots, showDivisionLabel: true, }, ], })); } /** * Shared builder for division-based display modes (NHL and MLB). * `divisionSpots`: how many teams per division qualify directly (3 for NHL, 1 for MLB). * `wcSpots`: how many wildcard slots exist per conference (2 for NHL, 3 for MLB). * `conferenceLabel`: the word shown after the conference name in the heading. */ function buildDivisionSections( standings: StandingRow[], divisionSpots: number, wcSpots: number, conferenceLabel: string ): ConferenceGroup[] { const confMap = new Map(); 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(); 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) <= divisionSpots) .toSorted((a, b) => (a.divisionRank ?? 99) - (b.divisionRank ?? 99)), })) .toSorted( (a, b) => (a.qualifiers[0]?.conferenceRank ?? 99) - (b.qualifiers[0]?.conferenceRank ?? 99) ); const wildCard = rows .filter((r) => (r.divisionRank ?? 99) > divisionSpots) .toSorted( (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: wcSpots, showDivisionLabel: true, }, ] : []), ]; return { conference, conferenceLabel, sections }; }); } function buildNhlSections(standings: StandingRow[]): ConferenceGroup[] { return buildDivisionSections(standings, 3, 2, "Conference"); } function buildMlbSections(standings: StandingRow[]): ConferenceGroup[] { return buildDivisionSections(standings, 1, 3, "League"); } // ─── Single table that renders all sections ─────────────────────────────────── function StandingsTable({ sections, teamOwnerships, userParticipantIds, showOtLosses, participantEvs, hasEvs, }: { sections: TableSection[]; teamOwnerships: Record; userParticipantIds: string[]; showOtLosses: boolean; participantEvs: Record; 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 (
{showOtLosses && } {showOtLosses && } {hasEvs && } {sections.flatMap((section, sIdx) => { const sectionRows: React.ReactNode[] = []; let playoffLineShown = false; if (section.heading) { sectionRows.push( ); } section.rows.forEach((row, i) => { const rank = getRank(row, i, section.rankMode); if (!playoffLineShown && section.playoffSpots > 0 && rank > section.playoffSpots) { playoffLineShown = true; sectionRows.push( ); } const isUserTeam = userParticipantIds.includes(row.participantId); const ownership = teamOwnerships[row.participantId]; const ev = participantEvs[row.participantId]; sectionRows.push( {showOtLosses && ( )} {showOtLosses && ( )} {hasEvs && ( )} ); }); return sectionRows; })}
# Team GP W LOTLPTSPCT GB L10 STK MgrPROJ
0 ? "pt-5" : "pt-2" }`} > {section.heading}
Playoff Line
{rank} {row.participant.shortName ?? row.participant.name} {section.showDivisionLabel && row.division && ( {row.division} )} {row.gamesPlayed} {row.wins} {row.losses}{row.otLosses ?? 0} {row.wins * 2 + (row.otLosses ?? 0)} {formatWinPct(row.winPct, row.wins, row.gamesPlayed)} {formatGB(row.gamesBack)} {row.lastTen ?? "—"} {row.streak ? ( {row.streak} ) : ( )} {ownership ? (
) : ( )}
{ev !== null ? ( {parseFloat(ev).toFixed(1)} ) : ( )}
); } // ─── 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) .toSorted() .at(-1); const groups = displayMode === "nhl-divisions" ? buildNhlSections(standings) : displayMode === "mlb-divisions" ? buildMlbSections(standings) : buildFlatSections(standings, playoffSpots); const tableProps = { teamOwnerships, userParticipantIds, showOtLosses, participantEvs, hasEvs }; return (
Regular Season Standings {lastSyncedAt && ( Updated {new Date(lastSyncedAt).toLocaleDateString()} )}
{groups.map((group) => (
{group.conference && (

{group.conference} {group.conferenceLabel}

)}
))}
); }