Add MLB playoff simulator with AL/NL division standings, fixes #121 (#225)
* Add MLB playoff simulator with AL/NL division standings, fixes #121
- New `mlb-simulator.ts`: 50k Monte Carlo sim drawing division winners
(weighted by p_div) and 3 WC teams per league, then simulating
WC best-of-3 → DS best-of-5 → LCS best-of-7 → World Series.
Elo parity factor 350; blends Vegas odds 30/70 when sourceOdds available.
- New `standings-sync/mlb.ts`: adapter for free statsapi.mlb.com API,
mapping AL/NL conferences, division ranks, streaks, home/away/L10 splits.
- New `mlb-divisions` display mode in RegularSeasonStandings: shows 1 division
winner per division section + Wild Card section with 3-spot playoff line,
headings read "AL/NL League" instead of "Conference".
- Refactored buildNhlSections/buildMlbSections into shared buildDivisionSections
(divisionSpots + wcSpots params); conferenceLabel now flows through group
objects rather than being derived from displayMode in the render.
- DB migration 0062 adds `mlb_bracket` to simulator_type enum.
- 37 new tests across simulator, adapter, and component.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix flaky tennis simulator test: increase trials and lower threshold
500 trials with a ~3–5% expected win rate had enough variance to
occasionally land below the 0.03 threshold (~2% failure rate).
Bumping to 2000 trials reduces the std dev by 2x; lowering the
threshold to 0.02 keeps the assertion meaningful (still well above
random 0.78%) while eliminating the flakiness.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 08:47:02 -07:00
|
|
|
|
import type { FetchedStandingsRecord, StandingsSyncAdapter } from "./types";
|
|
|
|
|
|
|
|
|
|
|
|
// MLB Stats API — free, no key required.
|
|
|
|
|
|
// leagueId 103 = American League, 104 = National League
|
|
|
|
|
|
const MLB_STANDINGS_URL =
|
|
|
|
|
|
"https://statsapi.mlb.com/api/v1/standings?leagueId=103,104&standingsTypes=regularSeason&hydrate=team(division,league)";
|
|
|
|
|
|
|
|
|
|
|
|
interface MlbSplitRecord {
|
|
|
|
|
|
type: string; // "home", "away", "lastTen", etc.
|
|
|
|
|
|
wins: number;
|
|
|
|
|
|
losses: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface MlbTeamRecord {
|
|
|
|
|
|
team: {
|
|
|
|
|
|
id: number;
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
league?: { name?: string };
|
|
|
|
|
|
division?: { name?: string };
|
|
|
|
|
|
};
|
|
|
|
|
|
wins: number;
|
|
|
|
|
|
losses: number;
|
|
|
|
|
|
gamesPlayed: number;
|
|
|
|
|
|
winningPercentage: string; // e.g. ".617"
|
|
|
|
|
|
divisionRank?: string; // e.g. "1"–"5" — rank within the 5-team division (all teams)
|
|
|
|
|
|
wildCardRank?: string; // e.g. "1" — only set for WC-ranked teams
|
|
|
|
|
|
leagueRank?: string; // rank within the 15-team AL or NL
|
|
|
|
|
|
gamesBack: string; // "-" for leader, "2.0" otherwise
|
|
|
|
|
|
wildCardGamesBack?: string;
|
Fix MLB streak doubling bug, hide empty standings columns, improve mobile layout (#413)
* Fix MLB streak doubling bug, hide empty standings columns, improve mobile layout
- mlb.ts: use streakCode alone (the API already returns the full string like
"W3"); appending streakNumber was doubling the digit, causing "L33" display
- Update mlb.test.ts mocks to match real API format (streakCode "W3" not "W")
- RegularSeasonStandings: conditionally hide GB/L10/STK columns when no rows
have data, so pre-season or stats-free sports don't show blank columns
- Mobile two-row layout: GP/PCT/GB/L10 move to a secondary sub-row (sm:hidden)
so all data stays visible without horizontal scroll on small screens; STK and
W/L remain on the primary row; reduce min-w from 740px to 360px
https://claude.ai/code/session_01RADi3LhYMPbRDm5no1ZpdF
* Address code review: cleanup mlb streak type, fix soccer GP on mobile, hoist showSubRow
- mlb.ts: drop redundant `?? undefined` after optional chain; document that
streakCode is the full string (e.g. "W3") and streakNumber is unused
- RegularSeasonStandings: hoist hasSecondaryStats → showSubRow to component
level (it only depends on a prop, not on individual row data)
- Remove non-functional `truncate`/`min-w-0` from team name cell — truncation
requires table-layout:fixed which we don't use; team names size naturally
- Soccer GP was hidden on mobile with no sub-row to surface it; GP now shows
inline for soccer on all viewports, hidden only for non-soccer (which has
the secondary sub-row)
- Add comment explaining totalCols counts hidden-on-mobile columns for colSpan
- Add missing test for GB column hiding when no rows have gamesBack data
https://claude.ai/code/session_01RADi3LhYMPbRDm5no1ZpdF
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-05-12 08:14:34 -07:00
|
|
|
|
streak?: {
|
|
|
|
|
|
streakCode: string; // full streak string e.g. "W3" or "L5" — NOT just the letter
|
|
|
|
|
|
streakNumber: number; // numeric part; redundant with streakCode, not used
|
|
|
|
|
|
};
|
Add MLB playoff simulator with AL/NL division standings, fixes #121 (#225)
* Add MLB playoff simulator with AL/NL division standings, fixes #121
- New `mlb-simulator.ts`: 50k Monte Carlo sim drawing division winners
(weighted by p_div) and 3 WC teams per league, then simulating
WC best-of-3 → DS best-of-5 → LCS best-of-7 → World Series.
Elo parity factor 350; blends Vegas odds 30/70 when sourceOdds available.
- New `standings-sync/mlb.ts`: adapter for free statsapi.mlb.com API,
mapping AL/NL conferences, division ranks, streaks, home/away/L10 splits.
- New `mlb-divisions` display mode in RegularSeasonStandings: shows 1 division
winner per division section + Wild Card section with 3-spot playoff line,
headings read "AL/NL League" instead of "Conference".
- Refactored buildNhlSections/buildMlbSections into shared buildDivisionSections
(divisionSpots + wcSpots params); conferenceLabel now flows through group
objects rather than being derived from displayMode in the render.
- DB migration 0062 adds `mlb_bracket` to simulator_type enum.
- 37 new tests across simulator, adapter, and component.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix flaky tennis simulator test: increase trials and lower threshold
500 trials with a ~3–5% expected win rate had enough variance to
occasionally land below the 0.03 threshold (~2% failure rate).
Bumping to 2000 trials reduces the std dev by 2x; lowering the
threshold to 0.02 keeps the assertion meaningful (still well above
random 0.78%) while eliminating the flakiness.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 08:47:02 -07:00
|
|
|
|
records?: { splitRecords?: MlbSplitRecord[] };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface MlbDivisionRecord {
|
|
|
|
|
|
teamRecords: MlbTeamRecord[];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface MlbStandingsResponse {
|
|
|
|
|
|
records: MlbDivisionRecord[];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function sortByRecord(a: MlbTeamRecord, b: MlbTeamRecord): number {
|
|
|
|
|
|
return b.wins !== a.wins ? b.wins - a.wins : a.losses - b.losses;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export class MlbStandingsAdapter implements StandingsSyncAdapter {
|
|
|
|
|
|
constructor(private season: number = new Date().getFullYear()) {}
|
|
|
|
|
|
|
|
|
|
|
|
async fetchStandings(): Promise<FetchedStandingsRecord[]> {
|
|
|
|
|
|
const url = `${MLB_STANDINGS_URL}&season=${this.season}`;
|
|
|
|
|
|
const response = await fetch(url);
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
throw new Error(`MLB standings API returned ${response.status}: ${response.statusText}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const json = (await response.json()) as MlbStandingsResponse;
|
|
|
|
|
|
|
|
|
|
|
|
if (!json.records || !Array.isArray(json.records)) {
|
|
|
|
|
|
throw new Error("Unexpected MLB standings API response shape");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Collect all team records from all division groups
|
|
|
|
|
|
const allTeamRecords: MlbTeamRecord[] = json.records.flatMap(
|
|
|
|
|
|
(divRecord) => divRecord.teamRecords ?? []
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
if (allTeamRecords.length === 0) {
|
|
|
|
|
|
throw new Error("MLB standings API returned no team records");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Compute leagueRank by sorting all teams by wins desc, losses asc within each league.
|
|
|
|
|
|
// We'll assign ranks per league (AL rank 1–15, NL rank 1–15) separately since
|
|
|
|
|
|
// the API's leagueRank field may reflect conference-level rank.
|
|
|
|
|
|
const alTeams = allTeamRecords.filter(
|
|
|
|
|
|
(t) => t.team.league?.name?.includes("American")
|
|
|
|
|
|
);
|
|
|
|
|
|
const nlTeams = allTeamRecords.filter(
|
|
|
|
|
|
(t) => t.team.league?.name?.includes("National")
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const alSorted = [...alTeams].toSorted(sortByRecord);
|
|
|
|
|
|
const nlSorted = [...nlTeams].toSorted(sortByRecord);
|
|
|
|
|
|
|
|
|
|
|
|
const conferenceRankMap = new Map<number, number>();
|
|
|
|
|
|
for (const [i, t] of alSorted.entries()) conferenceRankMap.set(t.team.id, i + 1);
|
|
|
|
|
|
for (const [i, t] of nlSorted.entries()) conferenceRankMap.set(t.team.id, i + 1);
|
|
|
|
|
|
|
|
|
|
|
|
// Compute overall league rank across all 30 teams for leagueRank field
|
|
|
|
|
|
const allSorted = [...allTeamRecords].toSorted(sortByRecord);
|
|
|
|
|
|
const overallRankMap = new Map<number, number>();
|
|
|
|
|
|
for (const [i, t] of allSorted.entries()) overallRankMap.set(t.team.id, i + 1);
|
|
|
|
|
|
|
|
|
|
|
|
return allTeamRecords.map((team): FetchedStandingsRecord => {
|
|
|
|
|
|
// Conference: "American League" → "AL", "National League" → "NL"
|
|
|
|
|
|
const leagueName = team.team.league?.name ?? "";
|
|
|
|
|
|
const conference = leagueName.startsWith("American")
|
|
|
|
|
|
? "AL"
|
|
|
|
|
|
: leagueName.startsWith("National")
|
|
|
|
|
|
? "NL"
|
|
|
|
|
|
: leagueName;
|
|
|
|
|
|
|
|
|
|
|
|
// Division: "AL East", "AL Central", etc.
|
|
|
|
|
|
const division = team.team.division?.name ?? null;
|
|
|
|
|
|
|
|
|
|
|
|
// Win percentage
|
|
|
|
|
|
const winPct = parseFloat(team.winningPercentage) || 0;
|
|
|
|
|
|
|
|
|
|
|
|
// Games back — "-" means leader; use null. Use isNaN guard so GB=0 (tied for first) is kept.
|
|
|
|
|
|
const gbParsed = parseFloat(team.gamesBack);
|
|
|
|
|
|
const gbStr = (team.gamesBack === "-" || isNaN(gbParsed)) ? null : gbParsed;
|
|
|
|
|
|
|
|
|
|
|
|
// Division rank and conference rank
|
|
|
|
|
|
const divisionRank = team.divisionRank ? parseInt(team.divisionRank, 10) : null;
|
|
|
|
|
|
|
|
|
|
|
|
// Conference rank: use the per-league rank computed above
|
|
|
|
|
|
const conferenceRank = conferenceRankMap.get(team.team.id) ?? null;
|
|
|
|
|
|
|
Fix MLB streak doubling bug, hide empty standings columns, improve mobile layout (#413)
* Fix MLB streak doubling bug, hide empty standings columns, improve mobile layout
- mlb.ts: use streakCode alone (the API already returns the full string like
"W3"); appending streakNumber was doubling the digit, causing "L33" display
- Update mlb.test.ts mocks to match real API format (streakCode "W3" not "W")
- RegularSeasonStandings: conditionally hide GB/L10/STK columns when no rows
have data, so pre-season or stats-free sports don't show blank columns
- Mobile two-row layout: GP/PCT/GB/L10 move to a secondary sub-row (sm:hidden)
so all data stays visible without horizontal scroll on small screens; STK and
W/L remain on the primary row; reduce min-w from 740px to 360px
https://claude.ai/code/session_01RADi3LhYMPbRDm5no1ZpdF
* Address code review: cleanup mlb streak type, fix soccer GP on mobile, hoist showSubRow
- mlb.ts: drop redundant `?? undefined` after optional chain; document that
streakCode is the full string (e.g. "W3") and streakNumber is unused
- RegularSeasonStandings: hoist hasSecondaryStats → showSubRow to component
level (it only depends on a prop, not on individual row data)
- Remove non-functional `truncate`/`min-w-0` from team name cell — truncation
requires table-layout:fixed which we don't use; team names size naturally
- Soccer GP was hidden on mobile with no sub-row to surface it; GP now shows
inline for soccer on all viewports, hidden only for non-soccer (which has
the secondary sub-row)
- Add comment explaining totalCols counts hidden-on-mobile columns for colSpan
- Add missing test for GB column hiding when no rows have gamesBack data
https://claude.ai/code/session_01RADi3LhYMPbRDm5no1ZpdF
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-05-12 08:14:34 -07:00
|
|
|
|
// streakCode is the full string e.g. "W3"; streakNumber is the numeric part embedded within it
|
|
|
|
|
|
const streak = team.streak?.streakCode;
|
Add MLB playoff simulator with AL/NL division standings, fixes #121 (#225)
* Add MLB playoff simulator with AL/NL division standings, fixes #121
- New `mlb-simulator.ts`: 50k Monte Carlo sim drawing division winners
(weighted by p_div) and 3 WC teams per league, then simulating
WC best-of-3 → DS best-of-5 → LCS best-of-7 → World Series.
Elo parity factor 350; blends Vegas odds 30/70 when sourceOdds available.
- New `standings-sync/mlb.ts`: adapter for free statsapi.mlb.com API,
mapping AL/NL conferences, division ranks, streaks, home/away/L10 splits.
- New `mlb-divisions` display mode in RegularSeasonStandings: shows 1 division
winner per division section + Wild Card section with 3-spot playoff line,
headings read "AL/NL League" instead of "Conference".
- Refactored buildNhlSections/buildMlbSections into shared buildDivisionSections
(divisionSpots + wcSpots params); conferenceLabel now flows through group
objects rather than being derived from displayMode in the render.
- DB migration 0062 adds `mlb_bracket` to simulator_type enum.
- 37 new tests across simulator, adapter, and component.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix flaky tennis simulator test: increase trials and lower threshold
500 trials with a ~3–5% expected win rate had enough variance to
occasionally land below the 0.03 threshold (~2% failure rate).
Bumping to 2000 trials reduces the std dev by 2x; lowering the
threshold to 0.02 keeps the assertion meaningful (still well above
random 0.78%) while eliminating the flakiness.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 08:47:02 -07:00
|
|
|
|
|
|
|
|
|
|
// Split records
|
|
|
|
|
|
const splits = team.records?.splitRecords ?? [];
|
|
|
|
|
|
const homeSplit = splits.find((s) => s.type === "home");
|
|
|
|
|
|
const awaySplit = splits.find((s) => s.type === "away");
|
|
|
|
|
|
const lastTenSplit = splits.find((s) => s.type === "lastTen");
|
|
|
|
|
|
|
|
|
|
|
|
const homeRecord = homeSplit
|
|
|
|
|
|
? `${homeSplit.wins}-${homeSplit.losses}`
|
|
|
|
|
|
: undefined;
|
|
|
|
|
|
const awayRecord = awaySplit
|
|
|
|
|
|
? `${awaySplit.wins}-${awaySplit.losses}`
|
|
|
|
|
|
: undefined;
|
|
|
|
|
|
const lastTen = lastTenSplit
|
|
|
|
|
|
? `${lastTenSplit.wins}-${lastTenSplit.losses}`
|
|
|
|
|
|
: undefined;
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
teamName: team.team.name,
|
|
|
|
|
|
externalTeamId: String(team.team.id),
|
|
|
|
|
|
wins: team.wins,
|
|
|
|
|
|
losses: team.losses,
|
|
|
|
|
|
gamesPlayed: team.gamesPlayed,
|
|
|
|
|
|
winPct,
|
|
|
|
|
|
// MLB has no OT losses
|
|
|
|
|
|
conference,
|
|
|
|
|
|
division: division ?? undefined,
|
|
|
|
|
|
divisionRank: divisionRank ?? undefined,
|
|
|
|
|
|
conferenceRank: conferenceRank ?? undefined,
|
|
|
|
|
|
leagueRank: overallRankMap.get(team.team.id) ?? 1,
|
|
|
|
|
|
gamesBack: gbStr ?? undefined,
|
|
|
|
|
|
streak,
|
|
|
|
|
|
lastTen,
|
|
|
|
|
|
homeRecord,
|
|
|
|
|
|
awayRecord,
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|