diff --git a/app/services/standings-sync/__tests__/mlb.test.ts b/app/services/standings-sync/__tests__/mlb.test.ts index e7242f9..7eb8ab6 100644 --- a/app/services/standings-sync/__tests__/mlb.test.ts +++ b/app/services/standings-sync/__tests__/mlb.test.ts @@ -332,4 +332,135 @@ describe("MlbStandingsAdapter", () => { const adapter = new MlbStandingsAdapter(); await expect(adapter.fetchStandings()).rejects.toThrow(); }); + + it("resolves division from hardcoded map when ESPN returns flat structure (no division children)", async () => { + const flatResponse = { + children: [ + { + name: "American League", + // No "children" key — ESPN returns all AL teams directly in standings.entries + standings: { + entries: [ + { + team: { id: "30", displayName: "Tampa Bay Rays" }, + stats: [ + { name: "wins", value: 40 }, + { name: "losses", value: 25 }, + { name: "winPercent", value: 0.615 }, + { name: "gamesBehind", value: 0 }, + { name: "playoffSeed", value: 1 }, + ], + }, + { + team: { id: "10", displayName: "New York Yankees" }, + stats: [ + { name: "wins", value: 41 }, + { name: "losses", value: 26 }, + { name: "winPercent", value: 0.612 }, + { name: "gamesBehind", value: 0 }, + { name: "playoffSeed", value: 2 }, + ], + }, + { + team: { id: "4", displayName: "Chicago White Sox" }, + stats: [ + { name: "wins", value: 36 }, + { name: "losses", value: 31 }, + { name: "winPercent", value: 0.537 }, + { name: "gamesBehind", value: 5 }, + { name: "playoffSeed", value: 3 }, + ], + }, + ], + }, + }, + { + name: "National League", + standings: { + entries: [ + { + team: { id: "15", displayName: "Atlanta Braves" }, + stats: [ + { name: "wins", value: 45 }, + { name: "losses", value: 23 }, + { name: "winPercent", value: 0.662 }, + { name: "gamesBehind", value: 0 }, + { name: "playoffSeed", value: 1 }, + ], + }, + ], + }, + }, + ], + }; + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => flatResponse, + } as Response); + + const adapter = new MlbStandingsAdapter(); + const records = await adapter.fetchStandings(); + + const rays = records.find((r) => r.teamName === "Tampa Bay Rays"); + const yankees = records.find((r) => r.teamName === "New York Yankees"); + const whiteSox = records.find((r) => r.teamName === "Chicago White Sox"); + const braves = records.find((r) => r.teamName === "Atlanta Braves"); + + // Division should be resolved from hardcoded map + expect(rays?.division).toBe("AL East"); + expect(yankees?.division).toBe("AL East"); + expect(whiteSox?.division).toBe("AL Central"); + expect(braves?.division).toBe("NL East"); + + // Conference should still be abbreviated correctly + expect(rays?.conference).toBe("AL"); + expect(braves?.conference).toBe("NL"); + + // divisionRank is sorted by win-loss within each division, NOT by ESPN response order. + // Yankees (41-26) has a better record than Rays (40-25) even though Rays appears first + // in ESPN's flat list → Yankees should be divisionRank 1. + expect(yankees?.divisionRank).toBe(1); + expect(rays?.divisionRank).toBe(2); + // White Sox is alone in AL Central sample → divisionRank 1 + expect(whiteSox?.divisionRank).toBe(1); + // Braves is alone in NL East sample → divisionRank 1 + expect(braves?.divisionRank).toBe(1); + }); + + it("warns to console when a team display name is not in the hardcoded division map", async () => { + const unknownTeamResponse = { + children: [ + { + name: "American League", + standings: { + entries: [ + { + team: { id: "99", displayName: "Portland Lumberjacks" }, + stats: [ + { name: "wins", value: 30 }, + { name: "losses", value: 30 }, + { name: "winPercent", value: 0.5 }, + ], + }, + ], + }, + }, + ], + }; + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => unknownTeamResponse, + } as Response); + + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const adapter = new MlbStandingsAdapter(); + await adapter.fetchStandings(); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("Portland Lumberjacks") + ); + warnSpy.mockRestore(); + }); }); diff --git a/app/services/standings-sync/mlb.ts b/app/services/standings-sync/mlb.ts index 02f986b..172faaf 100644 --- a/app/services/standings-sync/mlb.ts +++ b/app/services/standings-sync/mlb.ts @@ -10,6 +10,49 @@ import { const MLB_STANDINGS_URL = "https://site.api.espn.com/apis/v2/sports/baseball/mlb/standings"; +// Fallback division lookup for when ESPN's API doesn't return division groupings. +// Keyed by the team's ESPN displayName. Athletics entries cover the Oakland→Las Vegas rename. +const MLB_TEAM_DIVISION: Record = { + // AL East + "Baltimore Orioles": "AL East", + "Boston Red Sox": "AL East", + "New York Yankees": "AL East", + "Tampa Bay Rays": "AL East", + "Toronto Blue Jays": "AL East", + // AL Central + "Chicago White Sox": "AL Central", + "Cleveland Guardians": "AL Central", + "Detroit Tigers": "AL Central", + "Kansas City Royals": "AL Central", + "Minnesota Twins": "AL Central", + // AL West + "Houston Astros": "AL West", + "Los Angeles Angels": "AL West", + "Athletics": "AL West", + "Oakland Athletics": "AL West", + "Las Vegas Athletics": "AL West", + "Seattle Mariners": "AL West", + "Texas Rangers": "AL West", + // NL East + "Atlanta Braves": "NL East", + "Miami Marlins": "NL East", + "New York Mets": "NL East", + "Philadelphia Phillies": "NL East", + "Washington Nationals": "NL East", + // NL Central + "Chicago Cubs": "NL Central", + "Cincinnati Reds": "NL Central", + "Milwaukee Brewers": "NL Central", + "Pittsburgh Pirates": "NL Central", + "St. Louis Cardinals": "NL Central", + // NL West + "Arizona Diamondbacks": "NL West", + "Colorado Rockies": "NL West", + "Los Angeles Dodgers": "NL West", + "San Diego Padres": "NL West", + "San Francisco Giants": "NL West", +}; + // "American League" → "AL", "National League" → "NL" function abbreviateLeague(name: string): string { if (name.startsWith("American")) return "AL"; @@ -39,23 +82,33 @@ export class MlbStandingsAdapter implements StandingsSyncAdapter { } // Pre-build statsMap once per entry so the sort doesn't rebuild it on every comparison. - const withSm = flattened.map(({ entry, conference, division }) => ({ - entry, - conference, - division, - sm: statsMap(entry.stats), - })); + // Resolve division: use ESPN's value when present; fall back to the hardcoded map. + const withSm = flattened.map(({ entry, conference, division }) => { + const resolvedDivision = division || MLB_TEAM_DIVISION[entry.team.displayName] || ""; + if (!resolvedDivision) { + // eslint-disable-next-line no-console + console.warn( + `[MLB standings] No division found for team "${entry.team.displayName}" (id=${entry.team.id}). ` + + "Update MLB_TEAM_DIVISION if this is a renamed or expansion team." + ); + } + return { entry, conference, division: resolvedDivision, sm: statsMap(entry.stats) }; + }); // Compute divisionRank: rank each team within its own division by win/loss. + // Key includes conference to prevent AL/NL teams from sharing a group when ESPN + // returns short names like "East" for both leagues, or no names at all. + // Sort by win/loss within each group so rank 1 is always the best record, + // regardless of the order ESPN returns teams in the response. const divisionRankMap = new Map(); const divisionGroups = new Map(); for (const item of withSm) { - const key = item.division; + const key = `${item.conference}:${item.division}`; if (!divisionGroups.has(key)) divisionGroups.set(key, []); divisionGroups.get(key)?.push(item); } for (const divEntries of divisionGroups.values()) { - // Use the ESPN entry order within each division — ESPN already applies correct tiebreakers. + divEntries.sort(sortByWinLoss); divEntries.forEach((e, i) => { divisionRankMap.set(e.entry.team.id, i + 1); });