diff --git a/app/components/sport-season/RegularSeasonStandings.tsx b/app/components/sport-season/RegularSeasonStandings.tsx index 202c136..8bd5b4d 100644 --- a/app/components/sport-season/RegularSeasonStandings.tsx +++ b/app/components/sport-season/RegularSeasonStandings.tsx @@ -14,6 +14,11 @@ interface StandingRow { wins: number; losses: number; otLosses: number | null; + ties?: number | null; + tablePoints?: number | null; + goalsFor?: number | null; + goalsAgainst?: number | null; + goalDifference?: number | null; winPct: string | null; gamesPlayed: number; gamesBack: string | null; @@ -41,6 +46,7 @@ interface Props { teamOwnerships: Record; userParticipantIds: string[]; showOtLosses?: boolean; + showSoccerTable?: boolean; /** 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. */ @@ -222,15 +228,18 @@ function StandingsTable({ teamOwnerships, userParticipantIds, showOtLosses, + showSoccerTable = false, }: { sections: TableSection[]; teamOwnerships: Record; userParticipantIds: string[]; showOtLosses: boolean; + showSoccerTable?: boolean; }) { // # Team GP W L [OTL] [PTS] PCT GB L10 STK Mgr - // OTL and PTS are both shown for hockey (showOtLosses = true) - const totalCols = 10 + (showOtLosses ? 2 : 0); + // OTL and PTS are both shown for hockey (showOtLosses = true). + // Soccer table mode shows D/GF/GA/GD/Pts instead of PCT/GB/form columns. + const totalCols = showSoccerTable ? 11 : 10 + (showOtLosses ? 2 : 0); return (
@@ -241,13 +250,18 @@ function StandingsTable({ Team GP W + {showSoccerTable && D} L + {showSoccerTable && GF} + {showSoccerTable && GA} + {showSoccerTable && GD} + {showSoccerTable && PTS} {showOtLosses && OTL} {showOtLosses && PTS} - PCT - GB - L10 - STK + {!showSoccerTable && PCT} + {!showSoccerTable && GB} + {!showSoccerTable && L10} + {!showSoccerTable && STK} Mgr @@ -316,7 +330,22 @@ function StandingsTable({ {row.gamesPlayed} {row.wins} + {showSoccerTable && ( + {row.ties ?? 0} + )} {row.losses} + {showSoccerTable && ( + {row.goalsFor ?? "—"} + )} + {showSoccerTable && ( + {row.goalsAgainst ?? "—"} + )} + {showSoccerTable && ( + {row.goalDifference ?? "—"} + )} + {showSoccerTable && ( + {row.tablePoints ?? row.wins * 3 + (row.ties ?? 0)} + )} {showOtLosses && ( {row.otLosses ?? 0} )} @@ -325,30 +354,38 @@ function StandingsTable({ {row.wins * 2 + (row.otLosses ?? 0)} )} - - {formatWinPct(row.winPct, row.wins, row.gamesPlayed)} - - - {formatGB(row.gamesBack)} - - - {row.lastTen ?? "—"} - - - {row.streak ? ( - - {row.streak} - - ) : ( - - )} - + {!showSoccerTable && ( + + {formatWinPct(row.winPct, row.wins, row.gamesPlayed)} + + )} + {!showSoccerTable && ( + + {formatGB(row.gamesBack)} + + )} + {!showSoccerTable && ( + + {row.lastTen ?? "—"} + + )} + {!showSoccerTable && ( + + {row.streak ? ( + + {row.streak} + + ) : ( + + )} + + )} {ownership ? (
@@ -381,6 +418,7 @@ export function RegularSeasonStandings({ teamOwnerships, userParticipantIds, showOtLosses = false, + showSoccerTable = false, playoffSpots = 8, displayMode = "flat", }: Props) { @@ -399,7 +437,7 @@ export function RegularSeasonStandings({ ? buildMlbSections(standings) : buildFlatSections(standings, playoffSpots); - const tableProps = { teamOwnerships, userParticipantIds, showOtLosses }; + const tableProps = { teamOwnerships, userParticipantIds, showOtLosses, showSoccerTable }; return ( diff --git a/app/components/sport-season/__tests__/RegularSeasonStandings.test.tsx b/app/components/sport-season/__tests__/RegularSeasonStandings.test.tsx index cdc325c..4d9e681 100644 --- a/app/components/sport-season/__tests__/RegularSeasonStandings.test.tsx +++ b/app/components/sport-season/__tests__/RegularSeasonStandings.test.tsx @@ -8,6 +8,11 @@ function makeRow(overrides: Partial<{ wins: number; losses: number; otLosses: number | null; + ties: number | null; + tablePoints: number | null; + goalsFor: number | null; + goalsAgainst: number | null; + goalDifference: number | null; gamesPlayed: number; winPct: string | null; gamesBack: string | null; @@ -29,6 +34,11 @@ function makeRow(overrides: Partial<{ wins: overrides.wins ?? 40, losses: overrides.losses ?? 28, otLosses: overrides.otLosses ?? null, + ties: overrides.ties ?? null, + tablePoints: overrides.tablePoints ?? null, + goalsFor: overrides.goalsFor ?? null, + goalsAgainst: overrides.goalsAgainst ?? null, + goalDifference: overrides.goalDifference ?? null, gamesPlayed: overrides.gamesPlayed ?? 68, winPct: overrides.winPct ?? "0.5882", gamesBack: overrides.gamesBack ?? null, @@ -99,6 +109,38 @@ describe("RegularSeasonStandings", () => { expect(screen.queryByText("OTL")).not.toBeInTheDocument(); }); + + it("shows soccer table columns when showSoccerTable=true", () => { + render( + + ); + + expect(screen.getByText("D")).toBeInTheDocument(); + expect(screen.getByText("GF")).toBeInTheDocument(); + expect(screen.getByText("GA")).toBeInTheDocument(); + expect(screen.getByText("GD")).toBeInTheDocument(); + expect(screen.getByText("PTS")).toBeInTheDocument(); + expect(screen.getByText("Arsenal")).toBeInTheDocument(); + expect(screen.getByText("69")).toBeInTheDocument(); + }); + it("renders conference headings and inline division labels when present", () => { render( = 0 && projectedWins <= config.seasonGames) { - const elo = projectedWinsToElo(projectedWins, config.seasonGames, config.parityFactor, config.averageOpponentElo); + const projectionMax = config.projectionInput === 'tablePoints' ? config.seasonGames * 3 : config.seasonGames; + if (!isNaN(projectedWins) && projectedWins >= 0 && projectedWins <= projectionMax) { + const elo = config.projectionInput === 'tablePoints' + ? projectedTablePointsToElo(projectedWins, config.seasonGames, config.parityFactor, config.averageOpponentElo) + : projectedWinsToElo(projectedWins, config.seasonGames, config.parityFactor, config.averageOpponentElo); eloInputs.push({ participantId: participant.id, sportsSeasonId, sourceElo: elo }); } } @@ -141,7 +149,7 @@ export async function action({ request, params }: Route.ActionArgs) { if (eloInputs.length === 0) { return { success: false, message: inputMode === 'projectedWins' - ? 'Please enter projected wins for at least one participant' + ? 'Please enter projections for at least one participant' : 'Please enter an Elo rating for at least one participant' }; } @@ -234,6 +242,13 @@ export default function AdminSportsSeasonEloRatings() { const navigation = useNavigation(); const [inputMode, setInputMode] = useState<'elo' | 'projectedWins'>('elo'); + const projectionLabel = simulatorConfig?.projectionInput === 'tablePoints' ? 'Projected Points' : 'Projected Wins'; + const projectionUnit = simulatorConfig?.projectionInput === 'tablePoints' ? 'points' : 'wins'; + const projectionMax = simulatorConfig + ? simulatorConfig.projectionInput === 'tablePoints' + ? simulatorConfig.seasonGames * 3 + : simulatorConfig.seasonGames + : 0; const [eloValues, setEloValues] = useState>(() => { const initial: Record = {}; @@ -259,8 +274,9 @@ export default function AdminSportsSeasonEloRatings() { participants.forEach(p => { const d = existingData[p.id]; if (d?.elo !== null && d?.elo !== undefined) { - initial[p.id] = eloToProjectedWins( - d.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo + initial[p.id] = (simulatorConfig.projectionInput === 'tablePoints' + ? eloToProjectedTablePoints(d.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo) + : eloToProjectedWins(d.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo) ).toFixed(1); } }); @@ -311,9 +327,11 @@ export default function AdminSportsSeasonEloRatings() { const inputName = match[1].trim(); const projectedWins = parseFloat(match[2]); - if (isNaN(projectedWins) || projectedWins < 0 || projectedWins > simulatorConfig.seasonGames) continue; + if (isNaN(projectedWins) || projectedWins < 0 || projectedWins > projectionMax) continue; - const elo = projectedWinsToElo(projectedWins, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo); + const elo = simulatorConfig.projectionInput === 'tablePoints' + ? projectedTablePointsToElo(projectedWins, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo) + : projectedWinsToElo(projectedWins, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo); const participant = findParticipantMatch(inputName); if (participant && !seen.has(participant.id)) { @@ -356,8 +374,9 @@ export default function AdminSportsSeasonEloRatings() { newElos[m.participantId] = m.elo.toString(); if (m.ranking !== null) newRanks[m.participantId] = m.ranking.toString(); if (inputMode === 'projectedWins' && simulatorConfig) { - newWins[m.participantId] = eloToProjectedWins( - m.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo + newWins[m.participantId] = (simulatorConfig.projectionInput === 'tablePoints' + ? eloToProjectedTablePoints(m.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo) + : eloToProjectedWins(m.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo) ).toFixed(1); } } @@ -408,7 +427,7 @@ export default function AdminSportsSeasonEloRatings() { variant={inputMode === 'projectedWins' ? 'default' : 'outline'} onClick={() => setInputMode('projectedWins')} > - Projected Wins + {projectionLabel}
)} @@ -420,8 +439,8 @@ export default function AdminSportsSeasonEloRatings() { {inputMode === 'projectedWins' && simulatorConfig ? ( <> - Paste projected season wins one per line. Format: Team Name, 15.6. - Wins are automatically converted to Elo ratings using {simulatorConfig.seasonGames} total games + Paste projected season {projectionUnit} one per line. Format: Team Name, {simulatorConfig.projectionInput === 'tablePoints' ? '76.5' : '15.6'}. + Projections are automatically converted to Elo ratings using {simulatorConfig.seasonGames} total games and parity factor {simulatorConfig.parityFactor}. Names are fuzzy-matched to participants. ) : usesRanking ? ( @@ -442,7 +461,9 @@ export default function AdminSportsSeasonEloRatings() {